diff --git a/.github/workflows/crowdin-download.yml b/.github/workflows/crowdin-download.yml deleted file mode 100644 index b37ae4f1f..000000000 --- a/.github/workflows/crowdin-download.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: i18n - Download -on: - workflow_dispatch: -# schedule: -# # runs every weekday at 12:30 PM UTC -# - cron: "30 12 * * 1-5" - -env: - GITHUB_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_PAT }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} - CROWDIN_API_URL: "https://freecodecamp.crowdin.com/api/v2/" - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - -jobs: - i18n-download-translations: - name: Download - runs-on: ubuntu-22.04 - strategy: - matrix: - node-version: [24.x] - fail-fast: true - - steps: - - name: Checkout Source Files - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - with: - token: ${{ secrets.CROWDIN_CAMPERBOT_PAT }} - - - name: Generate Crowdin Config - uses: freecodecamp/crowdin-action@main - env: - PLUGIN: "generate-config" - PROJECT_NAME: "mobile" - - ##### Download Chinese ##### - - name: Crowdin Download Chinese Translations - uses: crowdin/github-action@master - # options: https://github.com/crowdin/github-action/blob/master/action.yml - with: - # uploads - upload_sources: false - upload_translations: false - auto_approve_imported: false - import_eq_suggestions: false - - # downloads - download_translations: true - download_language: zh-CN - skip_untranslated_strings: false - skip_untranslated_files: false - export_only_approved: true - - push_translations: false - - # pull-request - create_pull_request: false - - # global options - config: "./crowdin-config.yml" - base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} - - # Uncomment below to debug - # dryrun_action: true - - ##### Download Spanish ##### - - name: Crowdin Download Spanish Translations - uses: crowdin/github-action@master - # options: https://github.com/crowdin/github-action/blob/master/action.yml - with: - # uploads - upload_sources: false - upload_translations: false - auto_approve_imported: false - import_eq_suggestions: false - - # downloads - download_translations: true - download_language: es-EM - skip_untranslated_strings: false - skip_untranslated_files: false - export_only_approved: true - - push_translations: false - - # pull-request - create_pull_request: false - - # global options - config: "./crowdin-config.yml" - base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} - - # Uncomment below to debug - # dryrun_action: true - - # We do not need to run tests because they are run after the PR is created. - - # Create Commit - - name: Commit Changes - uses: freecodecamp/crowdin-action@main - env: - PLUGIN: "commit-changes" - GH_USERNAME: "camperbot" - GH_EMAIL: ${{ secrets.ACTIONS_CAMPERBOT_EMAIL }} - GH_BRANCH: "i18n-sync" - GH_MESSAGE: "chore(i18n): processed translations" - - # Create PR - - name: Create PR - uses: freecodecamp/crowdin-action@main - env: - PLUGIN: "pull-request" - GH_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_PAT }} - BRANCH: "i18n-sync" - REPOSITORY: "freecodecamp/freecodecamp" - BASE: "main" - TITLE: "chore(i18n): processed translations" - BODY: "This PR was opened auto-magically by Crowdin." - LABELS: "crowdin-sync" - TEAM_REVIEWERS: "i18n" diff --git a/.github/workflows/crowdin-upload.yml b/.github/workflows/crowdin-upload.yml deleted file mode 100644 index 4fb353120..000000000 --- a/.github/workflows/crowdin-upload.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: i18n - Upload -on: - workflow_dispatch: -# schedule: -# # runs every weekday at 11:30 AM UTC -# - cron: '30 11 * * 1-5' - -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} - CROWDIN_API_URL: "https://freecodecamp.crowdin.com/api/v2/" - CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - -jobs: - i18n-upload-files: - name: Learn - runs-on: ubuntu-22.04 - - steps: - - name: Checkout Source Files - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - - name: Generate Crowdin Config - uses: freecodecamp/crowdin-action@main - env: - PLUGIN: "generate-config" - PROJECT_NAME: "mobile" - - - name: Crowdin Upload - uses: crowdin/github-action@master - # options: https://github.com/crowdin/github-action/blob/master/action.yml - with: - # uploads - upload_sources: true - upload_translations: false - auto_approve_imported: false - import_eq_suggestions: false - - # downloads - download_translations: false - - # pull-request - create_pull_request: false - - # global options - config: "./crowdin-config.yml" - base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} - - # Uncomment below to debug - # dryrun_action: true diff --git a/.github/workflows/flutter-unit-tests.yml b/.github/workflows/flutter-checks.yml similarity index 63% rename from .github/workflows/flutter-unit-tests.yml rename to .github/workflows/flutter-checks.yml index 7ce3a3b5d..9722b8d3e 100644 --- a/.github/workflows/flutter-unit-tests.yml +++ b/.github/workflows/flutter-checks.yml @@ -1,4 +1,4 @@ -name: Flutter Unit Tests +name: Flutter Checks on: pull_request: @@ -10,17 +10,17 @@ on: branches: [main, prod] jobs: - unit-tests: - name: Analyze and Run Unit Tests + checks: + name: Generated Files and Analysis defaults: run: working-directory: ./mobile-app runs-on: ubuntu-latest steps: - - name: Checkout files + - name: Checkout repository uses: actions/checkout@v4 - - name: Setup Flutter 3.44.x + - name: Set up Flutter 3.44.x uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 # v2 with: flutter-version: "3.44.x" @@ -29,7 +29,7 @@ jobs: cache-key: flutter-3.44.x cache-path: ${{ runner.tool_cache }}/flutter - - name: Create .env file + - name: Write .env run: | echo "HASHNODE_PUBLICATION_ID=$HASHNODE_PUBLICATION_ID" > .env echo "ALGOLIAAPPID=$ALGOLIA_APP_ID" >> .env @@ -43,14 +43,20 @@ jobs: AUTH0_DOMAIN: ${{ secrets.AUTH0_DOMAIN }} AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - - name: Install packages + - name: Get dependencies run: flutter pub get - - name: Analyze code - run: flutter analyze --no-fatal-warnings + - name: Analyze + run: | + dart analyze --no-fatal-warnings + flutter analyze --no-fatal-warnings - - name: Run unit tests + - name: Check generated files are up to date run: | - cd test - flutter test unit - flutter test services + dart run build_runner build + if [ -n "$(git status --porcelain -- .)" ]; then + git status --porcelain -- . + git diff -- . + echo "::error::Generated files are out of date. Run 'dart run build_runner build' and commit the result." + exit 1 + fi diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index 292d42d8b..b5cd6d706 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -34,9 +34,6 @@ jobs: - name: Checkout files uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Install SQLite libraries - run: sudo apt-get -y install libsqlite3-0 libsqlite3-dev - - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules diff --git a/.github/workflows/flutter-deploy.yml b/.github/workflows/flutter-deploy.yml index eaab764fe..8add3ecfd 100644 --- a/.github/workflows/flutter-deploy.yml +++ b/.github/workflows/flutter-deploy.yml @@ -28,9 +28,6 @@ jobs: - name: Checkout files uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - - name: Install SQLite libraries - run: sudo apt-get -y install libsqlite3-0 libsqlite3-dev - - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -95,6 +92,21 @@ jobs: - name: Install packages run: flutter pub get + - name: Analyze + run: | + dart analyze --no-fatal-warnings + flutter analyze --no-fatal-warnings + + - name: Check generated files are up to date + run: | + dart run build_runner build + if [ -n "$(git status --porcelain -- .)" ]; then + git status --porcelain -- . + git diff -- . + echo "::error::Generated files are out of date. Run 'dart run build_runner build' and commit the result." + exit 1 + fi + - name: Run tests in emulator uses: reactivecircus/android-emulator-runner@1dcd0090116d15e7c562f8db72807de5e036a4ed # v2 with: diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 75b4ea770..33b753f72 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,3 +1,6 @@ { - "!(app.*)*": "flutter analyze --no-fatal-warnings" + "mobile-app/**/*.dart": [ + "dart analyze --no-fatal-warnings", + "flutter analyze --no-fatal-warnings" + ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..6f69dbeb7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,120 @@ +# AGENTS.md + +freeCodeCamp's mobile app monorepo. + +| Folder | What it is | +|---|---| +| `mobile-app/` | Flutter app (Riverpod 3 + GoRouter + Freezed), package `freecodecamp`. | +| `mobile-api/` | Node backend for podcasts (`/podcasts`, `/podcasts/{id}/episodes`). | + +Everything below describes `mobile-app/`. The provider dependency graph is in [docs/LAYER_MAP.md](docs/LAYER_MAP.md). + +## Commands + +Run from `mobile-app/`: + +```sh +dart run build_runner build # after touching any @riverpod, @freezed, @JsonSerializable or @TypedGoRoute +dart analyze +flutter analyze +flutter drive --driver=test_driver/integration_test.dart --target=integration_test//_test.dart +dart integration_test_runner.dart [--curriculum] [--ios] +``` + +- Run both analyzers from the project root. +- A clean analyze is not verification. Run the change on a device. +- `.env` is bundled as an asset and must exist. Keys: `ALGOLIAAPPID`, `ALGOLIAKEY`, `HASHNODE_PUBLICATION_ID`, `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, `DEVELOPMENT_MODE` (switches .org/.dev), `SHOW_ALL_SB` (show all superblocks). + +## Architecture + +``` +lib/ + main.dart init: Firebase, Remote Config, audio, then runApp + routing/router.dart GoRouter; one routing/.dart per feature + services/ app-wide keepAlive state: auth, remote config, analytics, audio handler, dio, notifications + / news, podcasts, code_radio, learn, auth, settings + constants/ + controllers/ state shared by 2+ screens + models/ Freezed + repositories/ raw data sources only + services/ everything between controllers and repositories + ui/ + _shell.dart + /_view.dart + _viewmodel.dart viewmodel = state used by that screen only + widgets/ shared widgets, flat + widgets/ cross-feature UI (ErrorRetry, drawer, html_handler) + utils/ provider-less helpers +``` + +### Layers +- **Repository** is the raw data source: API client, file or SharedPreferences. It deals in raw shapes and never imports UI. +- **Service** wraps repositories. It handles mapping to models, business rules, coordination and lifecycle. +- **Controller / viewmodel** is the single source of truth for a screen or shared widget. +- **View** renders state from its controller/viewmodel and forwards user actions to it. No business logic, no repository or service calls where avoidable. + +Rules: +- A controller or viewmodel never watches a `…RepositoryProvider` directly. +- A view never imports another screen's viewmodel. If two screens need the same state, move it to `controllers/`. +- Don't collapse a layer just because it is thin. Judge a layer by the responsibility it owns. +- `lib/services/` holds app-wide, keepAlive providers that several features share: shared state (`AuthNotifier` → `authProvider`, Remote Config) and platform clients (audio handler, Dio, analytics, notifications). Shared state lives in a `…Notifier` whose methods are thin delegations to the feature's service; the repository, service, models and screens stay in `lib//` (auth's are in `lib/auth/`). +- Naming: the folder carries the feature, the filename doesn't repeat it, the class does (`lib/news/repositories/api_repository.dart` → `NewsApiRepository`). Every routed screen gets its own folder under `ui/`. Don't create single-file folders. + +### Riverpod 3 (codegen) +- Repositories and services are `@riverpod` function providers. State is `@riverpod class XNotifier`, and the generated provider drops the `Notifier` suffix (`NewsFeedNotifier` → `newsFeedProvider`). Check the `.g.dart` file. +- Providers are auto-dispose by default. Guard `state =` after an `await` with `if (ref.mounted)`. +- Never read `state` inside `build()`: it throws on the first build. Pass carried-over values as parameters instead. +- A keepAlive provider must not depend on an auto-dispose one (`only_use_keep_alive_inside_keep_alive`). +- Riverpod retries a failing provider up to 10 times (~38s of backoff, staying in `AsyncLoading`). Any provider with its own Retry button uses `@Riverpod(retry: noRetry)` from `lib/utils/riverpod.dart`. +- Links from `ref.keepAlive()` are dropped on every rebuild, not only on dispose. +- A family caches on its arguments. Don't pass closures, and give the arguments stable `==` (Freezed). Model a mode as a sealed Freezed union, not sentinel parameters. + +### Models +- Freezed 3: `sealed class` for unions, `abstract class` for data. +- For snake_case wire keys, use `@JsonKey(name:)` on parameters. +- Split list and detail models when the detail payload is heavy (`PostSummary` vs `Post`). + +### Routing +- Typed routes via `go_router_builder`, with path strings as top-level consts in `lib/routing/.dart`. +- **Concrete paths must be declared above parameterised ones**, because `$appRoutes` follows source order (`/news/image` above `/news/:slug`, `/podcasts/downloads` above `/podcasts/:podcastId`). +- Objects travel via `$extra`. That is only safe because nothing deep-links to those routes and state restoration is off. + +## Persisted data + +Installed apps already hold this data in the documents directory and SharedPreferences, so **paths and keys never change**. Downgrades never happen, so a format may change as long as the previous one is still read. + +| Data | Location | +|---|---| +| Bookmarks | `storage/bookmarked-articles.json`, keys `articleId`/`articleTitle`/`authorName`/`articleText` (mapped only in `bookmark_service.dart`) | +| Podcast downloads | `storage/podcast-downloads.json` (version 2, API key names; version 1 is normalised in memory on read), `episodes/`, `images/` | +| Podcast resume | prefs `{episodeId}_progress`, LRU index `podcast_recent_episodes` (cap 10) | +| Learn saved code | prefs `..` | + +- JSON files live inside the feature's own repository: an atomic write (`.tmp` then `rename`, no delete first), a serialised `Future` queue, and tolerant reads. Don't build a generic store. +- Podcast completion is signalled by the store row, never by file existence: dio writes the `.mp3` in place while downloading. The orphan sweep reads raw store entries, not parsed models. + +## Audio + +`AudioPlayerHandler` (`lib/services/audio_service.dart`) is shared by Code Radio, podcasts and Learn, and is injected in `main()` via `audioHandlerProvider.overrideWithValue`. +- Code Radio is one continuous live stream. Never reload on a song change; push `mediaItem` only. Resuming after a pause reconnects to rejoin the live edge. +- Never call `load()` after `setAudioSource`: it is already preloading, and a second call opens a second connection. +- Only advertise media controls that have a real override behind them. The control set depends on `_audioConfig`. +- Code Radio is never shipped on iOS (the drawer tile is hidden there). Keep `UIBackgroundModes: audio` anyway, because podcasts and Learn need it. + +## Startup + +- `main()` awaits Remote Config (10s fetch timeout) and audio init before `runApp`. Nothing else goes there: a hang before `runApp` means a black screen. +- Session restore runs under a deferred first frame (10s timeout). Post-frame work (notification permission, daily reminders, podcast orphan sweep) is registered after `allowFirstFrame`. +- Crashlytics and Analytics only collect in release builds. + +## Conventions + +- No `///` doc comments. Use plain one-line `//` only where a reader would otherwise change the code and break it. +- Strings are hardcoded English. There is no localisation. +- No unit tests yet. The curriculum e2e (`integration_test/test_runner/curriculum_test.dart`, run via `dart integration_test_runner.dart --curriculum`) is the required test. Other e2e tests live in `integration_test//`: + - Use type-based finders (`find.byType`, `tester.widget().field`). Widgets used as finders (e.g. `NewsPostHeader`) stay public. + - Device stdout is lossy on long runs. Return results through `binding.reportData`. +- Use `ErrorRetry` (`lib/widgets/error_retry.dart`) for async error states. +- Always give `CachedNetworkImage` explicit bounds. Unsized, it fills its constraints or jumps while loading. +- Cover art (podcasts, Code Radio) is always square: use `AspectRatio(1)` and never crop. +- Pin every dependency to the exact version it was tested with (no `^`). New dependencies start at the latest version. +- Hashnode's GraphQL API is slow upstream, and `graphql` 5.2.4 applies a 5s default request timeout. diff --git a/codemagic.yaml b/codemagic.yaml index a2b251e98..d960b0856 100644 --- a/codemagic.yaml +++ b/codemagic.yaml @@ -44,7 +44,19 @@ workflows: script: flutter pub get - name: Analyze code - script: flutter analyze --no-fatal-warnings + script: | + dart analyze --no-fatal-warnings + flutter analyze --no-fatal-warnings + + - name: Check generated files are up to date + script: | + dart run build_runner build + if [ -n "$(git status --porcelain -- .)" ]; then + git status --porcelain -- . + git diff -- . + echo "Generated files are out of date. Run 'dart run build_runner build' and commit the result." + exit 1 + fi - name: Install applesimutils script: | @@ -123,7 +135,19 @@ workflows: script: flutter pub get - name: Analyze code - script: flutter analyze --no-fatal-warnings + script: | + dart analyze --no-fatal-warnings + flutter analyze --no-fatal-warnings + + - name: Check generated files are up to date + script: | + dart run build_runner build + if [ -n "$(git status --porcelain -- .)" ]; then + git status --porcelain -- . + git diff -- . + echo "Generated files are out of date. Run 'dart run build_runner build' and commit the result." + exit 1 + fi - name: Install applesimutils script: | diff --git a/docs/LAYER_MAP.md b/docs/LAYER_MAP.md new file mode 100644 index 000000000..4566b1984 --- /dev/null +++ b/docs/LAYER_MAP.md @@ -0,0 +1,381 @@ +# Layer Map + +Every provider dependency in the app (`mobile-app/lib/`): which controllers reach which services, which services own which repositories, and where each repository gets its data. Views are not drawn. + +## Legend + +`view → controller / view-model → service → repository → data source` + +| Shape | Meaning | +|---|---| +| Gold border | **Controller**: state shared by 2+ screens, in `controllers/` | +| Dashed gold | **View-model**: state used by one screen, beside its view | +| Grey | **Service**: between controllers and repositories, owns business rules | +| Dark | **Repository**: raw data source only | +| Violet | **App-level service** in `lib/services/` | +| Dashed pill | **Data source** outside the app | +| Grey pill | `main.dart` | + +Solid arrows go one step down the chain. Dashed arrows leave it, to an app-level service, a data source or `main.dart`. An arrow labelled `read` is a one-shot `ref.read`; every other arrow is reactive. + +## Podcasts + +### Catalogue, library and downloads + +```mermaid +flowchart LR + PLVM["podcast_list_viewmodel"]:::vm + ELVM["episode_list_viewmodel"]:::vm + LIB["library_controller"]:::ctrl + DLC["downloads_controller"]:::ctrl + ROOT(["main.dart"]):::root + + APIS["api_service"]:::svc + LIBS["library_service"]:::svc + + APIR["api_repository"]:::repo + STOR["storage_repository"]:::repo + DLR["download_repository"]:::repo + + NOTIF["notification_service"]:::plat + DIO["dioProvider"]:::plat + + SRC1(["freeCodeCamp mobile API"]):::ext + SRC2(["podcast-downloads.json"]):::ext + SRC3(["episodes/ + images/ on disk"]):::ext + + PLVM --> APIS + ELVM --> APIS + ELVM --> LIB + DLC -->|read| LIB + DLC -->|read| LIBS + LIB --> LIBS + + APIS --> APIR + LIBS --> STOR + LIBS --> DLR + + APIR -.-> SRC1 + STOR -.-> SRC2 + DLR -.-> SRC3 + + LIBS -.-> NOTIF + ROOT -.->|read| LIBS + ROOT -.->|read| NOTIF + APIR -.-> DIO + DLR -.-> DIO + + classDef ctrl fill:#2A2A40,stroke:#FFC300,stroke-width:1.5px,color:#F5F6F7 + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 + classDef root fill:#15152a,stroke:#8E8EA8,stroke-width:1.5px,color:#DFDFE2 +``` + +### Playback and resume + +```mermaid +flowchart LR + PLY["player_controller"]:::ctrl + LIB2["library_controller"]:::ctrl + PBS["playback_service"]:::svc + + STOR2["storage_repository"]:::repo + DLR2["download_repository"]:::repo + PRGR["progress_repository"]:::repo + + AUDIO["audio_service
AudioPlayerHandler"]:::plat + SRC4(["SharedPreferences"]):::ext + + PLY --> PBS + PBS --> STOR2 + PBS --> DLR2 + PBS --> PRGR + PRGR -.-> SRC4 + + PLY -.-> AUDIO + PBS -.-> AUDIO + LIB2 -.->|read| AUDIO + + classDef ctrl fill:#2A2A40,stroke:#FFC300,stroke-width:1.5px,color:#F5F6F7 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 +``` + +There are two graphs because the code is split the same way: audio on one side, podcast data on the other. `storage_repository` and `download_repository` appear in both because both services own them. `library_controller` appears in the second only because it stops playback before an episode is deleted. + +**Screens:** `podcast_list_view`, `episode_list_view`, `episode_view`, `downloads_view`, plus the `episode_tile`, `download_button` and `podcast_grid_tile` widgets. + +- **Two controllers, deliberately separate.** `library_controller` answers "is this on disk?". `downloads_controller` tracks transfers in progress. The library holds data that changes rarely and many widgets watch; downloads writes progress several times a second. +- **`downloads_controller` only reads.** It calls `library_controller.refresh()` after a download, because only the notifier can push new state to what the grid and tiles watch. `refresh()` assigns `AsyncData` directly, where `ref.invalidate` would flash a spinner. +- **"Is this downloaded?" is answered by `playback_service.playbackUris()`** from the store row, not from the controller's cached state or the filesystem. +- **`playback_service` runs off-screen and owns resume progress.** It holds raw stream subscriptions, so positions keep saving when no podcast screen is mounted. +- **Only `storage_repository` is `keepAlive`**, so every write to `podcast-downloads.json` goes through one serialised queue, even after the rest of the chain has been disposed and rebuilt. `player_controller` holds a `KeepAliveLink` while the handler holds an episode, which also covers a pause (exactly as long as the media notification exists). `downloads_controller` holds one while a transfer is running. Everything below them stays alive through them. A failed download is not kept, so leaving the screen resets its retry icon. +- **Both API repositories share one `Dio`** through `dioProvider`. + +## News + +```mermaid +flowchart TD + PVM["post_viewmodel"]:::vm + AVM["author_feed_viewmodel"]:::vm + SVM["search_viewmodel"]:::vm + FEED["feed_controller"]:::ctrl + BOOK["bookmarks_controller"]:::ctrl + + APIS["api_service"]:::svc + BMS["bookmark_service"]:::svc + SRS["search_service"]:::svc + + APIR["api_repository"]:::repo + BMR["bookmark_repository"]:::repo + SRR["search_repository"]:::repo + + SRC1(["Hashnode GraphQL"]):::ext + SRC2(["bookmarked-articles.json"]):::ext + SRC3(["Algolia index"]):::ext + + PVM --> APIS + AVM --> APIS + FEED --> APIS + SVM --> SRS + BOOK --> BMS + + APIS --> APIR + BMS --> BMR + SRS --> SRR + + APIR -.-> SRC1 + BMR -.-> SRC2 + SRR -.-> SRC3 + + classDef ctrl fill:#2A2A40,stroke:#FFC300,stroke-width:1.5px,color:#F5F6F7 + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 +``` + +**Screens:** `feed_view`, `tag_feed_view`, `author_feed_view`, `post_view`, `search_view`, `bookmark_feed_view`, `bookmark_post_view`, `image_view`, plus the `post_feed_list` and `bookmark_button` widgets. + +- **The reference implementation.** It has three independent stacks; each service owns exactly one repository and nothing crosses between them. +- **`feed_controller` is a family** keyed by the sealed `NewsFeedSource` (all / tag / author), so the main, tag and author feeds share one implementation. +- **No app-level services.** The API repository builds its own `GraphQLClient` rather than using `dioProvider`. + +## Code Radio + +```mermaid +flowchart TD + PVM["player_viewmodel"]:::vm + CRS["code_radio_service"]:::svc + WSR["websocket_repository"]:::repo + AUDIO["audio_service
AudioPlayerHandler"]:::plat + SRC(["AzuraCast live websocket"]):::ext + + PVM --> CRS + CRS --> WSR + PVM -.->|read| AUDIO + CRS -.-> AUDIO + WSR -.-> SRC + + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 +``` + +**Screen:** `player_view`. + +- **The service drives the notification.** It holds a raw websocket subscription inside a keepAlive provider and pushes song changes to the audio handler, so the notification keeps updating off-screen. +- **No `controllers/` folder.** There is one screen, so the state is a view-model beside it. + +## Learn + +### Curriculum, challenges and the editor + +```mermaid +flowchart LR + CVM["challenge_viewmodel"]:::vm + EVM["editor_challenge_viewmodel"]:::vm + CC["curriculum_controller"]:::ctrl + SC["scene_controller"]:::ctrl + + CS["curriculum_service"]:::svc + CODE["code_service"]:::svc + TRS["test_runner_service"]:::svc + QS["quote_service"]:::svc + + CR["curriculum_repository"]:::repo + PR["prefs_repository"]:::repo + SESS["auth/session_repository"]:::repo + + RC["remote_config_service"]:::plat + AUTH["auth_service
AuthNotifier"]:::plat + AUDIO["audio_service
AudioPlayerHandler"]:::plat + DIO["dioProvider"]:::plat + + SRC1(["curriculum data JSON"]):::ext + SRC2(["SharedPreferences"]):::ext + SRC3(["localhost:8080 · assets/test_runner"]):::ext + + CVM --> CC + CVM --> CS + CVM -->|read| CODE + CVM -.->|read| AUTH + EVM -->|read| TRS + EVM -->|read| CODE + EVM --> QS + CC --> CS + SC --> CS + + CS --> CR + CODE --> PR + + CR -.-> SRC1 + PR -.-> SRC2 + TRS -.-> SRC3 + + CC -.-> RC + SC -.->|read| AUDIO + CR -.-> SESS + CR -.-> DIO + + classDef ctrl fill:#2A2A40,stroke:#FFC300,stroke-width:1.5px,color:#F5F6F7 + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 +``` + +### Daily challenge, reminders and the landing page + +```mermaid +flowchart LR + LVM["landing_viewmodel"]:::vm + DVM["daily_challenge_viewmodel"]:::vm + AVM["daily_challenge_archive_viewmodel"]:::vm + CVM2["challenge_viewmodel"]:::vm + ROOT(["main.dart"]):::root + + DCS["daily_challenge_service"]:::svc + REM["daily_challenge_reminder_service"]:::svc + QS2["quote_service"]:::svc + + DCR["daily_challenge_repository"]:::repo + PR2["prefs_repository"]:::repo + QR["quote_repository"]:::repo + SESS2["auth/session_repository"]:::repo + + NOTIF["notification_service"]:::plat + DIO2["dioProvider"]:::plat + + SRC4(["freeCodeCamp API
/daily-coding-challenge"]):::ext + SRC5(["SharedPreferences"]):::ext + SRC6(["motivational-quotes.json asset"]):::ext + + LVM --> DCS + LVM --> QS2 + DVM --> DCS + AVM --> DCS + CVM2 -->|read| DCS + CVM2 -->|read| REM + REM --> DCS + + DCS --> DCR + DCS --> PR2 + QS2 --> QR + + DCR -.-> SRC4 + PR2 -.-> SRC5 + QR -.-> SRC6 + + REM -.-> NOTIF + ROOT -.->|read| REM + DCR -.-> SESS2 + DCR -.-> DIO2 + + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 + classDef root fill:#15152a,stroke:#8E8EA8,stroke-width:1.5px,color:#DFDFE2 +``` + +The curriculum side and the daily-challenge side share no service. `challenge_viewmodel` appears in both because a daily challenge is solved on the ordinary challenge screen, which then records it through the daily services. + +**Screens:** `landing_view`, `superblock_view`, `module_view`, `challenge_view` (with its `english_view`, `lesson_view`, `quiz_view` and `python_project_view` bodies), `editor_challenge_view`, `daily_challenge_view`, `daily_challenge_archive_view`, `scene_fullscreen_view`, plus the `block_card`, `daily_challenge_card`, `complete_button`, `quiz_audio_player` and `scene/` widgets. + +- **`curriculum_controller` is the shared one.** The landing, superblock and module screens all watch it, and `challenge_viewmodel` watches its `superBlockProvider`. +- **Both API repositories use auth's `session_repository`**, which supplies `apiUrl` and wraps signed-in requests. Any 401 signs the user out everywhere. +- **The only service-to-service edge in any feature:** `daily_challenge_reminder_service` asks `daily_challenge_service` for today's challenge, then checks the user's completed list before rescheduling. `main.dart` calls it at startup. +- **`test_runner_service` is the only `keepAlive` in Learn.** It owns an `InAppLocalhostServer` on port 8080 plus headless web views (Babel and the test evaluator), so the next challenge never fights the previous one for the port. +- **Some views skip their view-model:** `editor_challenge_view` reads `code_service` and the test-runner server, `daily_challenge_view` watches `daily_challenge_service`, and `quiz_audio_player` and `scene_controls` use the audio handler. + +## Auth & Settings + +```mermaid +flowchart TD + LGVM["login_viewmodel"]:::vm + PFVM["profile_viewmodel"]:::vm + DAVM["delete_account_viewmodel"]:::vm + ROOT2(["main.dart"]):::root + + AUTH2["auth_service
AuthNotifier"]:::plat + SSVC["session_service"]:::svc + SREP["session_repository"]:::repo + DIO3["dioProvider"]:::plat + + SRC7(["Auth0"]):::ext + SRC8(["freeCodeCamp API"]):::ext + SRC9(["secure storage"]):::ext + + LGVM -.->|read| AUTH2 + LGVM -->|read| SSVC + PFVM -.->|read| AUTH2 + DAVM -.->|read| AUTH2 + ROOT2 -.->|read| AUTH2 + AUTH2 -.-> SSVC + + SSVC --> SREP + SSVC -.-> SRC7 + SREP -.-> SRC8 + SREP -.-> SRC9 + SREP -.-> DIO3 + + classDef vm fill:#23233a,stroke:#B98F1F,stroke-width:1.5px,stroke-dasharray:4 3,color:#DFDFE2 + classDef svc fill:#22223a,stroke:#6B6B85,stroke-width:1.5px,color:#DFDFE2 + classDef repo fill:#1B1B32,stroke:#4A4A63,stroke-width:1.5px,color:#DFDFE2 + classDef plat fill:#241736,stroke:#DBB8FF,stroke-width:1.5px,color:#EBD9FF + classDef ext fill:transparent,stroke:#585870,stroke-width:1px,stroke-dasharray:3 3,color:#9A9AA8 + classDef root fill:#15152a,stroke:#8E8EA8,stroke-width:1.5px,color:#DFDFE2 +``` + +**Screens:** `login_view`, `profile_view`, `settings_view`, `delete_account_view` and the app drawer. All of them also watch `authProvider` directly for the current user. + +- **`AuthNotifier` is the only place auth state changes.** View-models read the notifier and call its methods; only login goes below it, to start a sign-in flow. +- **The session layer is `keepAlive`** and lives for the app's lifetime. Learn's two API repositories depend on it too. + +## Regenerating + +The edges come from resolving every `ref.watch` / `ref.read` / `ref.listen` in `lib/` back to the file that declares the provider (map each `final xProvider` in `*.g.dart` to its source file). From `mobile-app/`: + +```sh +for f in $(find lib -name '*.dart' ! -name '*.g.dart' ! -name '*.freezed.dart' | sort); do + tr '\n' ' ' < "$f" | grep -oE 'ref *\. *(watch|read|listen) *\( *[A-Za-z_][A-Za-z0-9_]*' \ + | sed -E 's/ref *\. *//; s/ *\( */ /' | sort -u | while read m p; do echo "${f#lib/}|$p|$m"; done +done +``` + +Seven edges are invisible to this method because the provider sits behind a local name, so they are added by hand: +- `_provider` getters in `post_feed_list`, `episode_list_view` and `editor_challenge_view` +- `_runner` in `editor_challenge_viewmodel` +- a local `provider` variable in `challenge_view`, `complete_button` and `module_view` + +A pair that appears as both `read` and `watch` counts as a watch. Diff the output against the nodes already drawn: a wrong edge survives regeneration if nobody checks it. diff --git a/mobile-app/analysis_options.yaml b/mobile-app/analysis_options.yaml index 898dd2faa..b9b3128d0 100644 --- a/mobile-app/analysis_options.yaml +++ b/mobile-app/analysis_options.yaml @@ -10,12 +10,9 @@ analyzer: - windows/** - macos/** - linux/** - # Our generated files - - temp/** - - lib/app/app.dart - - lib/app/app.locator.dart - - lib/app/app.logger.dart - - lib/app/app.router.dart + language: + strict-raw-types: true + strict-casts: true errors: todo: info linter: @@ -28,5 +25,8 @@ linter: directives_ordering: true sort_pub_dependencies: true +plugins: + riverpod_lint: 3.1.4 + # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/mobile-app/android/app/build.gradle.kts b/mobile-app/android/app/build.gradle.kts index 7eab5bf92..649029699 100644 --- a/mobile-app/android/app/build.gradle.kts +++ b/mobile-app/android/app/build.gradle.kts @@ -73,5 +73,4 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") - implementation("com.google.android.material:material:1.11.0") } diff --git a/mobile-app/android/app/src/main/AndroidManifest.xml b/mobile-app/android/app/src/main/AndroidManifest.xml index 74e29e9b8..fae243710 100644 --- a/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/mobile-app/android/app/src/main/AndroidManifest.xml @@ -6,15 +6,16 @@ - - '; + } + + final linkedJs = _linked(html, 'script', 'src'); + for (final file in challenge.challengeFiles.where((f) => f.ext == Ext.js)) { + final name = file.fileName; + if (!linkedJs.contains(name)) continue; + + final document = parse(html); + document.querySelector('script[src\$="$name"]')!.text = await transpile( + await contents(challenge, file), + ); + html = document.outerHtml; + } + + return html; + } + + Set _linked(String html, String tag, String attribute) => { + for (final element in parse(html).getElementsByTagName(tag)) + if (element.attributes[attribute] case final value? when value.isNotEmpty) + value.split('/').last, + }; + + String _activateLinks(String html) { + final document = parse(html); + for (final (tag, attribute, name) in [ + ('link', 'href', 'styles.css'), + ('script', 'src', 'script.js'), + ]) { + for (final element in document.querySelectorAll(tag)) { + final value = element.attributes[attribute]; + if (value != name && value != './$name') continue; + element.attributes + ..remove(attribute) + ..['data-$attribute'] = name; + } + } + return document.outerHtml; + } +} diff --git a/mobile-app/lib/learn/services/code_service.g.dart b/mobile-app/lib/learn/services/code_service.g.dart new file mode 100644 index 000000000..1df5b1e24 --- /dev/null +++ b/mobile-app/lib/learn/services/code_service.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'code_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnCodeService) +final learnCodeServiceProvider = LearnCodeServiceProvider._(); + +final class LearnCodeServiceProvider + extends + $FunctionalProvider< + LearnCodeService, + LearnCodeService, + LearnCodeService + > + with $Provider { + LearnCodeServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'learnCodeServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnCodeServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + LearnCodeService create(Ref ref) { + return learnCodeService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LearnCodeService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$learnCodeServiceHash() => r'd4a83ca552e225bb114024b250f18a9e25473cd6'; diff --git a/mobile-app/lib/learn/services/curriculum_service.dart b/mobile-app/lib/learn/services/curriculum_service.dart new file mode 100644 index 000000000..38a66b1ce --- /dev/null +++ b/mobile-app/lib/learn/services/curriculum_service.dart @@ -0,0 +1,201 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:freecodecamp/learn/constants/curriculum.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/models/scene_assets_model.dart'; +import 'package:freecodecamp/learn/repositories/curriculum_repository.dart'; +import 'package:freecodecamp/services/remote_config_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'curriculum_service.g.dart'; + +typedef CurriculumStage = ({ + String key, + String title, + List superBlocks, +}); + +@riverpod +LearnCurriculumService learnCurriculumService(Ref ref) => + LearnCurriculumService(ref.watch(learnCurriculumRepositoryProvider)); + +class LearnCurriculumService { + LearnCurriculumService(this._repo); + + final LearnCurriculumRepository _repo; + + Future> getStages(RemoteConfig remoteConfig) async { + final stages = await _repo.getSuperBlockStages(); + final showAll = dotenv.getBool('SHOW_ALL_SB', fallback: false); + + return [ + for (final MapEntry(key: stage, value: title) in stageTitles.entries) + if (stages[stage] case final entries?) + ( + key: stage, + title: title, + superBlocks: [ + for (final entry in entries) + if (!(entry['dashedName'] as String).contains('full-stack')) + _toSummary(entry, remoteConfig, showAll), + ], + ), + ]; + } + + SuperBlockSummary _toSummary( + Map raw, + RemoteConfig remoteConfig, + bool showAll, + ) { + final dashedName = raw['dashedName'] as String; + final override = remoteConfig.activationOverride(dashedName); + + return SuperBlockSummary( + dashedName: dashedName, + title: raw['title'] as String, + isPublic: override ?? (showAll || raw['public'] == true), + isDisabledByOverride: override == false, + ); + } + + Future getSuperBlock( + String dashedName, + RemoteConfig remoteConfig, + ) async { + final raw = await _repo.getSuperBlock(dashedName); + + bool isActive(Map block) => + remoteConfig.activationOverride( + dashedName, + _meta(block)['dashedName'] as String, + ) != + false; + + if (!chapterBasedSuperBlocks.contains(dashedName)) { + final blocks = (raw['blocks'] as List).cast>() + ..sort((a, b) => _order(a).compareTo(_order(b))); + + return SuperBlock.blocks([ + for (final block in blocks) + _toBlock(block, dashedName, isDisabled: !isActive(block)), + ]); + } + + // NOTE: Unlike the flat path, disabled blocks are removed here, along with + // any module or chapter they leave empty. + return SuperBlock.chapters([ + for (final chapter + in (raw['chapters'] as List).cast>()) + if (_toChapter(chapter, dashedName, isActive) case final chapter + when chapter.modules.isNotEmpty) + chapter, + ]); + } + + Chapter _toChapter( + Map raw, + String superBlock, + bool Function(Map) isActive, + ) { + final modules = (raw['modules'] as List).cast>(); + + return Chapter( + dashedName: raw['dashedName'] as String, + name: raw['name'] as String, + comingSoon: raw['comingSoon'] == true, + modules: [ + for (final module in modules) + if (_toModule(module, superBlock, isActive) case final module + when module.blocks.isNotEmpty) + module, + ], + ); + } + + Module _toModule( + Map raw, + String superBlock, + bool Function(Map) isActive, + ) { + final blocks = (raw['blocks'] as List).cast>(); + + return Module( + dashedName: raw['dashedName'] as String, + name: raw['name'] as String? ?? 'No name', + comingSoon: raw['comingSoon'] == true, + moduleType: ModuleType.fromValue(raw['moduleType']), + blocks: [ + for (final block in blocks) + if (isActive(block)) _toBlock(block, superBlock), + ], + ); + } + + Block _toBlock( + Map raw, + String superBlock, { + bool isDisabled = false, + }) { + final meta = _meta(raw); + final description = [ + for (final paragraph in raw['intro'] as List? ?? const []) + if (paragraph is String && paragraph.trim().isNotEmpty) paragraph, + ]; + + return Block( + superBlock: superBlock, + dashedName: meta['dashedName'] as String, + name: meta['name'] as String, + layout: BlockLayout.fromValue(meta['blockLayout']), + // NOTE: `blockType` is the older name for `blockLabel`. + label: BlockLabel.fromValue(meta['blockLabel'] ?? meta['blockType']), + description: description.isEmpty + ? const ['Continue to improve your skills'] + : description, + challenges: [ + for (final challenge in meta['challengeOrder'] as List) + ChallengeOrder.fromJson(challenge as Map), + ], + isDisabledByOverride: isDisabled, + ); + } + + Map _meta(Map block) => + block['meta'] as Map; + + int _order(Map block) => _meta(block)['order'] as int? ?? 0; + + Future getChallenge( + String superBlock, + String block, + String challengeId, + ) async => Challenge.fromJson( + await _repo.getChallenge(superBlock, block, challengeId), + ); + + Future getSceneAssets() async => + SceneAssets.fromJson(await _repo.getSceneAssets()); + + Future submit( + Challenge challenge, { + String? solution, + List>? files, + }) async { + final submitTypes = await _repo.getSubmitTypes(); + final body = {'id': challenge.id, 'challengeType': challenge.challengeType}; + + switch (submitTypes['${challenge.challengeType}']) { + case 'tests': + await _repo.postModernChallengeCompleted({ + ...body, + if (challenge.challengeType == 14 || + challenge.block == + 'javascript-algorithms-and-data-structures-projects') + 'files': files, + }); + case 'project.backEnd': + await _repo.postProjectCompleted({...body, 'solution': solution}); + } + } +} diff --git a/mobile-app/lib/learn/services/curriculum_service.g.dart b/mobile-app/lib/learn/services/curriculum_service.g.dart new file mode 100644 index 000000000..fab45ac00 --- /dev/null +++ b/mobile-app/lib/learn/services/curriculum_service.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'curriculum_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnCurriculumService) +final learnCurriculumServiceProvider = LearnCurriculumServiceProvider._(); + +final class LearnCurriculumServiceProvider + extends + $FunctionalProvider< + LearnCurriculumService, + LearnCurriculumService, + LearnCurriculumService + > + with $Provider { + LearnCurriculumServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'learnCurriculumServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnCurriculumServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LearnCurriculumService create(Ref ref) { + return learnCurriculumService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LearnCurriculumService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$learnCurriculumServiceHash() => + r'4d2cb5cb3589a54d6d867bd3fa48c7fb634e8d12'; diff --git a/mobile-app/lib/learn/services/daily_challenge_reminder_service.dart b/mobile-app/lib/learn/services/daily_challenge_reminder_service.dart new file mode 100644 index 000000000..f23995431 --- /dev/null +++ b/mobile-app/lib/learn/services/daily_challenge_reminder_service.dart @@ -0,0 +1,86 @@ +import 'dart:developer'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:freecodecamp/auth/models/user_model.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:freecodecamp/services/notification_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'daily_challenge_reminder_service.g.dart'; + +@riverpod +LearnDailyChallengeReminderService learnDailyChallengeReminderService( + Ref ref, +) => LearnDailyChallengeReminderService( + ref.watch(notificationServiceProvider), + ref.watch(learnDailyChallengeServiceProvider), +); + +// NOTE: Fixed ids, so a reschedule replaces the previous week instead of stacking on it. +const _firstId = 9000; +const _days = 7; + +const _details = NotificationDetails( + android: AndroidNotificationDetails( + 'daily_challenge_channel', + 'Daily Challenge Notifications', + channelDescription: 'Notifications for new daily coding challenges', + priority: Priority.high, + importance: Importance.max, + color: FccColors.gray90, + ), + iOS: DarwinNotificationDetails( + threadIdentifier: 'daily-challenge-notification', + categoryIdentifier: 'DAILY_CHALLENGE', + ), +); + +class LearnDailyChallengeReminderService { + LearnDailyChallengeReminderService(this._notifications, this._daily); + + final NotificationService _notifications; + final LearnDailyChallengeService _daily; + + Future reschedule(FccUserModel? user) async { + try { + if (!await _notifications.enabled()) return; + + for (var i = 0; i < _days; i++) { + await _notifications.cancel(_firstId + i); + } + + final now = DateTime.now(); + final skipToday = await _completedToday(user); + for (var i = 0; i < _days; i++) { + final at = DateTime( + now.year, + now.month, + now.day + i + (skipToday ? 1 : 0), + 9, + ); + if (!at.isAfter(now)) continue; + + await _notifications.schedule( + id: _firstId + i, + title: 'Daily Coding Challenge', + body: "Today's coding challenge is ready. Can you solve it?", + at: at, + details: _details, + ); + } + } catch (e) { + log('Daily challenge reminders: could not schedule ($e)'); + } + } + + Future _completedToday(FccUserModel? user) async { + if (user == null) return false; + try { + final today = await _daily.getToday(); + return user.completedDailyCodingChallenges.any((c) => c.id == today.id); + } catch (_) { + return false; + } + } +} diff --git a/mobile-app/lib/learn/services/daily_challenge_reminder_service.g.dart b/mobile-app/lib/learn/services/daily_challenge_reminder_service.g.dart new file mode 100644 index 000000000..da5c22d33 --- /dev/null +++ b/mobile-app/lib/learn/services/daily_challenge_reminder_service.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'daily_challenge_reminder_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnDailyChallengeReminderService) +final learnDailyChallengeReminderServiceProvider = + LearnDailyChallengeReminderServiceProvider._(); + +final class LearnDailyChallengeReminderServiceProvider + extends + $FunctionalProvider< + LearnDailyChallengeReminderService, + LearnDailyChallengeReminderService, + LearnDailyChallengeReminderService + > + with $Provider { + LearnDailyChallengeReminderServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'learnDailyChallengeReminderServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => + _$learnDailyChallengeReminderServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LearnDailyChallengeReminderService create(Ref ref) { + return learnDailyChallengeReminderService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LearnDailyChallengeReminderService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider( + value, + ), + ); + } +} + +String _$learnDailyChallengeReminderServiceHash() => + r'f6f66188b5f1e447a59f166e6bdfa898c42ca392'; diff --git a/mobile-app/lib/learn/services/daily_challenge_service.dart b/mobile-app/lib/learn/services/daily_challenge_service.dart new file mode 100644 index 000000000..1fcc8c6d0 --- /dev/null +++ b/mobile-app/lib/learn/services/daily_challenge_service.dart @@ -0,0 +1,105 @@ +import 'package:collection/collection.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/daily_challenge_model.dart'; +import 'package:freecodecamp/learn/repositories/daily_challenge_repository.dart'; +import 'package:freecodecamp/learn/repositories/prefs_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'daily_challenge_service.g.dart'; + +typedef DailyChallengeMonth = ({ + DateTime month, + List challenges, +}); + +@riverpod +LearnDailyChallengeService learnDailyChallengeService(Ref ref) => + LearnDailyChallengeService( + ref.watch(learnDailyChallengeRepositoryProvider), + ref.watch(learnPrefsRepositoryProvider), + ); + +const _languageKey = 'selectedDailyChallengeLanguage'; + +class LearnDailyChallengeService { + LearnDailyChallengeService(this._repo, this._prefs); + + final LearnDailyChallengeRepository _repo; + final LearnPrefsRepository _prefs; + + Future getToday() async => + DailyChallengeOverview.fromJson(await _repo.getToday()); + + Future> getArchive() async { + final challenges = [ + for (final raw in await _repo.getAll()) + DailyChallengeOverview.fromJson(raw), + ]; + final byMonth = groupBy( + challenges, + (challenge) => DateTime.utc(challenge.date.year, challenge.date.month), + ); + + return [ + for (final month in byMonth.keys.sorted((a, b) => b.compareTo(a))) + ( + month: month, + challenges: byMonth[month]!.sorted( + (a, b) => b.challengeNumber.compareTo(a.challengeNumber), + ), + ), + ]; + } + + Future getChallenge(String monthDay) async { + final raw = await _repo.getDay(monthDay); + + for (final (language, name, ext) in [ + ('javascript', 'script', 'js'), + ('python', 'main', 'py'), + ]) { + for (final file + in raw[language]?['challengeFiles'] as List? ?? const []) { + (file as Map) + ..['name'] = name + ..['ext'] = ext; + } + } + + return DailyChallenge.fromJson(raw); + } + + Challenge toChallenge(DailyChallenge daily, DailyChallengeLanguage language) { + final (data, type) = switch (language) { + DailyChallengeLanguage.javascript => (daily.javascript, 28), + DailyChallengeLanguage.python => (daily.python, 29), + }; + + return Challenge( + id: daily.id, + block: 'daily-coding-challenge', + superBlock: 'daily-coding-challenge', + title: daily.title, + dashedName: '', + challengeType: type, + helpCategory: language.label, + description: daily.description, + tests: data.tests, + challengeFiles: data.challengeFiles, + ); + } + + Future getLanguage() async { + final saved = await _prefs.read(_languageKey); + return DailyChallengeLanguage.values.asNameMap()[saved] ?? + DailyChallengeLanguage.javascript; + } + + Future setLanguage(DailyChallengeLanguage language) => + _prefs.write(_languageKey, language.name); + + Future complete(Challenge challenge) => _repo.postCompleted({ + 'id': challenge.id, + 'language': challenge.challengeType == 29 ? 'python' : 'javascript', + }); +} diff --git a/mobile-app/lib/learn/services/daily_challenge_service.g.dart b/mobile-app/lib/learn/services/daily_challenge_service.g.dart new file mode 100644 index 000000000..c65125949 --- /dev/null +++ b/mobile-app/lib/learn/services/daily_challenge_service.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'daily_challenge_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnDailyChallengeService) +final learnDailyChallengeServiceProvider = + LearnDailyChallengeServiceProvider._(); + +final class LearnDailyChallengeServiceProvider + extends + $FunctionalProvider< + LearnDailyChallengeService, + LearnDailyChallengeService, + LearnDailyChallengeService + > + with $Provider { + LearnDailyChallengeServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'learnDailyChallengeServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnDailyChallengeServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LearnDailyChallengeService create(Ref ref) { + return learnDailyChallengeService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LearnDailyChallengeService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$learnDailyChallengeServiceHash() => + r'7634a3a37e1d72c66a9785151e642013fd24b5b9'; diff --git a/mobile-app/lib/learn/services/quote_service.dart b/mobile-app/lib/learn/services/quote_service.dart new file mode 100644 index 000000000..1c03b10c3 --- /dev/null +++ b/mobile-app/lib/learn/services/quote_service.dart @@ -0,0 +1,25 @@ +import 'dart:math'; + +import 'package:freecodecamp/learn/repositories/quote_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'quote_service.g.dart'; + +typedef MotivationalQuote = ({String quote, String author}); + +@riverpod +LearnQuoteService learnQuoteService(Ref ref) => + LearnQuoteService(ref.watch(learnQuoteRepositoryProvider)); + +class LearnQuoteService { + LearnQuoteService(this._repo); + + final LearnQuoteRepository _repo; + + Future getRandomQuote() async { + final quotes = await _repo.getQuotes(); + final quote = quotes[Random().nextInt(quotes.length)]; + + return (quote: quote['quote'] as String, author: quote['author'] as String); + } +} diff --git a/mobile-app/lib/learn/services/quote_service.g.dart b/mobile-app/lib/learn/services/quote_service.g.dart new file mode 100644 index 000000000..c6a5d4d0f --- /dev/null +++ b/mobile-app/lib/learn/services/quote_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'quote_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnQuoteService) +final learnQuoteServiceProvider = LearnQuoteServiceProvider._(); + +final class LearnQuoteServiceProvider + extends + $FunctionalProvider< + LearnQuoteService, + LearnQuoteService, + LearnQuoteService + > + with $Provider { + LearnQuoteServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'learnQuoteServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnQuoteServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + LearnQuoteService create(Ref ref) { + return learnQuoteService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LearnQuoteService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$learnQuoteServiceHash() => r'8dc7e1cb9a76a8d1df48b44215bc416a49dd2c15'; diff --git a/mobile-app/lib/learn/services/test_runner_service.dart b/mobile-app/lib/learn/services/test_runner_service.dart new file mode 100644 index 000000000..3e25aa290 --- /dev/null +++ b/mobile-app/lib/learn/services/test_runner_service.dart @@ -0,0 +1,256 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/utils/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'test_runner_service.g.dart'; + +typedef TestResult = ({bool pass, Map? err}); + +// NOTE: No auto-retry; the editor offers a manual retry instead. +// NOTE: keepAlive so the next challenge never races the previous one for port 8080. +@Riverpod(keepAlive: true, retry: noRetry) +Future learnTestRunnerServer(Ref ref) async { + final server = InAppLocalhostServer(documentRoot: 'assets/test_runner'); + ref.onDispose(server.close); + await server.start(); +} + +@Riverpod(retry: noRetry) +Future learnTestRunnerService( + Ref ref, + String workerType, +) async { + await ref.watch(learnTestRunnerServerProvider.future); + + final service = LearnTestRunnerService(workerType); + ref.onDispose(service.dispose); + await service.start(); + return service; +} + +class BabelException implements Exception { + BabelException(this.message); + + final String message; + + @override + String toString() => message; +} + +class LearnTestRunnerService { + LearnTestRunnerService(this.workerType); + + final String workerType; + + final _babelReady = Completer(); + final _runnerReady = Completer(); + final _console = StreamController.broadcast(); + + late final _babel = HeadlessInAppWebView( + initialData: InAppWebViewInitialData( + data: 'Babel', + baseUrl: WebUri('http://localhost:8080/babel-transformer'), + ), + initialSettings: InAppWebViewSettings(isInspectable: kDebugMode), + onConsoleMessage: (_, message) => + log('Babel console message: ${message.message}'), + onLoadStop: (controller, _) async { + final res = await controller.injectJavascriptFileFromAsset( + assetFilePath: 'assets/test_runner/babel/babel.min.js', + ); + log('Babel load: $res'); + if (!_babelReady.isCompleted) _babelReady.complete(); + }, + ); + + late final _runner = HeadlessInAppWebView( + initialData: InAppWebViewInitialData( + data: 'Test Runner', + baseUrl: WebUri('http://localhost:8080/test-runner'), + ), + initialSettings: InAppWebViewSettings( + isInspectable: kDebugMode, + mediaPlaybackRequiresUserGesture: false, + ), + onConsoleMessage: (_, message) { + if (message.messageLevel == ConsoleMessageLevel.LOG && + !_console.isClosed) { + _console.add(message.message); + } + }, + onLoadStop: (controller, _) async { + await _createRunner(controller); + if (!_runnerReady.isCompleted) _runnerReady.complete(); + }, + ); + + Stream get consoleMessages => _console.stream; + + Future start() async { + await _babel.run(); + await _runner.run(); + // NOTE: A webview that never finishes loading would otherwise hang here silently. + await ( + _babelReady.future, + _runnerReady.future, + ).wait.timeout(const Duration(seconds: 30)); + } + + Future dispose() async { + try { + await _console.close(); + await _runner.dispose(); + await _babel.dispose(); + } catch (e) { + log('Test runner: dispose failed ($e)'); + } + } + + Future transpile(String code, {required String challengeId}) async { + try { + return await _transpile(code); + } on BabelException { + if (_skipBabelErrorChallengeIds.contains(challengeId)) return code; + rethrow; + } + } + + Future _transpile(String code) async { + final res = await _babel.webViewController!.callAsyncJavaScript( + functionBody: _transpileScript, + arguments: {'code': code}, + ); + if (res?.error case final error?) throw BabelException(error); + + final value = res?.value as Map?; + if (value?['success'] == false) throw BabelException('${value?['error']}'); + return value?['code'] as String? ?? code; + } + + Future evaluate(String code) async => + (await _runner.webViewController!.callAsyncJavaScript( + functionBody: code, + ))?.error; + + Future> run({ + required String userCode, + required String combinedCode, + required String editableContents, + required Hooks hooks, + required List tests, + }) async { + final controller = _runner.webViewController!; + await _createRunner( + controller, + userCode: userCode, + combinedCode: combinedCode, + editableContents: editableContents, + hooks: hooks, + ); + + return [ + for (final test in tests) await _runTest(controller, test.testString), + ]; + } + + Future _runTest( + InAppWebViewController controller, + String testString, + ) async { + final res = await controller.callAsyncJavaScript( + functionBody: _testScript, + arguments: {'testStr': testString}, + ); + if (res == null) throw StateError('Test runner returned no result'); + + final value = res.value as Map?; + return ( + pass: value?['pass'] != null, + err: value?['err'] as Map?, + ); + } + + Future _createRunner( + InAppWebViewController controller, { + String userCode = '', + String combinedCode = '', + String editableContents = '', + Hooks hooks = const Hooks(), + }) => controller.callAsyncJavaScript( + functionBody: _runnerScript, + arguments: { + 'userCode': userCode, + 'workerType': workerType, + 'combinedCode': combinedCode, + 'editableRegionContent': editableContents, + 'hooks': { + 'beforeAll': hooks.beforeAll, + 'beforeEach': hooks.beforeEach, + 'afterEach': hooks.afterEach, + }, + }, + ); +} + +// NOTE: Babel syntax errors are expected in these steps +const _skipBabelErrorChallengeIds = { + // workshop-fcc-authors-page + '69e16a610b33e847d85be16e', // step 27 + '69e1e83ac6424325f77331e8', // step 28 + // workshop-library-search + '6a1c49231334592cc5a4c6b0', // step 5 +}; + +String syntaxErrorHint(BabelException error) { + final match = RegExp( + r'SyntaxError:.*?\((\d+):(\d+)\)', + ).firstMatch(error.message); + if (match == null) { + return 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; + } + + final line = int.parse(match.group(1)!) - 1; + return 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; +} + +String workerTypeOf(int challengeType) => switch (challengeType) { + 1 || 26 || 28 => 'javascript', + 20 || 23 || 27 || 29 => 'python', + _ => 'dom', +}; + +const _transpileScript = ''' +try { + return { success: true, code: Babel.transform(code, { presets: [["env", { "targets": "> 0.4%, not dead" }]] }).code }; +} catch (e) { + let errorMsg = e.message || e.toString(); + if (e.loc) { + errorMsg = "SyntaxError: " + e.message + " (" + e.loc.line + ":" + e.loc.column + ")"; + } + return { success: false, error: errorMsg }; +} +'''; + +const _runnerScript = ''' +await import("http://localhost:8080/dist/index.js"); +window.TestRunner = await window.FCCTestRunner.createTestRunner({ + source: userCode, + type: workerType, + assetPath: "/dist", + code: { + contents: combinedCode, + editableContents: editableRegionContent, + }, + hooks +}); +'''; + +const _testScript = ''' +const testRes = await window.TestRunner.runTest(testStr); +return testRes; +'''; diff --git a/mobile-app/lib/learn/services/test_runner_service.g.dart b/mobile-app/lib/learn/services/test_runner_service.g.dart new file mode 100644 index 000000000..af024c9ae --- /dev/null +++ b/mobile-app/lib/learn/services/test_runner_service.g.dart @@ -0,0 +1,123 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'test_runner_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(learnTestRunnerServer) +final learnTestRunnerServerProvider = LearnTestRunnerServerProvider._(); + +final class LearnTestRunnerServerProvider + extends $FunctionalProvider, void, FutureOr> + with $FutureModifier, $FutureProvider { + LearnTestRunnerServerProvider._() + : super( + from: null, + argument: null, + retry: noRetry, + name: r'learnTestRunnerServerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnTestRunnerServerHash(); + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return learnTestRunnerServer(ref); + } +} + +String _$learnTestRunnerServerHash() => + r'89c48950d65c444fb2a9b53fba87b1b79ea65a3c'; + +@ProviderFor(learnTestRunnerService) +final learnTestRunnerServiceProvider = LearnTestRunnerServiceFamily._(); + +final class LearnTestRunnerServiceProvider + extends + $FunctionalProvider< + AsyncValue, + LearnTestRunnerService, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + LearnTestRunnerServiceProvider._({ + required LearnTestRunnerServiceFamily super.from, + required String super.argument, + }) : super( + retry: noRetry, + name: r'learnTestRunnerServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$learnTestRunnerServiceHash(); + + @override + String toString() { + return r'learnTestRunnerServiceProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return learnTestRunnerService(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is LearnTestRunnerServiceProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$learnTestRunnerServiceHash() => + r'fd8a905a211dccc0cee57f657ef4268f9996810d'; + +final class LearnTestRunnerServiceFamily extends $Family + with $FunctionalFamilyOverride, String> { + LearnTestRunnerServiceFamily._() + : super( + retry: noRetry, + name: r'learnTestRunnerServiceProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + LearnTestRunnerServiceProvider call(String workerType) => + LearnTestRunnerServiceProvider._(argument: workerType, from: this); + + @override + String toString() => r'learnTestRunnerServiceProvider'; +} diff --git a/mobile-app/lib/learn/ui/challenge/challenge_view.dart b/mobile-app/lib/learn/ui/challenge/challenge_view.dart new file mode 100644 index 000000000..74e23c805 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/challenge_view.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/learn/ui/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/challenge/english_view.dart'; +import 'package:freecodecamp/learn/ui/challenge/lesson_view.dart'; +import 'package:freecodecamp/learn/ui/challenge/python_project_view.dart'; +import 'package:freecodecamp/learn/ui/challenge/quiz_view.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_challenge_view.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class ChallengeView extends ConsumerWidget { + const ChallengeView({ + super.key, + required this.superBlock, + required this.block, + required this.challengeId, + }); + + final String superBlock; + final String block; + final String challengeId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = challengeScreenProvider(superBlock, block, challengeId); + + switch (ref.watch(provider)) { + case AsyncData(value: (:final challenge, :final block)): + switch (challenge.challengeType) { + case 8: // quiz + return QuizView(challenge: challenge, block: block); + case 10: // pythonProject + return PythonProjectView(challenge: challenge, block: block); + case 11: // video + case 15: // theOdinProject + case 19: // multipleChoice + case 24: // generic + case 31: // review + return LessonView(challenge: challenge, block: block); + case 21: // dialogue + case 22: // fillInTheBlank + return EnglishView(challenge: challenge, block: block); + case 0: // html + case 1: // js + case 14: // multifileCertProject + case 20: // python + case 23: // multifilePythonCertProject + case 25: // lab + case 26: // jsLab + case 27: // pyLab + return EditorChallengeView(challenge: challenge, block: block); + default: + return _Message( + child: Text( + 'This challenge type (${challenge.challengeType}) is not available in the app yet.', + textAlign: TextAlign.center, + ), + ); + } + case AsyncError(): + return _Message( + child: ErrorRetry( + message: 'Something went wrong!', + onRetry: () => ref.invalidate(provider), + ), + ); + default: + return const _Message(child: CircularProgressIndicator()); + } + } +} + +class _Message extends StatelessWidget { + const _Message({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Center( + child: Padding(padding: const EdgeInsets.all(16), child: child), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.dart b/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.dart new file mode 100644 index 000000000..cb88804c9 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:collection/collection.dart'; +import 'package:freecodecamp/learn/controllers/curriculum_controller.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/services/code_service.dart'; +import 'package:freecodecamp/learn/services/curriculum_service.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_reminder_service.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'challenge_viewmodel.g.dart'; + +typedef ChallengeScreenData = ({Challenge challenge, Block block}); + +@riverpod +Future challengeScreen( + Ref ref, + String superBlock, + String block, + String challengeId, +) async { + final (challenge, curriculum) = await ( + ref + .watch(learnCurriculumServiceProvider) + .getChallenge(superBlock, block, challengeId), + ref.watch(superBlockProvider(superBlock).future), + ).wait; + + final blocks = switch (curriculum) { + BlockSuperBlock(:final blocks) => blocks, + ChapterSuperBlock(:final chapters) => [ + for (final chapter in chapters) + for (final module in chapter.modules) ...module.blocks, + ], + }; + final found = blocks.firstWhereOrNull((b) => b.dashedName == block); + if (found == null) throw StateError('Block $block not in $superBlock'); + + return (challenge: challenge, block: found); +} + +@riverpod +class ChallengeSubmitNotifier extends _$ChallengeSubmitNotifier { + @override + AsyncValue build(String challengeId) => const AsyncData(null); + + Future submit(Challenge challenge, {String? solution}) async { + if (!ref.read(authProvider).isLoggedIn) return true; + + final isDaily = const {28, 29}.contains(challenge.challengeType); + state = const AsyncLoading(); + final link = ref.keepAlive(); + try { + if (isDaily) { + await ref.read(learnDailyChallengeServiceProvider).complete(challenge); + } else { + await ref + .read(learnCurriculumServiceProvider) + .submit( + challenge, + solution: solution, + files: await ref + .read(learnCodeServiceProvider) + .filesPayload(challenge), + ); + } + await ref + .read(authProvider.notifier) + .refresh() + .catchError((Object e) => log('submit: profile refresh failed: $e')); + if (isDaily) { + unawaited( + ref + .read(learnDailyChallengeReminderServiceProvider) + .reschedule(ref.read(authProvider).value), + ); + } + if (ref.mounted) state = const AsyncData(null); + return true; + } catch (e, stack) { + if (ref.mounted) state = AsyncError(e, stack); + return false; + } finally { + link.close(); + } + } +} + +// NOTE: English tasks are titled "Task N"; dialogue headers and single-challenge blocks get no title. +String challengeTitle(Challenge challenge, Block block) { + final total = block.challenges.length; + if (total == 1 || + challenge.title.contains('Dialogue') || + challenge.title.contains('Step')) { + return ''; + } + + if (challenge.title.contains('Task')) { + final dialogues = block.challenges + .where((c) => c.title.contains('Dialogue')) + .length; + return '${challenge.title} of ${total - dialogues}'; + } + + final step = block.challenges.indexWhere((c) => c.id == challenge.id) + 1; + return 'Step $step of $total'; +} diff --git a/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.g.dart b/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.g.dart new file mode 100644 index 000000000..7b88b1665 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/challenge_viewmodel.g.dart @@ -0,0 +1,196 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(challengeScreen) +final challengeScreenProvider = ChallengeScreenFamily._(); + +final class ChallengeScreenProvider + extends + $FunctionalProvider< + AsyncValue, + ChallengeScreenData, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + ChallengeScreenProvider._({ + required ChallengeScreenFamily super.from, + required (String, String, String) super.argument, + }) : super( + retry: null, + name: r'challengeScreenProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$challengeScreenHash(); + + @override + String toString() { + return r'challengeScreenProvider' + '' + '$argument'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as (String, String, String); + return challengeScreen(ref, argument.$1, argument.$2, argument.$3); + } + + @override + bool operator ==(Object other) { + return other is ChallengeScreenProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$challengeScreenHash() => r'aaa487fd4d644debd080b833ffb921d83db9fd1a'; + +final class ChallengeScreenFamily extends $Family + with + $FunctionalFamilyOverride< + FutureOr, + (String, String, String) + > { + ChallengeScreenFamily._() + : super( + retry: null, + name: r'challengeScreenProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ChallengeScreenProvider call( + String superBlock, + String block, + String challengeId, + ) => ChallengeScreenProvider._( + argument: (superBlock, block, challengeId), + from: this, + ); + + @override + String toString() => r'challengeScreenProvider'; +} + +@ProviderFor(ChallengeSubmitNotifier) +final challengeSubmitProvider = ChallengeSubmitNotifierFamily._(); + +final class ChallengeSubmitNotifierProvider + extends $NotifierProvider> { + ChallengeSubmitNotifierProvider._({ + required ChallengeSubmitNotifierFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'challengeSubmitProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$challengeSubmitNotifierHash(); + + @override + String toString() { + return r'challengeSubmitProvider' + '' + '($argument)'; + } + + @$internal + @override + ChallengeSubmitNotifier create() => ChallengeSubmitNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + + @override + bool operator ==(Object other) { + return other is ChallengeSubmitNotifierProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$challengeSubmitNotifierHash() => + r'9bb6ff7516f2234b8604744bc5a092b0281edc95'; + +final class ChallengeSubmitNotifierFamily extends $Family + with + $ClassFamilyOverride< + ChallengeSubmitNotifier, + AsyncValue, + AsyncValue, + AsyncValue, + String + > { + ChallengeSubmitNotifierFamily._() + : super( + retry: null, + name: r'challengeSubmitProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + ChallengeSubmitNotifierProvider call(String challengeId) => + ChallengeSubmitNotifierProvider._(argument: challengeId, from: this); + + @override + String toString() => r'challengeSubmitProvider'; +} + +abstract class _$ChallengeSubmitNotifier extends $Notifier> { + late final _$args = ref.$arg as String; + String get challengeId => _$args; + + AsyncValue build(String challengeId); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, AsyncValue>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AsyncValue>, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/english_view.dart b/mobile-app/lib/learn/ui/challenge/english_view.dart new file mode 100644 index 000000000..9effedf8d --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/english_view.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/assignment_tile.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/complete_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/help_dialog.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/html_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_view.dart'; +import 'package:html/parser.dart'; + +const _wordStyle = TextStyle(fontSize: 20, letterSpacing: 0); + +String _plain(String html) => parseFragment(html).text ?? ''; + +class EnglishView extends StatefulWidget { + const EnglishView({super.key, required this.challenge, required this.block}); + + final Challenge challenge; + final Block block; + + @override + State createState() => _EnglishViewState(); +} + +class _EnglishViewState extends State { + late final _blanks = widget.challenge.fillInTheBlank?.blanks ?? const []; + late final _inputs = [for (final _ in _blanks) TextEditingController()]; + late final _assignments = List.filled( + widget.challenge.assignments.length, + false, + ); + List? _results; + + bool get _allCorrect => _results?.every((r) => r) ?? false; + + @override + void dispose() { + for (final input in _inputs) { + input.dispose(); + } + super.dispose(); + } + + void _check() => setState(() { + _results = [ + for (final (i, blank) in _blanks.indexed) + _inputs[i].text.trim().toLowerCase() == blank.answer.toLowerCase(), + ]; + }); + + @override + Widget build(BuildContext context) { + final challenge = widget.challenge; + final gray = { + '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( + color: FccColors.gray05, + ), + }; + final results = _results; + final firstWrong = results?.indexOf(false) ?? -1; + final feedback = firstWrong == -1 ? '' : _blanks[firstWrong].feedback; + + return Scaffold( + backgroundColor: FccColors.gray90, + appBar: AppBar( + backgroundColor: FccColors.gray90, + title: Text(challengeTitle(challenge, widget.block)), + ), + body: SafeArea( + child: ListView( + children: [ + ChallengeCard( + title: challenge.title, + child: Column( + children: [ + ParsedHtml(challenge.description, customStyles: gray), + ParsedHtml(challenge.instructions, customStyles: gray), + ], + ), + ), + if (challenge.scene case final scene?) + ChallengeCard( + title: 'Scene', + child: SceneView(scene: scene, title: challenge.title), + ), + if (challenge.assignments.isNotEmpty) + ChallengeCard( + title: 'Assignments', + child: Column( + children: [ + for (final (i, assignment) in challenge.assignments.indexed) + AssignmentTile( + label: assignment, + value: _assignments[i], + onToggle: () => + setState(() => _assignments[i] = !_assignments[i]), + ), + ], + ), + ), + if (challenge.fillInTheBlank case final fill?) + ChallengeCard( + title: 'Fill in the Blank', + child: Padding( + padding: const EdgeInsets.all(10), + child: Column(children: _sentences(fill.sentence)), + ), + ), + if (feedback.isNotEmpty) + HtmlCard(title: 'Feedback', html: feedback), + if (challenge.explanation.isNotEmpty) + HtmlCard( + title: 'Explanation', + html: challenge.explanation, + collapsible: true, + ), + if (_blanks.isEmpty || _allCorrect) + CompleteButton( + challenge: challenge, + block: widget.block, + label: 'Go to Next Challenge', + enabled: _assignments.every((done) => done), + ) + else + ChallengeButton(label: 'Check Answers', onPressed: _check), + ChallengeButton( + label: 'Ask for Help', + onPressed: () => + showHelpDialog(context, challenge, widget.block.name), + ), + ], + ), + ), + ); + } + + // NOTE: A blank can be glued to the words around it ("BLANK," or "(BLANK)"), so each word splits on it. + List _sentences(String sentence) { + var blank = 0; + + return [ + for (final line in sentence.split('\n')) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Align( + alignment: Alignment.topLeft, + child: Wrap( + spacing: 3, + runSpacing: 3, + children: [ + for (final word in line.split(' ')) + if (word.contains('BLANK')) ...[ + for (final (i, part) in word.split('BLANK').indexed) ...[ + if (i > 0) _input(blank++), + if (part.isNotEmpty) + Text(_plain(part), style: _wordStyle), + ], + ] else + Text(_plain(word), style: _wordStyle), + ], + ), + ), + ), + ]; + } + + Widget _input(int index) { + final result = _results?[index]; + final border = OutlineInputBorder( + borderSide: switch (result) { + null => const BorderSide(color: FccColors.blue50), + true => const BorderSide(width: 2, color: Colors.green), + false => const BorderSide(width: 2, color: Colors.red), + }, + ); + final painter = TextPainter( + // NOTE: Two extra characters of leeway, so a typo or the cursor isn't clipped. + text: TextSpan(text: '${_blanks[index].answer}mm', style: _wordStyle), + textDirection: TextDirection.ltr, + )..layout(); + final answerWidth = painter.width; + painter.dispose(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 5), + width: answerWidth, + child: TextField( + controller: _inputs[index], + cursorHeight: 19, + autocorrect: false, + smartQuotesType: SmartQuotesType.disabled, + spellCheckConfiguration: const SpellCheckConfiguration.disabled(), + style: const TextStyle(fontSize: 19, letterSpacing: 0), + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 4), + enabledBorder: border, + focusedBorder: border, + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/lesson_view.dart b/mobile-app/lib/learn/ui/challenge/lesson_view.dart new file mode 100644 index 000000000..f91c856cc --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/lesson_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/assignment_tile.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_video.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/complete_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/example_editor.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/help_dialog.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/html_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/questions.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_view.dart'; + +class LessonView extends StatefulWidget { + const LessonView({super.key, required this.challenge, required this.block}); + + final Challenge challenge; + final Block block; + + @override + State createState() => _LessonViewState(); +} + +class _LessonViewState extends State { + late final _questions = widget.challenge.questions.map(questionItem).toList(); + late final _selected = List.filled(_questions.length, null); + late final _assignments = List.filled( + widget.challenge.assignments.length, + false, + ); + bool _validated = false; + + bool get _passed => [ + for (final (i, q) in _questions.indexed) _selected[i] == q.solution, + ].every((correct) => correct); + + bool get _canCheck => + _assignments.every((done) => done) && !_selected.contains(null); + + @override + Widget build(BuildContext context) { + final challenge = widget.challenge; + final hasQuestions = _questions.isNotEmpty; + final showHelp = ![24, 31].contains(challenge.challengeType); + + return Scaffold( + appBar: AppBar(title: Text(challengeTitle(challenge, widget.block))), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.only(bottom: 50), + children: [ + if (challenge.description.isNotEmpty) + HtmlCard(title: 'Description', html: challenge.description), + if (challenge.nodules.isNotEmpty) + ExampleEditor(nodules: challenge.nodules), + if (challenge.videoId case final videoId?) + ChallengeCard( + title: 'Watch the Video', + child: ChallengeVideo(videoId: videoId), + ), + if (challenge.transcript.isNotEmpty) + HtmlCard( + title: 'Transcript', + html: challenge.transcript, + collapsible: challenge.videoId != null, + ), + if (challenge.scene case final scene?) + ChallengeCard( + title: 'Scene', + child: SceneView(scene: scene, title: challenge.title), + ), + if (challenge.instructions.isNotEmpty) + HtmlCard(title: 'Instructions', html: challenge.instructions), + if (challenge.assignments.isNotEmpty) + ChallengeCard( + title: 'Assignments', + child: Column( + children: [ + for (final (i, assignment) in challenge.assignments.indexed) + AssignmentTile( + label: assignment, + value: _assignments[i], + onToggle: () => + setState(() => _assignments[i] = !_assignments[i]), + ), + ], + ), + ), + Questions( + questions: _questions, + selected: _selected, + validated: _validated, + onChanged: (question, answer) => setState(() { + _selected[question] = answer; + _validated = false; + }), + ), + if (challenge.explanation.isNotEmpty) + HtmlCard( + title: 'Explanation', + html: challenge.explanation, + collapsible: true, + ), + if (_validated && !_passed && _questions.length > 1) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Some answers are incorrect.', + style: TextStyle(fontSize: 18), + ), + ), + if (!hasQuestions || (_validated && _passed)) + CompleteButton( + challenge: challenge, + block: widget.block, + label: hasQuestions ? 'Next Challenge' : 'Next', + enabled: _canCheck, + ) + else + ChallengeButton( + label: _validated ? 'Try Again' : 'Check your Answer', + onPressed: _canCheck + ? () => setState(() => _validated = true) + : null, + ), + if (showHelp) + ChallengeButton( + label: 'Ask for Help', + onPressed: () => + showHelpDialog(context, challenge, widget.block.name), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/python_project_view.dart b/mobile-app/lib/learn/ui/challenge/python_project_view.dart new file mode 100644 index 000000000..c8cb3281b --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/python_project_view.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/complete_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/help_dialog.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; + +final _fccRegex = RegExp( + r'codepen\.io\/freecodecamp|freecodecamp\.rocks|github\.com\/freecodecamp|\.freecodecamp\.org', + caseSensitive: false, +); +final _localhostRegex = RegExp(r'localhost:'); +final _httpRegex = RegExp(r'http(?!s|([^s]+?localhost))'); + +String? _linkError(String link) { + if (!(Uri.tryParse(link)?.isAbsolute ?? false)) { + return 'Please enter a valid link.'; + } + if (_fccRegex.hasMatch(link)) return 'Remember to submit your own work.'; + if (_httpRegex.hasMatch(link)) { + return 'An unsecure (http) URL cannot be used.'; + } + if (_localhostRegex.hasMatch(link)) { + return 'Remember to submit a publicly visible app URL.'; + } + return null; +} + +class PythonProjectView extends StatefulWidget { + const PythonProjectView({ + super.key, + required this.challenge, + required this.block, + }); + + final Challenge challenge; + final Block block; + + @override + State createState() => _PythonProjectViewState(); +} + +class _PythonProjectViewState extends State { + final _link = TextEditingController(); + bool _checked = false; + String? _error; + + bool get _valid => _checked && _error == null; + + @override + void dispose() { + _link.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final challenge = widget.challenge; + final border = BorderSide( + color: _valid ? Colors.green : FccColors.gray00, + width: 2, + ); + + return Scaffold( + appBar: AppBar(title: Text(challenge.title)), + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 48), + children: [ + ParsedHtml(challenge.description), + const Divider(color: FccColors.gray00), + ParsedHtml(challenge.instructions), + const Divider(color: FccColors.gray00), + const Padding( + padding: EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Text( + 'Solution Link', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: TextField( + controller: _link, + keyboardType: TextInputType.url, + autocorrect: false, + onChanged: (_) => setState(() { + _checked = false; + _error = null; + }), + decoration: InputDecoration( + hintText: 'ex: https://replit.com/@camperbot/hello', + errorText: _error, + errorMaxLines: 5, + enabledBorder: OutlineInputBorder( + borderSide: border.copyWith(width: 1), + ), + focusedBorder: OutlineInputBorder(borderSide: border), + ), + ), + ), + const SizedBox(height: 8), + if (_valid) + CompleteButton( + challenge: challenge, + block: widget.block, + label: 'Submit', + solution: _link.text, + ) + else + ChallengeButton( + label: 'Check Link', + onPressed: _link.text.isEmpty + ? null + : () => setState(() { + _checked = true; + _error = _linkError(_link.text); + }), + ), + ChallengeButton( + label: 'Ask for Help', + onPressed: () => + showHelpDialog(context, challenge, widget.block.name), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/quiz_view.dart b/mobile-app/lib/learn/ui/challenge/quiz_view.dart new file mode 100644 index 000000000..67698a03a --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/quiz_view.dart @@ -0,0 +1,191 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/complete_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/questions.dart'; +import 'package:freecodecamp/widgets/dialog/confirm_dialog.dart'; + +// NOTE: The quiz picks one of its question sets at random and shuffles each question's answers. +List _pickQuestions(List quizzes) { + if (quizzes.isEmpty) return const []; + + final random = Random(); + final quiz = quizzes[random.nextInt(quizzes.length)]; + + return [ + for (final q in quiz.questions) + () { + final answers = [ + for (final text in [...q.distractors, q.answer]) Answer(answer: text), + ]..shuffle(random); + + return ( + text: q.text, + answers: answers, + solution: answers.indexWhere((a) => a.answer == q.answer), + audioData: q.audioData, + ); + }(), + ]; +} + +class QuizView extends StatefulWidget { + const QuizView({super.key, required this.challenge, required this.block}); + + final Challenge challenge; + final Block block; + + @override + State createState() => _QuizViewState(); +} + +class _QuizViewState extends State { + static const _passRate = 0.9; + + late final _questions = _pickQuestions(widget.challenge.quizzes); + late List _selected = List.filled(_questions.length, null); + bool _validated = false; + String _message = ''; + + int get _correct => [ + for (final (i, q) in _questions.indexed) + if (_selected[i] == q.solution) q, + ].length; + + bool get _passed => + _validated && _correct >= (_questions.length * _passRate).ceil(); + + void _check() { + final unanswered = [ + for (final (i, answer) in _selected.indexed) + if (answer == null) i + 1, + ]; + + setState(() { + if (unanswered.isNotEmpty) { + _message = + 'The following questions are unanswered: ${unanswered.join(', ')}. You must answer all questions.'; + return; + } + + _validated = true; + final total = _questions.length; + _message = _passed + ? '✅ You have $_correct out of $total questions correct. You have passed.' + : '❌ You have $_correct out of $total questions correct. You did not pass.'; + }); + } + + void _reset() => setState(() { + _selected = List.filled(_questions.length, null); + _validated = false; + _message = ''; + }); + + @override + Widget build(BuildContext context) { + final challenge = widget.challenge; + final gray = { + '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( + color: FccColors.gray05, + ), + }; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + + final leave = await showConfirmDialog( + context, + title: 'Are you sure?', + description: + 'Do you want to leave this quiz? Your progress will be lost.', + confirmLabel: 'Leave', + destructive: true, + ); + if (leave && context.mounted) Navigator.of(context).pop(); + }, + child: Scaffold( + backgroundColor: FccColors.gray90, + appBar: AppBar(backgroundColor: FccColors.gray90), + body: SafeArea( + bottom: false, + child: ListView( + padding: const EdgeInsets.only(bottom: 16), + children: [ + ChallengeCard( + title: challenge.title, + child: Column( + children: [ + ParsedHtml(challenge.description, customStyles: gray), + ParsedHtml(challenge.instructions, customStyles: gray), + ], + ), + ), + Questions( + questions: _questions, + selected: _selected, + validated: _validated, + onChanged: (question, answer) => setState(() { + _selected[question] = answer; + _validated = false; + _message = ''; + }), + ), + ], + ), + ), + bottomNavigationBar: Container( + decoration: BoxDecoration( + color: FccColors.gray80, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_message.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + child: Text( + _message, + style: const TextStyle(fontSize: 18), + ), + ), + if (_passed) + CompleteButton( + challenge: challenge, + block: widget.block, + label: 'Next Challenge', + ) + else + ChallengeButton( + label: _validated ? 'Try Again' : 'Check your Answer', + onPressed: _validated ? _reset : _check, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/assignment_tile.dart b/mobile-app/lib/learn/ui/challenge/widgets/assignment_tile.dart new file mode 100644 index 000000000..d0377ce46 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/assignment_tile.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +class AssignmentTile extends StatelessWidget { + const AssignmentTile({ + super.key, + required this.label, + required this.value, + required this.onToggle, + }); + + final String label; + final bool value; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Material( + type: MaterialType.transparency, + child: CheckboxListTile( + value: value, + onChanged: (_) => onToggle(), + controlAffinity: ListTileControlAffinity.leading, + activeColor: const Color(0xDEFFFFFF), + checkColor: FccColors.gray90, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: HTMLParser( + context: context, + ).parse(label, isSelectable: false), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/challenge_button.dart b/mobile-app/lib/learn/ui/challenge/widgets/challenge_button.dart new file mode 100644 index 000000000..4532dce97 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/challenge_button.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +class ChallengeButton extends StatelessWidget { + const ChallengeButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + backgroundColor: FccColors.gray75, + side: const BorderSide(width: 2, color: FccColors.gray00), + shape: const RoundedRectangleBorder(), + ), + onPressed: loading ? null : onPressed, + child: loading + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(label, style: const TextStyle(fontSize: 20)), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/challenge_card.dart b/mobile-app/lib/learn/ui/challenge/widgets/challenge_card.dart new file mode 100644 index 000000000..3ae7d4b49 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/challenge_card.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +class ChallengeCard extends StatelessWidget { + const ChallengeCard({super.key, required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: FccColors.gray85, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 14, left: 8, right: 8), + child: Text( + title.toUpperCase(), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + child, + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/challenge_video.dart b/mobile-app/lib/learn/ui/challenge/widgets/challenge_video.dart new file mode 100644 index 000000000..179cb2cba --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/challenge_video.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:youtube_player_iframe/youtube_player_iframe.dart'; + +class ChallengeVideo extends StatefulWidget { + const ChallengeVideo({super.key, required this.videoId}); + + final String videoId; + + @override + State createState() => _ChallengeVideoState(); +} + +class _ChallengeVideoState extends State { + late final _controller = YoutubePlayerController.fromVideoId( + videoId: widget.videoId, + params: const YoutubePlayerParams( + showFullscreenButton: true, + strictRelatedVideos: true, + origin: 'https://www.youtube-nocookie.com', + ), + ); + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: YoutubePlayer( + controller: _controller, + enableFullScreenOnVerticalDrag: false, + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/complete_button.dart b/mobile-app/lib/learn/ui/challenge/widgets/complete_button.dart new file mode 100644 index 000000000..a0d86fa55 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/complete_button.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_button.dart'; +import 'package:freecodecamp/routing/learn.dart'; + +class CompleteButton extends ConsumerWidget { + const CompleteButton({ + super.key, + required this.challenge, + required this.block, + required this.label, + this.solution, + this.enabled = true, + }); + + final Challenge challenge; + final Block? block; + final String label; + final String? solution; + final bool enabled; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = challengeSubmitProvider(challenge.id); + final submit = ref.watch(provider); + + return Column( + children: [ + if (submit.hasError) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Your progress could not be saved. Check your connection and try again.', + style: TextStyle(fontSize: 16, color: FccColors.red15), + ), + ), + ChallengeButton( + label: label, + loading: submit.isLoading, + onPressed: !enabled + ? null + : () async { + final saved = await ref + .read(provider.notifier) + .submit(challenge, solution: solution); + if (saved && context.mounted) _goToNext(context); + }, + ), + ], + ); + } + + void _goToNext(BuildContext context) { + final block = this.block; + final index = + block?.challenges.indexWhere((c) => c.id == challenge.id) ?? -1; + if (block == null || index == -1 || index == block.challenges.length - 1) { + // NOTE: Inside an open drawer a plain pop only closes the drawer. + final route = ModalRoute.of(context); + Navigator.of(context).popUntil((r) => r != route); + return; + } + + LearnChallengeRoute( + superBlock: block.superBlock, + block: block.dashedName, + challengeId: block.challenges[index + 1].id, + ).pushReplacement(context); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/example_editor.dart b/mobile-app/lib/learn/ui/challenge/widgets/example_editor.dart new file mode 100644 index 000000000..6bb8393ae --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/example_editor.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; +import 'package:phone_ide/phone_ide.dart'; + +class ExampleEditor extends StatefulWidget { + const ExampleEditor({super.key, required this.nodules}); + + final List nodules; + + @override + State createState() => _ExampleEditorState(); +} + +class _ExampleEditorState extends State { + Widget? _card; + + @override + Widget build(BuildContext context) => _card ??= _build(context); + + Widget _build(BuildContext context) { + final parser = HTMLParser(context: context); + + return ChallengeCard( + title: 'Lesson', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final nodule in widget.nodules) + ...switch (nodule) { + ParagraphNodule(:final contents) => parser.parse( + contents, + customStyles: { + '*': Style(fontSize: FontSize(18)), + 'ul': Style( + fontSize: FontSize(18), + padding: HtmlPaddings.only(left: 10), + ), + }, + ), + InteractiveEditorNodule(:final files) => [ + for (final file in files) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.ext.toUpperCase(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Editor( + options: EditorOptions( + fontFamily: 'Hack', + takeFullHeight: false, + showLinebar: false, + isEditable: false, + ), + defaultLanguage: file.ext, + defaultValue: file.contents, + path: '/', + ), + ], + ), + ), + ], + }, + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/help_dialog.dart b/mobile-app/lib/learn/ui/challenge/widgets/help_dialog.dart new file mode 100644 index 000000000..5d7e3e166 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/help_dialog.dart @@ -0,0 +1,291 @@ +import 'dart:io'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const _forum = 'https://forum.freecodecamp.org'; +const _readSearchAsk = + '$_forum/t/how-to-get-help-when-you-are-stuck-coding/19514'; +const _minDescription = 50; +// NOTE: Above this the forum rejects the URL, so the post body falls back to a shorter template. +const _maxUrlLength = 8000; + +Future showHelpDialog( + BuildContext context, + Challenge challenge, + String blockName, { + String code = '', +}) async { + final search = Uri.parse( + '$_forum/search?q=${Uri.encodeComponent('$blockName - ${challenge.title} in:title')}', + ); + + final proceed = await showDialog( + context: context, + builder: (context) => _HelpDialog(search: search), + ); + if (proceed != true || !context.mounted) return; + + final description = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => _CreatePostDialog(search: search), + ); + if (description == null) return; + + await launchUrl(_forumPostUrl(challenge, blockName, description, code)); +} + +Uri _forumPostUrl( + Challenge challenge, + String blockName, + String description, + String code, +) { + final title = '$blockName - ${challenge.title}'; + final ending = + '**Your mobile information:**\n```txt\n${Platform.operatingSystem} ${Platform.operatingSystemVersion}\n```\n\n' + '**Challenge:** $title\n\n' + '**Link to the challenge:**\n' + 'https://www.freecodecamp.org/learn/${challenge.superBlock}/${challenge.block}/${challenge.dashedName}'; + + final body = + "**Tell us what's happening:**\nDescribe your issue in detail here. \n\n$description \n\n" + '${code.isEmpty ? '' : '**Your code so far**$code\n\n'}$ending'; + final shortBody = "**Tell us what's happening:**\n\n\n\n$ending"; + + final base = + '$_forum/new-topic?category=${Uri.encodeComponent(challenge.helpCategory)}&title=${Uri.encodeComponent(title)}&body='; + final url = '$base${Uri.encodeComponent(body)}'; + + return Uri.parse( + url.length < _maxUrlLength ? url : '$base${Uri.encodeComponent(shortBody)}', + ); +} + +TextSpan _link(String text, Uri url) => TextSpan( + text: text, + style: const TextStyle( + color: FccColors.gray00, + decoration: TextDecoration.underline, + decorationColor: FccColors.gray00, + ), + recognizer: TapGestureRecognizer()..onTap = () => launchUrl(url), +); + +class _DialogButton extends StatelessWidget { + const _DialogButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 50), + backgroundColor: FccColors.gray75, + foregroundColor: FccColors.gray00, + side: const BorderSide(width: 2, color: FccColors.gray00), + shape: const RoundedRectangleBorder(), + ), + onPressed: onPressed, + child: Text( + label, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ); + } +} + +class _HelpDialog extends StatelessWidget { + const _HelpDialog({required this.search}); + + final Uri search; + + @override + Widget build(BuildContext context) { + const body = TextStyle(fontSize: 16, color: FccColors.gray05); + + return Dialog( + backgroundColor: FccColors.gray90, + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + runSpacing: 20, + children: [ + const Text( + 'Ask for Help', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + Text.rich( + TextSpan( + style: body, + children: [ + const TextSpan(text: "If you've already tried the "), + _link('Read-Search-Ask', Uri.parse(_readSearchAsk)), + const TextSpan( + text: + ' method, then you can ask for help on the freeCodeCamp forum.', + ), + ], + ), + ), + Text.rich( + TextSpan( + style: body, + children: [ + const TextSpan(text: 'Before making a new post please '), + _link( + 'check if your question has already been answered on the forum', + search, + ), + const TextSpan(text: '.'), + ], + ), + ), + _DialogButton( + label: 'Create a post', + onPressed: () => Navigator.of(context).pop(true), + ), + _DialogButton( + label: 'Cancel', + onPressed: () => Navigator.of(context).pop(false), + ), + ], + ), + ), + ); + } +} + +class _CreatePostDialog extends StatefulWidget { + const _CreatePostDialog({required this.search}); + + final Uri search; + + @override + State<_CreatePostDialog> createState() => _CreatePostDialogState(); +} + +class _CreatePostDialogState extends State<_CreatePostDialog> { + final _description = TextEditingController(); + bool _triedReadSearchAsk = false; + bool _searched = false; + + @override + void dispose() { + _description.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final remaining = _minDescription - _description.text.length; + final canSubmit = _triedReadSearchAsk && _searched && remaining <= 0; + + return Dialog( + backgroundColor: FccColors.gray90, + shape: const RoundedRectangleBorder(), + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Wrap( + runSpacing: 20, + children: [ + const Text( + 'Create a post', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const Text( + 'You must confirm the following statements before you can submit your post to the forum.', + style: TextStyle(fontSize: 16, color: FccColors.gray05), + ), + _Statement( + value: _triedReadSearchAsk, + onChanged: (v) => setState(() => _triedReadSearchAsk = v), + text: TextSpan( + children: [ + const TextSpan(text: 'I have tried the '), + _link('Read-Search-Ask', Uri.parse(_readSearchAsk)), + const TextSpan(text: ' method'), + ], + ), + ), + _Statement( + value: _searched, + onChanged: (v) => setState(() => _searched = v), + text: TextSpan( + children: [ + const TextSpan(text: 'I have searched for '), + _link( + 'similar questions that have already been answered on the forum', + widget.search, + ), + ], + ), + ), + if (remaining > 0) + Text( + 'Please enter at least $remaining more characters.', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextField( + controller: _description, + onChanged: (_) => setState(() {}), + maxLines: 5, + decoration: const InputDecoration( + hintText: 'Describe your issue in detail here...', + border: OutlineInputBorder(), + ), + ), + _DialogButton( + label: 'Submit', + onPressed: canSubmit + ? () => Navigator.of(context).pop(_description.text) + : null, + ), + _DialogButton( + label: 'Cancel', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + ); + } +} + +class _Statement extends StatelessWidget { + const _Statement({ + required this.value, + required this.onChanged, + required this.text, + }); + + final bool value; + final ValueChanged onChanged; + final TextSpan text; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Checkbox( + value: value, + onChanged: (v) => onChanged(v ?? false), + activeColor: FccColors.gray00, + ), + Flexible( + child: Text.rich( + text, + style: const TextStyle(fontSize: 14, color: FccColors.gray00), + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/html_card.dart b/mobile-app/lib/learn/ui/challenge/widgets/html_card.dart new file mode 100644 index 000000000..073547017 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/html_card.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; + +class HtmlCard extends StatelessWidget { + const HtmlCard({ + super.key, + required this.title, + required this.html, + this.collapsible = false, + }); + + final String title; + final String html; + final bool collapsible; + + @override + Widget build(BuildContext context) { + return ChallengeCard( + title: title, + child: collapsible + ? ExpansionTile( + title: const Text('Tap to expand'), + shape: const Border(), + collapsedShape: const Border(), + children: [ParsedHtml(html)], + ) + : ParsedHtml(html), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/parsed_html.dart b/mobile-app/lib/learn/ui/challenge/widgets/parsed_html.dart new file mode 100644 index 000000000..c3a834f22 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/parsed_html.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +// NOTE: Parses once. Re-parsing on every parent rebuild recreates the code editors, +// and their re-measuring jumps the scroll position. +class ParsedHtml extends StatefulWidget { + const ParsedHtml(this.html, {super.key, this.customStyles = const {}}); + + final String html; + final Map customStyles; + + @override + State createState() => _ParsedHtmlState(); +} + +class _ParsedHtmlState extends State { + List? _children; + + @override + void didUpdateWidget(ParsedHtml oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.html != widget.html) _children = null; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: _children ??= HTMLParser( + context: context, + ).parse(widget.html, customStyles: widget.customStyles), + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/questions.dart b/mobile-app/lib/learn/ui/challenge/widgets/questions.dart new file mode 100644 index 000000000..3d6b7bc5f --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/questions.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/quiz_audio_player.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +typedef QuestionItem = ({ + String text, + List answers, + // NOTE: 0-based index into answers. + int solution, + QuizAudioData? audioData, +}); + +QuestionItem questionItem(Question q) => ( + text: q.text, + answers: q.answers, + solution: q.solution - 1, + audioData: null, +); + +class Questions extends StatefulWidget { + const Questions({ + super.key, + required this.questions, + required this.selected, + required this.validated, + required this.onChanged, + }); + + final List questions; + final List selected; + final bool validated; + final void Function(int question, int answer) onChanged; + + @override + State createState() => _QuestionsState(); +} + +class _QuestionsState extends State { + late List> _texts; + late List>> _answers; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _parse(); + } + + @override + void didUpdateWidget(Questions oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.questions, widget.questions)) _parse(); + } + + void _parse() { + final parser = HTMLParser(context: context); + _texts = [for (final q in widget.questions) parser.parse(q.text)]; + _answers = [ + for (final q in widget.questions) + [ + for (final a in q.answers) + parser.parse( + a.answer, + isSelectable: false, + customStyles: {'p': Style(margin: Margins.zero)}, + ), + ], + ]; + } + + @override + Widget build(BuildContext context) { + final multiple = widget.questions.length > 1; + + return Column( + children: [ + for (final (i, question) in widget.questions.indexed) + ChallengeCard( + title: multiple ? 'Question ${i + 1}' : 'Question', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ..._texts[i], + if (question.audioData case final audio?) ...[ + const SizedBox(height: 16), + QuizAudioPlayer(audioData: audio), + ], + const SizedBox(height: 8), + RadioGroup( + groupValue: widget.selected[i], + onChanged: (answer) { + if (answer != null) widget.onChanged(i, answer); + }, + child: Column( + children: [ + for (final (j, answer) in question.answers.indexed) + _option(i, j, answer), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _option(int question, int index, Answer answer) { + final isSelected = widget.selected[question] == index; + final isCorrect = widget.questions[question].solution == index; + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Material( + child: RadioListTile( + value: index, + selected: isSelected, + tileColor: FccColors.gray90, + selectedTileColor: FccColors.gray90, + shape: const RoundedRectangleBorder( + side: BorderSide(color: Color(0xFFAAAAAA), width: 2), + ), + title: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: _answers[question][index], + ), + ), + subtitle: isSelected && widget.validated + ? _Feedback(isCorrect: isCorrect, feedback: answer.feedback) + : null, + ), + ), + ); + } +} + +class _Feedback extends StatelessWidget { + const _Feedback({required this.isCorrect, required this.feedback}); + + final bool isCorrect; + final String? feedback; + + @override + Widget build(BuildContext context) { + final color = isCorrect ? FccColors.green40 : FccColors.red15; + final feedback = this.feedback ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 12, bottom: feedback.isEmpty ? 24 : 0), + child: Text( + isCorrect ? 'Correct!' : 'Incorrect!', + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ), + if (feedback.isNotEmpty) + ...HTMLParser(context: context).parse( + feedback, + customStyles: {'*:not(pre):not(code)': Style(color: color)}, + ), + ], + ); + } +} diff --git a/mobile-app/lib/learn/ui/challenge/widgets/quiz_audio_player.dart b/mobile-app/lib/learn/ui/challenge/widgets/quiz_audio_player.dart new file mode 100644 index 000000000..0266690a8 --- /dev/null +++ b/mobile-app/lib/learn/ui/challenge/widgets/quiz_audio_player.dart @@ -0,0 +1,162 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/challenge_card.dart'; +import 'package:freecodecamp/services/audio_service.dart'; + +class QuizAudioPlayer extends ConsumerStatefulWidget { + const QuizAudioPlayer({super.key, required this.audioData}); + + final QuizAudioData audioData; + + @override + ConsumerState createState() => _QuizAudioPlayerState(); +} + +class _QuizAudioPlayerState extends ConsumerState { + bool _loading = false; + bool _failed = false; + + late final _handler = ref.read(audioHandlerProvider); + + AudioClip get _clip => widget.audioData.audio; + + @override + void dispose() { + if (_handler.curriculumClipId == _clip.id) _handler.stop(); + super.dispose(); + } + + Future _loadAndPlay() async { + setState(() => _loading = true); + + final loaded = await _handler.loadCurriculumAudio( + _clip.url, + clipId: _clip.id, + title: 'Quiz audio', + start: _clip.start, + end: _clip.end, + ); + + if (!mounted) return; + setState(() { + _loading = false; + _failed = !loaded; + }); + if (loaded) await _handler.play(); + } + + @override + Widget build(BuildContext context) { + if (_failed) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: Text('Error loading audio')), + ); + } + + return Column( + children: [ + StreamBuilder( + stream: _handler.playbackState, + builder: (context, snapshot) { + final state = snapshot.data ?? PlaybackState(); + final isActive = _handler.curriculumClipId == _clip.id; + final completed = + state.processingState == AudioProcessingState.completed; + final playing = isActive && state.playing && !completed; + + return Column( + children: [ + StreamBuilder( + stream: _handler.positionStream, + builder: (context, position) => _Progress( + position: isActive ? position.data : null, + total: isActive ? _handler.duration : null, + ), + ), + if (_loading) + const Padding( + padding: EdgeInsets.all(8), + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + IconButton( + tooltip: playing ? 'Pause' : 'Play', + icon: Icon(playing ? Icons.pause : Icons.play_arrow), + onPressed: () async { + if (!isActive) return _loadAndPlay(); + if (playing) return _handler.pause(); + if (completed) await _handler.seek(Duration.zero); + await _handler.play(); + }, + ), + ], + ); + }, + ), + if (widget.audioData.transcript.isNotEmpty) ...[ + const SizedBox(height: 8), + ChallengeCard( + title: 'Transcript', + child: ExpansionTile( + title: const Text( + 'Show transcript', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + shape: const Border(), + collapsedShape: const Border(), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in widget.audioData.transcript) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text.rich( + TextSpan( + style: const TextStyle( + fontSize: 14, + color: FccColors.gray05, + ), + children: [ + TextSpan( + text: '${line.character}: ', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: line.text), + ], + ), + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +class _Progress extends StatelessWidget { + const _Progress({required this.position, required this.total}); + + final Duration? position; + final Duration? total; + + @override + Widget build(BuildContext context) { + final totalMs = total?.inMilliseconds ?? 0; + final positionMs = position?.inMilliseconds ?? 0; + + return LinearProgressIndicator( + backgroundColor: FccColors.gray75, + valueColor: const AlwaysStoppedAnimation(FccColors.blue50), + value: totalMs == 0 ? 0 : (positionMs / totalMs).clamp(0.0, 1.0), + minHeight: 8, + ); + } +} diff --git a/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_view.dart b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_view.dart new file mode 100644 index 000000000..a9ad2da4d --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_view.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/daily_challenge_model.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:freecodecamp/learn/ui/daily_challenge/daily_challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_challenge_view.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class DailyChallengeView extends ConsumerWidget { + const DailyChallengeView({super.key, required this.day}); + + final String day; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final challenge = ref.watch(dailyChallengeProvider(day)); + final language = ref.watch(dailyChallengeLanguageProvider); + + return switch ((challenge, language)) { + (AsyncData(value: final daily), AsyncData(value: final language)) => + EditorChallengeView( + challenge: ref + .watch(learnDailyChallengeServiceProvider) + .toChallenge(daily, language), + selector: _LanguageSelector( + selected: language, + onSelected: (language) => ref + .read(dailyChallengeLanguageProvider.notifier) + .select(language), + ), + ), + (AsyncError(), _) || (_, AsyncError()) => Scaffold( + appBar: AppBar(), + body: Center( + child: ErrorRetry( + message: 'Something went wrong!', + onRetry: () => ref + ..invalidate(dailyChallengeProvider(day)) + ..invalidate(dailyChallengeLanguageProvider), + ), + ), + ), + _ => Scaffold( + appBar: AppBar(), + body: const Center(child: CircularProgressIndicator()), + ), + }; + } +} + +class _LanguageSelector extends StatelessWidget { + const _LanguageSelector({required this.selected, required this.onSelected}); + + final DailyChallengeLanguage selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + Text label(DailyChallengeLanguage language) => Text( + language.label, + style: TextStyle( + color: language == selected ? FccColors.blue50 : Colors.white, + fontWeight: language == selected ? FontWeight.bold : FontWeight.normal, + ), + ); + + return DropdownButton( + isExpanded: true, + dropdownColor: FccColors.gray85, + underline: const SizedBox.shrink(), + value: selected, + items: [ + for (final language in DailyChallengeLanguage.values) + DropdownMenuItem(value: language, child: label(language)), + ], + selectedItemBuilder: (_) => [ + for (final language in DailyChallengeLanguage.values) + Center(child: label(language)), + ], + onChanged: (language) { + if (language != null) onSelected(language); + }, + ); + } +} diff --git a/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.dart b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.dart new file mode 100644 index 000000000..cf89d28c4 --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.dart @@ -0,0 +1,21 @@ +import 'package:freecodecamp/learn/models/daily_challenge_model.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'daily_challenge_viewmodel.g.dart'; + +@riverpod +Future dailyChallenge(Ref ref, String day) => + ref.watch(learnDailyChallengeServiceProvider).getChallenge(day); + +@riverpod +class DailyChallengeLanguageNotifier extends _$DailyChallengeLanguageNotifier { + @override + Future build() => + ref.watch(learnDailyChallengeServiceProvider).getLanguage(); + + Future select(DailyChallengeLanguage language) async { + state = AsyncData(language); + await ref.read(learnDailyChallengeServiceProvider).setLanguage(language); + } +} diff --git a/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.g.dart b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.g.dart new file mode 100644 index 000000000..2f32afd49 --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge/daily_challenge_viewmodel.g.dart @@ -0,0 +1,141 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'daily_challenge_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(dailyChallenge) +final dailyChallengeProvider = DailyChallengeFamily._(); + +final class DailyChallengeProvider + extends + $FunctionalProvider< + AsyncValue, + DailyChallenge, + FutureOr + > + with $FutureModifier, $FutureProvider { + DailyChallengeProvider._({ + required DailyChallengeFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'dailyChallengeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dailyChallengeHash(); + + @override + String toString() { + return r'dailyChallengeProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return dailyChallenge(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is DailyChallengeProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$dailyChallengeHash() => r'2f2f76a7983dcc14f246cd87ced3be1074a6729e'; + +final class DailyChallengeFamily extends $Family + with $FunctionalFamilyOverride, String> { + DailyChallengeFamily._() + : super( + retry: null, + name: r'dailyChallengeProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + DailyChallengeProvider call(String day) => + DailyChallengeProvider._(argument: day, from: this); + + @override + String toString() => r'dailyChallengeProvider'; +} + +@ProviderFor(DailyChallengeLanguageNotifier) +final dailyChallengeLanguageProvider = + DailyChallengeLanguageNotifierProvider._(); + +final class DailyChallengeLanguageNotifierProvider + extends + $AsyncNotifierProvider< + DailyChallengeLanguageNotifier, + DailyChallengeLanguage + > { + DailyChallengeLanguageNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dailyChallengeLanguageProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dailyChallengeLanguageNotifierHash(); + + @$internal + @override + DailyChallengeLanguageNotifier create() => DailyChallengeLanguageNotifier(); +} + +String _$dailyChallengeLanguageNotifierHash() => + r'359d384996f5097bfac5e2453ad2bca379605ad2'; + +abstract class _$DailyChallengeLanguageNotifier + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, DailyChallengeLanguage>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + DailyChallengeLanguage + >, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_view.dart b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_view.dart new file mode 100644 index 000000000..4b12dcb38 --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_view.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/daily_challenge_model.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:freecodecamp/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/widgets/drawer/drawer.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; +import 'package:jiffy/jiffy.dart'; + +class DailyChallengeArchiveView extends ConsumerStatefulWidget { + const DailyChallengeArchiveView({super.key}); + + @override + ConsumerState createState() => + _DailyChallengeArchiveViewState(); +} + +class _DailyChallengeArchiveViewState + extends ConsumerState { + final _toggled = {}; + + @override + Widget build(BuildContext context) { + final archive = ref.watch(dailyChallengeArchiveProvider); + final completedChallenges = ref.watch( + authProvider.select((s) => s.value?.completedDailyCodingChallenges), + ); + final completed = {...?completedChallenges?.map((c) => c.id)}; + + return Scaffold( + appBar: AppBar(title: const Text('Daily Coding Challenges')), + drawer: const DrawerWidget(), + body: archive.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'No challenges available at the moment.', + onRetry: () => ref.invalidate(dailyChallengeArchiveProvider), + ), + data: (months) => months.isEmpty + ? const ErrorRetry( + message: 'No challenges available at the moment.', + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: months.length, + itemBuilder: (context, index) { + final month = months[index].month; + + return _MonthCard( + key: ValueKey(month), + month: months[index], + completed: completed, + isOpen: (index == 0) != _toggled.contains(month), + onToggle: () => setState(() { + if (!_toggled.remove(month)) _toggled.add(month); + }), + ); + }, + ), + ), + ); + } +} + +class _MonthCard extends StatelessWidget { + const _MonthCard({ + super.key, + required this.month, + required this.completed, + required this.isOpen, + required this.onToggle, + }); + + final DailyChallengeMonth month; + final Set completed; + final bool isOpen; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final challenges = month.challenges; + final done = challenges.where((c) => completed.contains(c.id)).length; + final jiffy = Jiffy.parseFromDateTime(month.month); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: FccColors.gray75), + color: FccColors.gray85, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(8), + child: Icon( + Icons.calendar_today, + color: FccColors.gray00, + size: 30, + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + jiffy.format(pattern: 'MMMM'), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: FccColors.gray00, + ), + ), + ), + ), + ], + ), + if (done == challenges.length) + const Padding( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Padding( + padding: EdgeInsets.only(right: 8), + child: Icon(Icons.check_circle, color: FccColors.blue30), + ), + Text( + 'Completed', + style: TextStyle( + color: FccColors.blue30, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ) + else + Padding( + padding: const EdgeInsets.all(10), + child: LinearProgressIndicator( + minHeight: 12, + value: done == 0 ? 0.01 : done / challenges.length, + valueColor: const AlwaysStoppedAnimation( + FccColors.blue30, + ), + backgroundColor: FccColors.gray80, + borderRadius: BorderRadius.circular(10), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Explore the daily coding challenges for ${jiffy.format(pattern: 'MMMM')}. ' + 'Stay motivated and keep your learning streak alive!', + style: const TextStyle(fontSize: 20, color: FccColors.gray05), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextButton( + onPressed: onToggle, + style: TextButton.styleFrom( + backgroundColor: FccColors.gray85, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: const BorderSide(color: FccColors.gray75), + ), + ), + child: Text(isOpen ? 'Hide Challenges' : 'Show Challenges'), + ), + ), + if (isOpen) + for (final challenge in challenges) + _ChallengeItem( + challenge: challenge, + isCompleted: completed.contains(challenge.id), + ), + ], + ), + ); + } +} + +class _ChallengeItem extends StatelessWidget { + const _ChallengeItem({required this.challenge, required this.isCompleted}); + + final DailyChallengeOverview challenge; + final bool isCompleted; + + @override + Widget build(BuildContext context) { + final title = 'Challenge ${challenge.challengeNumber}: ${challenge.title}'; + + return Semantics( + label: '$title, ${isCompleted ? 'completed' : 'not completed'}', + button: true, + excludeSemantics: true, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: isCompleted ? FccColors.blue10 : FccColors.gray75, + ), + ), + color: isCompleted ? FccColors.blue90Translucent : FccColors.gray80, + child: InkWell( + onTap: () => LearnDailyChallengeRoute( + day: challenge.monthDay, + ).push(context), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (isCompleted) + const Padding( + padding: EdgeInsets.only(right: 8), + child: Icon( + Icons.check_circle, + color: FccColors.blue10, + size: 20, + ), + ), + Expanded( + child: Text(title, style: const TextStyle(fontSize: 18)), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.dart b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.dart new file mode 100644 index 000000000..77fda1d6c --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.dart @@ -0,0 +1,8 @@ +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'daily_challenge_archive_viewmodel.g.dart'; + +@riverpod +Future> dailyChallengeArchive(Ref ref) => + ref.watch(learnDailyChallengeServiceProvider).getArchive(); diff --git a/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.g.dart b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.g.dart new file mode 100644 index 000000000..f55d487f1 --- /dev/null +++ b/mobile-app/lib/learn/ui/daily_challenge_archive/daily_challenge_archive_viewmodel.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'daily_challenge_archive_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(dailyChallengeArchive) +final dailyChallengeArchiveProvider = DailyChallengeArchiveProvider._(); + +final class DailyChallengeArchiveProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + DailyChallengeArchiveProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dailyChallengeArchiveProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dailyChallengeArchiveHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return dailyChallengeArchive(ref); + } +} + +String _$dailyChallengeArchiveHash() => + r'5d2c5c749cdd143306987cf2a129e8e3ef7f4693'; diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_body.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_body.dart new file mode 100644 index 000000000..dc517515b --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_body.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; + +class TestList extends StatelessWidget { + const TestList({super.key, required this.tests, required this.results}); + + final List tests; + final List? results; + + static final _styles = { + '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( + color: FccColors.gray00, + ), + 'p': Style(margin: Margins.zero), + }; + + @override + Widget build(BuildContext context) { + final results = this.results; + + return ListView.separated( + itemCount: tests.length, + separatorBuilder: (_, _) => const Divider(color: Colors.white, height: 1), + itemBuilder: (context, i) => Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Expanded(child: ParsedHtml(tests[i].text, customStyles: _styles)), + if (results != null && i < results.length) ...[ + const SizedBox(width: 8), + Icon( + results[i] ? Icons.check_circle_rounded : Icons.cancel_rounded, + ), + ], + ], + ), + ), + ); + } +} + +class ConsoleOutput extends StatelessWidget { + const ConsoleOutput({ + super.key, + required this.testConsole, + required this.userConsole, + }); + + final List testConsole; + final List userConsole; + + static final _styles = { + 'body': Style(margin: Margins.symmetric(vertical: 0, horizontal: 8)), + 'p': Style(margin: Margins.zero), + 'ol': Style(padding: HtmlPaddings.only(inlineStart: 20)), + }; + + @override + Widget build(BuildContext context) { + final messages = [ + if (testConsole.isEmpty) + '

/**
* Your test output will go here
*/

' + else + ...testConsole, + if (userConsole.isNotEmpty) '

// console output

', + ...userConsole, + ]; + + return ListView( + padding: const EdgeInsets.all(8), + children: [ + for (final message in messages) + ParsedHtml(message, customStyles: _styles), + ], + ); + } +} + +class ProjectPreview extends StatefulWidget { + const ProjectPreview({super.key, required this.load}); + + final Future Function() load; + + @override + State createState() => _ProjectPreviewState(); +} + +class _ProjectPreviewState extends State { + late final Future _html = widget.load(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _html, + builder: (context, snapshot) => switch (snapshot) { + AsyncSnapshot(:final data?) => InAppWebView( + initialData: InAppWebViewInitialData(data: data), + initialSettings: InAppWebViewSettings(isInspectable: kDebugMode), + ), + AsyncSnapshot(hasError: true) => const Center( + child: Text('The preview could not be built.'), + ), + _ => const Center(child: CircularProgressIndicator()), + }, + ); + } +} diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_view.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_view.dart new file mode 100644 index 000000000..90c8faa48 --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_view.dart @@ -0,0 +1,506 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_file_model.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/services/code_service.dart'; +import 'package:freecodecamp/learn/services/test_runner_service.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_body.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_challenge_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_panels.dart'; +import 'package:phone_ide/models/textfield_data.dart'; +import 'package:phone_ide/phone_ide.dart'; + +class EditorChallengeView extends ConsumerStatefulWidget { + const EditorChallengeView({ + super.key, + required this.challenge, + this.block, + this.selector, + }); + + final Challenge challenge; + // NOTE: Null for daily challenges, which belong to no block. + final Block? block; + // NOTE: Replaces the file selector; daily challenges pick a language here. + final Widget? selector; + + @override + ConsumerState createState() => + _EditorChallengeViewState(); +} + +class _EditorChallengeViewState extends ConsumerState { + final _scaffoldKey = GlobalKey(); + final _subscriptions = >[]; + + Editor? _editor; + TextFieldData? _focused; + bool _drawerOpen = false; + + EditorChallengeNotifierProvider get _provider => + editorChallengeProvider(widget.challenge); + + @override + void initState() { + super.initState(); + _loadEditor(ref.read(_provider).file); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scaffoldKey.currentState?.openEndDrawer(), + ); + } + + @override + void didUpdateWidget(EditorChallengeView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.challenge != widget.challenge) { + _loadEditor(ref.read(_provider).file); + } + } + + @override + void dispose() { + _cancelSubscriptions(); + super.dispose(); + } + + void _cancelSubscriptions() { + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + } + + Future _loadEditor(ChallengeFile file) async { + final challenge = widget.challenge; + final resetCount = ref.read(_provider).resetCount; + final contents = await ref + .read(learnCodeServiceProvider) + .contents(challenge, file); + // NOTE: A newer load started while this one awaited, so this result is stale. + if (!mounted || challenge != widget.challenge) return; + final current = ref.read(_provider); + if (current.file != file || current.resetCount != resetCount) return; + + final region = file.editableRegionBoundaries; + final editor = Editor( + key: ValueKey( + '${LearnCodeService.editorPath(challenge, file)}#$resetCount', + ), + defaultLanguage: file.ext.name, + defaultValue: contents, + path: LearnCodeService.editorPath(challenge, file), + options: EditorOptions( + fontFamily: 'Hack', + regionOptions: region.isEmpty + ? null + : EditorRegionOptions(start: region[0], end: region[1]), + ), + ); + + _cancelSubscriptions(); + _subscriptions.addAll([ + editor.onTextChange.stream.listen( + (text) => ref.read(_provider.notifier).codeChanged(file, text), + ), + editor.textfieldData.stream.listen((data) => _focused = data), + ]); + setState(() { + _editor = editor; + _focused = null; + }); + } + + void _insertSymbol(String symbol) { + final focused = _focused; + final editor = _editor; + if (focused == null || editor == null) return; + + final controller = focused.controller; + final selection = controller.selection; + if (!selection.isValid) return; + + final text = symbol == 'Tab' ? '\t' : symbol; + controller.value = TextEditingValue( + text: controller.text.replaceRange(selection.start, selection.end, text), + selection: TextSelection.collapsed(offset: selection.start + text.length), + ); + editor.textfieldData.add( + TextFieldData(controller: controller, position: focused.position), + ); + } + + void _retryRunner(String workerType) { + if (ref.read(learnTestRunnerServerProvider).hasError) { + ref.invalidate(learnTestRunnerServerProvider); + } else { + ref.invalidate(learnTestRunnerServiceProvider(workerType)); + } + } + + Future _runTests() async { + FocusManager.instance.primaryFocus?.unfocus(); + await ref.read(_provider.notifier).runTests(); + if (mounted) _scaffoldKey.currentState?.openEndDrawer(); + } + + Future _reset() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset Code'), + content: const Text('Are you sure you want to reset your code?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Reset'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + _scaffoldKey.currentState?.closeEndDrawer(); + await ref.read(_provider.notifier).reset(); + } + + @override + Widget build(BuildContext context) { + ref.listen( + _provider.select((s) => (s.file, s.resetCount)), + (_, next) => _loadEditor(next.$1), + ); + + final state = ref.watch(_provider); + final editor = _editor; + + return Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: false, + onEndDrawerChanged: (open) { + if (open) FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _drawerOpen = open); + }, + appBar: _appBar(state), + endDrawer: Drawer( + width: MediaQuery.sizeOf(context).width * 0.9, + child: SafeArea( + child: switch (state.panel) { + EditorPanel.instruction => InstructionPanel( + challenge: widget.challenge, + block: widget.block, + ), + EditorPanel.hint => HintPanel( + challenge: widget.challenge, + block: widget.block, + hint: state.hint, + onReset: _reset, + ), + EditorPanel.pass => PassPanel( + challenge: widget.challenge, + block: widget.block, + ), + }, + ), + ), + bottomNavigationBar: _bottomBar(state), + body: IndexedStack( + index: state.body.index, + sizing: StackFit.expand, + children: [ + editor ?? const Center(child: CircularProgressIndicator()), + TestList(tests: widget.challenge.tests, results: state.testResults), + if (state.body == EditorBody.preview) + ProjectPreview( + load: () => ref.read(_provider.notifier).previewHtml(), + ) + else + const SizedBox.shrink(), + ConsoleOutput( + testConsole: state.testConsole, + userConsole: state.userConsole, + ), + ], + ), + ); + } + + PreferredSizeWidget _appBar(EditorChallengeState state) { + final title = switch (state.body) { + EditorBody.preview => 'PREVIEW', + EditorBody.console => 'CONSOLE', + _ => null, + }; + final showingTests = state.body == EditorBody.tests; + + return AppBar( + automaticallyImplyLeading: title == null, + actions: const [SizedBox.shrink()], + bottom: const PreferredSize( + preferredSize: Size.fromHeight(1), + child: Divider(height: 1, thickness: 1, color: FccColors.gray45), + ), + title: title != null + ? Text(title, style: const TextStyle(fontWeight: FontWeight.bold)) + : Row( + children: [ + Expanded( + child: + widget.selector ?? + _FileSelector( + files: widget.challenge.challengeFiles, + selected: state.file, + highlight: !showingTests, + onSelected: (file) => + ref.read(_provider.notifier).selectFile(file), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _TabButton( + label: 'Tests', + icon: Icons.science_outlined, + active: showingTests, + onPressed: () => + ref.read(_provider.notifier).showBody(EditorBody.tests), + ), + ), + ], + ), + ); + } + + Widget _bottomBar(EditorChallengeState state) { + final keyboard = MediaQuery.viewInsetsOf(context).bottom; + final workerType = workerTypeOf(widget.challenge.challengeType); + final runner = ref.watch(learnTestRunnerServiceProvider(workerType)); + final failed = runner.hasError && !runner.isLoading; + final busy = state.testsRunning || (!runner.hasValue && !failed); + + return Container( + decoration: const BoxDecoration( + border: Border(top: BorderSide(color: FccColors.gray45)), + ), + padding: EdgeInsets.only(bottom: keyboard), + child: BottomAppBar( + height: keyboard != 0 ? 116 : 72, + padding: keyboard != 0 ? const EdgeInsets.only(bottom: 8) : null, + color: const Color(0x990a0a23), + child: Column( + children: [ + if (keyboard != 0) _SymbolBar(onSymbol: _insertSymbol), + Row( + children: [ + _PanelButton( + icon: Icons.info_outline_rounded, + active: _drawerOpen && state.panel == EditorPanel.instruction, + onPressed: () { + ref + .read(_provider.notifier) + .showPanel(EditorPanel.instruction); + _scaffoldKey.currentState?.openEndDrawer(); + }, + ), + if (workerType == 'dom') + _PanelButton( + icon: Icons.remove_red_eye_outlined, + active: state.body == EditorBody.preview, + onPressed: () => ref + .read(_provider.notifier) + .showBody(EditorBody.preview), + ), + _PanelButton( + icon: Icons.terminal, + active: state.body == EditorBody.console, + onPressed: () => + ref.read(_provider.notifier).showBody(EditorBody.console), + ), + const Spacer(), + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + color: state.passed + ? FccColors.green70 + : const Color.fromRGBO(0x1D, 0x9B, 0xF0, 1), + child: IconButton( + tooltip: failed + ? 'The test runner failed to load. Tap to retry.' + : null, + icon: busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ) + : failed + ? const Icon(Icons.refresh_rounded, size: 30) + : Icon( + state.passed + ? Icons.arrow_forward_rounded + : Icons.done_rounded, + size: 30, + ), + onPressed: busy + ? null + : failed + ? () => _retryRunner(workerType) + : _runTests, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _FileSelector extends StatelessWidget { + const _FileSelector({ + required this.files, + required this.selected, + required this.highlight, + required this.onSelected, + }); + + final List files; + final ChallengeFile selected; + final bool highlight; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + if (files.length == 1) { + return _TabButton( + label: selected.fileName, + icon: Icons.insert_drive_file_outlined, + active: highlight, + onPressed: () => onSelected(selected), + ); + } + + Text label(ChallengeFile file) { + final active = highlight && file == selected; + return Text( + file.fileName, + style: TextStyle( + color: active ? FccColors.blue50 : Colors.white, + fontWeight: active ? FontWeight.bold : FontWeight.normal, + ), + ); + } + + return DropdownButton( + isExpanded: true, + dropdownColor: FccColors.gray85, + underline: const SizedBox.shrink(), + value: selected, + items: [ + for (final file in files) + DropdownMenuItem(value: file, child: label(file)), + ], + selectedItemBuilder: (_) => [ + for (final file in files) Center(child: label(file)), + ], + onChanged: (file) => onSelected(file ?? selected), + ); + } +} + +class _TabButton extends StatelessWidget { + const _TabButton({ + required this.label, + required this.icon, + required this.active, + required this.onPressed, + }); + + final String label; + final IconData icon; + final bool active; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final color = active ? FccColors.blue50 : Colors.white; + + return TextButton.icon( + onPressed: onPressed, + label: Text(label, style: TextStyle(color: color)), + icon: Icon(icon, size: 30, color: color), + iconAlignment: IconAlignment.end, + style: TextButton.styleFrom( + backgroundColor: FccColors.gray90, + shape: const RoundedRectangleBorder(), + ), + ); + } +} + +class _PanelButton extends StatelessWidget { + const _PanelButton({ + required this.icon, + required this.active, + required this.onPressed, + }); + + final IconData icon; + final bool active; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + color: active ? Colors.white : FccColors.gray75, + child: IconButton( + icon: Icon( + icon, + size: 32, + color: active ? FccColors.gray75 : Colors.white, + ), + onPressed: onPressed, + ), + ); + } +} + +class _SymbolBar extends StatelessWidget { + const _SymbolBar({required this.onSymbol}); + + final ValueChanged onSymbol; + + static const _symbols = ['Tab', '<', '/', '>', r'\', "'", '"', '=', '{', '}']; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + height: 50, + color: FccColors.gray85, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + for (final symbol in _symbols) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 1), + child: TextButton( + onPressed: () => onSymbol(symbol), + style: TextButton.styleFrom( + shape: const RoundedRectangleBorder(), + ), + child: Text(symbol), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.dart new file mode 100644 index 000000000..a7f5f30b9 --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:freecodecamp/learn/models/challenge_file_model.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/services/code_service.dart'; +import 'package:freecodecamp/learn/services/quote_service.dart'; +import 'package:freecodecamp/learn/services/test_runner_service.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'editor_challenge_viewmodel.freezed.dart'; +part 'editor_challenge_viewmodel.g.dart'; + +enum EditorPanel { instruction, hint, pass } + +enum EditorBody { editor, tests, preview, console } + +@freezed +abstract class EditorChallengeState with _$EditorChallengeState { + const factory EditorChallengeState({ + required ChallengeFile file, + @Default(EditorPanel.instruction) EditorPanel panel, + @Default(EditorBody.editor) EditorBody body, + @Default(false) bool testsRunning, + @Default(false) bool passed, + @Default('') String hint, + @Default([]) List testConsole, + @Default([]) List userConsole, + // NOTE: Null until a run reaches the tests. + List? testResults, + // NOTE: Bumped on reset so the view rebuilds its editor from the original code. + @Default(0) int resetCount, + }) = _EditorChallengeState; +} + +@riverpod +Future passQuote(Ref ref) => + ref.watch(learnQuoteServiceProvider).getRandomQuote(); + +@riverpod +class EditorChallengeNotifier extends _$EditorChallengeNotifier { + LearnTestRunnerServiceProvider get _runner => + learnTestRunnerServiceProvider(workerTypeOf(challenge.challengeType)); + + @override + EditorChallengeState build(Challenge challenge) { + final files = challenge.challengeFiles; + return EditorChallengeState( + file: + files + .where((f) => f.editableRegionBoundaries.isNotEmpty) + .firstOrNull ?? + files.first, + ); + } + + void selectFile(ChallengeFile file) => + state = state.copyWith(file: file, body: EditorBody.editor); + + void showBody(EditorBody body) => state = state.copyWith( + body: state.body == body ? EditorBody.editor : body, + ); + + void showPanel(EditorPanel panel) => state = state.copyWith(panel: panel); + + Future codeChanged(ChallengeFile file, String text) async { + if (state.passed) state = state.copyWith(passed: false); + await ref.read(learnCodeServiceProvider).save(challenge, file, text); + } + + Future reset() async { + await ref.read(learnCodeServiceProvider).reset(challenge); + if (!ref.mounted) return; + state = EditorChallengeState( + file: state.file, + resetCount: state.resetCount + 1, + ); + } + + Future previewHtml() async { + final runner = await ref.read(_runner.future); + return ref + .read(learnCodeServiceProvider) + .previewHtml( + challenge, + (js) => runner.transpile(js, challengeId: challenge.id), + ); + } + + Future runTests() async { + final runner = await ref.read(_runner.future); + final code = ref.read(learnCodeServiceProvider); + + state = state.copyWith( + testsRunning: true, + testConsole: ['

// running tests

'], + userConsole: [], + testResults: null, + ); + final logs = runner.consoleMessages.listen((message) { + if (!ref.mounted) return; + state = state.copyWith( + userConsole: [...state.userConsole, '

$message

'], + ); + }); + + try { + final String userCode; + try { + userCode = await code.userCode( + challenge, + (js) => runner.transpile(js, challengeId: challenge.id), + ); + } on BabelException catch (e) { + if (!ref.mounted) return; + final hint = syntaxErrorHint(e); + state = state.copyWith( + panel: EditorPanel.hint, + hint: '

$hint

', + testConsole: [ + ...state.testConsole, + '

$hint

', + '

// tests completed

', + ], + ); + return; + } + + if (runner.workerType == 'javascript') { + final error = await runner.evaluate(userCode); + if (!ref.mounted) return; + if (error != null) { + state = state.copyWith( + userConsole: [...state.userConsole, '

$error

'], + ); + } + } + + final results = await runner.run( + userCode: userCode, + combinedCode: await code.combinedCode(challenge), + editableContents: await code.editableContents(challenge), + hooks: challenge.hooks, + tests: challenge.tests, + ); + if (!ref.mounted) return; + + final failures = [ + for (final (i, result) in results.indexed) + if (!result.pass) + _fillPlaceholders(challenge.tests[i].text, result.err), + ]; + + state = state.copyWith( + testResults: [for (final result in results) result.pass], + panel: failures.isEmpty ? EditorPanel.pass : EditorPanel.hint, + passed: failures.isEmpty, + hint: failures.firstOrNull ?? state.hint, + testConsole: [ + ...state.testConsole, + if (failures.isNotEmpty) + '
    ${failures.map((f) => '
  1. $f
  2. ').join()}
', + '

// tests completed

', + ], + ); + } catch (e, stack) { + log('Editor: tests failed to run', error: e, stackTrace: stack); + if (!ref.mounted) return; + state = state.copyWith( + testConsole: [ + ...state.testConsole, + '

The tests could not run: ${const HtmlEscape().convert('$e')}

', + '

// tests completed

', + ], + ); + } finally { + await logs.cancel(); + if (ref.mounted) state = state.copyWith(testsRunning: false); + } + } + + String _fillPlaceholders(String text, Map? err) => text + .replaceAll('--fcc-expected--', '${err?['expected'] ?? ''}') + .replaceAll('--fcc-actual--', '${err?['actual'] ?? ''}'); +} diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.freezed.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.freezed.dart new file mode 100644 index 000000000..0cc0b5732 --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.freezed.dart @@ -0,0 +1,336 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'editor_challenge_viewmodel.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$EditorChallengeState { + + ChallengeFile get file; EditorPanel get panel; EditorBody get body; bool get testsRunning; bool get passed; String get hint; List get testConsole; List get userConsole; List? get testResults; int get resetCount; +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EditorChallengeStateCopyWith get copyWith => _$EditorChallengeStateCopyWithImpl(this as EditorChallengeState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is EditorChallengeState&&(identical(other.file, file) || other.file == file)&&(identical(other.panel, panel) || other.panel == panel)&&(identical(other.body, body) || other.body == body)&&(identical(other.testsRunning, testsRunning) || other.testsRunning == testsRunning)&&(identical(other.passed, passed) || other.passed == passed)&&(identical(other.hint, hint) || other.hint == hint)&&const DeepCollectionEquality().equals(other.testConsole, testConsole)&&const DeepCollectionEquality().equals(other.userConsole, userConsole)&&const DeepCollectionEquality().equals(other.testResults, testResults)&&(identical(other.resetCount, resetCount) || other.resetCount == resetCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,file,panel,body,testsRunning,passed,hint,const DeepCollectionEquality().hash(testConsole),const DeepCollectionEquality().hash(userConsole),const DeepCollectionEquality().hash(testResults),resetCount); + +@override +String toString() { + return 'EditorChallengeState(file: $file, panel: $panel, body: $body, testsRunning: $testsRunning, passed: $passed, hint: $hint, testConsole: $testConsole, userConsole: $userConsole, testResults: $testResults, resetCount: $resetCount)'; +} + + +} + +/// @nodoc +abstract mixin class $EditorChallengeStateCopyWith<$Res> { + factory $EditorChallengeStateCopyWith(EditorChallengeState value, $Res Function(EditorChallengeState) _then) = _$EditorChallengeStateCopyWithImpl; +@useResult +$Res call({ + ChallengeFile file, EditorPanel panel, EditorBody body, bool testsRunning, bool passed, String hint, List testConsole, List userConsole, List? testResults, int resetCount +}); + + +$ChallengeFileCopyWith<$Res> get file; + +} +/// @nodoc +class _$EditorChallengeStateCopyWithImpl<$Res> + implements $EditorChallengeStateCopyWith<$Res> { + _$EditorChallengeStateCopyWithImpl(this._self, this._then); + + final EditorChallengeState _self; + final $Res Function(EditorChallengeState) _then; + +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? file = null,Object? panel = null,Object? body = null,Object? testsRunning = null,Object? passed = null,Object? hint = null,Object? testConsole = null,Object? userConsole = null,Object? testResults = freezed,Object? resetCount = null,}) { + return _then(_self.copyWith( +file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable +as ChallengeFile,panel: null == panel ? _self.panel : panel // ignore: cast_nullable_to_non_nullable +as EditorPanel,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as EditorBody,testsRunning: null == testsRunning ? _self.testsRunning : testsRunning // ignore: cast_nullable_to_non_nullable +as bool,passed: null == passed ? _self.passed : passed // ignore: cast_nullable_to_non_nullable +as bool,hint: null == hint ? _self.hint : hint // ignore: cast_nullable_to_non_nullable +as String,testConsole: null == testConsole ? _self.testConsole : testConsole // ignore: cast_nullable_to_non_nullable +as List,userConsole: null == userConsole ? _self.userConsole : userConsole // ignore: cast_nullable_to_non_nullable +as List,testResults: freezed == testResults ? _self.testResults : testResults // ignore: cast_nullable_to_non_nullable +as List?,resetCount: null == resetCount ? _self.resetCount : resetCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ChallengeFileCopyWith<$Res> get file { + + return $ChallengeFileCopyWith<$Res>(_self.file, (value) { + return _then(_self.copyWith(file: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [EditorChallengeState]. +extension EditorChallengeStatePatterns on EditorChallengeState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _EditorChallengeState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _EditorChallengeState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _EditorChallengeState value) $default,){ +final _that = this; +switch (_that) { +case _EditorChallengeState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _EditorChallengeState value)? $default,){ +final _that = this; +switch (_that) { +case _EditorChallengeState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( ChallengeFile file, EditorPanel panel, EditorBody body, bool testsRunning, bool passed, String hint, List testConsole, List userConsole, List? testResults, int resetCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _EditorChallengeState() when $default != null: +return $default(_that.file,_that.panel,_that.body,_that.testsRunning,_that.passed,_that.hint,_that.testConsole,_that.userConsole,_that.testResults,_that.resetCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( ChallengeFile file, EditorPanel panel, EditorBody body, bool testsRunning, bool passed, String hint, List testConsole, List userConsole, List? testResults, int resetCount) $default,) {final _that = this; +switch (_that) { +case _EditorChallengeState(): +return $default(_that.file,_that.panel,_that.body,_that.testsRunning,_that.passed,_that.hint,_that.testConsole,_that.userConsole,_that.testResults,_that.resetCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( ChallengeFile file, EditorPanel panel, EditorBody body, bool testsRunning, bool passed, String hint, List testConsole, List userConsole, List? testResults, int resetCount)? $default,) {final _that = this; +switch (_that) { +case _EditorChallengeState() when $default != null: +return $default(_that.file,_that.panel,_that.body,_that.testsRunning,_that.passed,_that.hint,_that.testConsole,_that.userConsole,_that.testResults,_that.resetCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _EditorChallengeState implements EditorChallengeState { + const _EditorChallengeState({required this.file, this.panel = EditorPanel.instruction, this.body = EditorBody.editor, this.testsRunning = false, this.passed = false, this.hint = '', final List testConsole = const [], final List userConsole = const [], final List? testResults, this.resetCount = 0}): _testConsole = testConsole,_userConsole = userConsole,_testResults = testResults; + + +@override final ChallengeFile file; +@override@JsonKey() final EditorPanel panel; +@override@JsonKey() final EditorBody body; +@override@JsonKey() final bool testsRunning; +@override@JsonKey() final bool passed; +@override@JsonKey() final String hint; + final List _testConsole; +@override@JsonKey() List get testConsole { + if (_testConsole is EqualUnmodifiableListView) return _testConsole; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_testConsole); +} + + final List _userConsole; +@override@JsonKey() List get userConsole { + if (_userConsole is EqualUnmodifiableListView) return _userConsole; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_userConsole); +} + + final List? _testResults; +@override List? get testResults { + final value = _testResults; + if (value == null) return null; + if (_testResults is EqualUnmodifiableListView) return _testResults; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +@override@JsonKey() final int resetCount; + +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EditorChallengeStateCopyWith<_EditorChallengeState> get copyWith => __$EditorChallengeStateCopyWithImpl<_EditorChallengeState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _EditorChallengeState&&(identical(other.file, file) || other.file == file)&&(identical(other.panel, panel) || other.panel == panel)&&(identical(other.body, body) || other.body == body)&&(identical(other.testsRunning, testsRunning) || other.testsRunning == testsRunning)&&(identical(other.passed, passed) || other.passed == passed)&&(identical(other.hint, hint) || other.hint == hint)&&const DeepCollectionEquality().equals(other._testConsole, _testConsole)&&const DeepCollectionEquality().equals(other._userConsole, _userConsole)&&const DeepCollectionEquality().equals(other._testResults, _testResults)&&(identical(other.resetCount, resetCount) || other.resetCount == resetCount)); +} + + +@override +int get hashCode => Object.hash(runtimeType,file,panel,body,testsRunning,passed,hint,const DeepCollectionEquality().hash(_testConsole),const DeepCollectionEquality().hash(_userConsole),const DeepCollectionEquality().hash(_testResults),resetCount); + +@override +String toString() { + return 'EditorChallengeState(file: $file, panel: $panel, body: $body, testsRunning: $testsRunning, passed: $passed, hint: $hint, testConsole: $testConsole, userConsole: $userConsole, testResults: $testResults, resetCount: $resetCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$EditorChallengeStateCopyWith<$Res> implements $EditorChallengeStateCopyWith<$Res> { + factory _$EditorChallengeStateCopyWith(_EditorChallengeState value, $Res Function(_EditorChallengeState) _then) = __$EditorChallengeStateCopyWithImpl; +@override @useResult +$Res call({ + ChallengeFile file, EditorPanel panel, EditorBody body, bool testsRunning, bool passed, String hint, List testConsole, List userConsole, List? testResults, int resetCount +}); + + +@override $ChallengeFileCopyWith<$Res> get file; + +} +/// @nodoc +class __$EditorChallengeStateCopyWithImpl<$Res> + implements _$EditorChallengeStateCopyWith<$Res> { + __$EditorChallengeStateCopyWithImpl(this._self, this._then); + + final _EditorChallengeState _self; + final $Res Function(_EditorChallengeState) _then; + +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? file = null,Object? panel = null,Object? body = null,Object? testsRunning = null,Object? passed = null,Object? hint = null,Object? testConsole = null,Object? userConsole = null,Object? testResults = freezed,Object? resetCount = null,}) { + return _then(_EditorChallengeState( +file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable +as ChallengeFile,panel: null == panel ? _self.panel : panel // ignore: cast_nullable_to_non_nullable +as EditorPanel,body: null == body ? _self.body : body // ignore: cast_nullable_to_non_nullable +as EditorBody,testsRunning: null == testsRunning ? _self.testsRunning : testsRunning // ignore: cast_nullable_to_non_nullable +as bool,passed: null == passed ? _self.passed : passed // ignore: cast_nullable_to_non_nullable +as bool,hint: null == hint ? _self.hint : hint // ignore: cast_nullable_to_non_nullable +as String,testConsole: null == testConsole ? _self._testConsole : testConsole // ignore: cast_nullable_to_non_nullable +as List,userConsole: null == userConsole ? _self._userConsole : userConsole // ignore: cast_nullable_to_non_nullable +as List,testResults: freezed == testResults ? _self._testResults : testResults // ignore: cast_nullable_to_non_nullable +as List?,resetCount: null == resetCount ? _self.resetCount : resetCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of EditorChallengeState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ChallengeFileCopyWith<$Res> get file { + + return $ChallengeFileCopyWith<$Res>(_self.file, (value) { + return _then(_self.copyWith(file: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.g.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.g.dart new file mode 100644 index 000000000..cc6006ab2 --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_challenge_viewmodel.g.dart @@ -0,0 +1,151 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'editor_challenge_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(passQuote) +final passQuoteProvider = PassQuoteProvider._(); + +final class PassQuoteProvider + extends + $FunctionalProvider< + AsyncValue, + MotivationalQuote, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + PassQuoteProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'passQuoteProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$passQuoteHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return passQuote(ref); + } +} + +String _$passQuoteHash() => r'2426ee6b188c091b647d44fef731607ef8cbd83e'; + +@ProviderFor(EditorChallengeNotifier) +final editorChallengeProvider = EditorChallengeNotifierFamily._(); + +final class EditorChallengeNotifierProvider + extends $NotifierProvider { + EditorChallengeNotifierProvider._({ + required EditorChallengeNotifierFamily super.from, + required Challenge super.argument, + }) : super( + retry: null, + name: r'editorChallengeProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$editorChallengeNotifierHash(); + + @override + String toString() { + return r'editorChallengeProvider' + '' + '($argument)'; + } + + @$internal + @override + EditorChallengeNotifier create() => EditorChallengeNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EditorChallengeState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is EditorChallengeNotifierProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$editorChallengeNotifierHash() => + r'7139c04d5034b7d37d0ed3eea3dbe928f2ede4af'; + +final class EditorChallengeNotifierFamily extends $Family + with + $ClassFamilyOverride< + EditorChallengeNotifier, + EditorChallengeState, + EditorChallengeState, + EditorChallengeState, + Challenge + > { + EditorChallengeNotifierFamily._() + : super( + retry: null, + name: r'editorChallengeProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + EditorChallengeNotifierProvider call(Challenge challenge) => + EditorChallengeNotifierProvider._(argument: challenge, from: this); + + @override + String toString() => r'editorChallengeProvider'; +} + +abstract class _$EditorChallengeNotifier + extends $Notifier { + late final _$args = ref.$arg as Challenge; + Challenge get challenge => _$args; + + EditorChallengeState build(Challenge challenge); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + EditorChallengeState, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/mobile-app/lib/learn/ui/editor_challenge/editor_panels.dart b/mobile-app/lib/learn/ui/editor_challenge/editor_panels.dart new file mode 100644 index 000000000..01381c0fa --- /dev/null +++ b/mobile-app/lib/learn/ui/editor_challenge/editor_panels.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/services/code_service.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/complete_button.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/help_dialog.dart'; +import 'package:freecodecamp/learn/ui/challenge/widgets/parsed_html.dart'; +import 'package:freecodecamp/learn/ui/editor_challenge/editor_challenge_viewmodel.dart'; +import 'package:freecodecamp/routing/auth.dart'; +import 'package:freecodecamp/services/auth_service.dart'; + +const _heading = TextStyle(fontSize: 28, fontWeight: FontWeight.bold); + +class InstructionPanel extends StatelessWidget { + const InstructionPanel({super.key, required this.challenge, this.block}); + + final Challenge challenge; + final Block? block; + + @override + Widget build(BuildContext context) { + final step = RegExp(r'^Step (\d+)$').firstMatch(challenge.title)?.group(1); + final total = block?.challenges.length; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 12, right: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + challenge.title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (step != null && total != null) + Text( + 'Step $step of $total', + style: const TextStyle(fontSize: 14, color: Colors.white70), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ParsedHtml(challenge.description), + ParsedHtml(challenge.instructions), + ], + ), + ), + ), + ], + ); + } +} + +class HintPanel extends ConsumerWidget { + const HintPanel({ + super.key, + required this.challenge, + required this.block, + required this.hint, + required this.onReset, + }); + + final Challenge challenge; + final Block? block; + final String hint; + final VoidCallback onReset; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Hint', style: _heading), + ), + const SizedBox(height: 8), + Expanded(child: SingleChildScrollView(child: ParsedHtml(hint))), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.question_mark), + padding: const EdgeInsets.all(16), + onPressed: () async { + final code = await ref + .read(learnCodeServiceProvider) + .filesMarkdown(challenge); + if (!context.mounted) return; + await showHelpDialog( + context, + challenge, + block?.name ?? 'Daily Coding Challenge', + code: code, + ); + }, + ), + IconButton( + icon: const Icon(Icons.restart_alt), + padding: const EdgeInsets.all(16), + onPressed: onReset, + ), + ], + ), + ], + ); + } +} + +class PassPanel extends ConsumerWidget { + const PassPanel({super.key, required this.challenge, this.block}); + + final Challenge challenge; + final Block? block; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final quote = ref.watch(passQuoteProvider).value; + final user = ref.watch(authProvider).value; + final block = this.block; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Passed', style: _heading), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (quote != null) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '"${quote.quote}"', + style: const TextStyle(fontSize: 20), + ), + ), + if (user == null) + Padding( + padding: const EdgeInsets.all(16), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: FccColors.yellow50, + minimumSize: const Size.fromHeight(50), + shape: const RoundedRectangleBorder(), + ), + onPressed: () => const LoginRoute().push(context), + child: const Text( + 'Sign in to save your progress', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black, fontSize: 20), + ), + ), + ) + else if (block != null) + _BlockProgress( + completed: { + for (final c in user.completedChallenges) c.id, + challenge.id, + }, + block: block, + ), + CompleteButton( + challenge: challenge, + block: block, + label: 'Next', + ), + ], + ), + ), + ), + ], + ); + } +} + +class _BlockProgress extends StatelessWidget { + const _BlockProgress({required this.completed, required this.block}); + + final Set completed; + final Block block; + + @override + Widget build(BuildContext context) { + final total = block.challenges.length; + final done = block.challenges.where((c) => completed.contains(c.id)).length; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + LinearProgressIndicator( + value: done / total, + minHeight: 7, + backgroundColor: FccColors.gray75, + ), + const SizedBox(height: 16), + Text('${(done * 100 / total).round()}% Completed'), + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/landing/daily_challenge_card.dart b/mobile-app/lib/learn/ui/landing/daily_challenge_card.dart new file mode 100644 index 000000000..3ab2d1d9b --- /dev/null +++ b/mobile-app/lib/learn/ui/landing/daily_challenge_card.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/ui/landing/landing_viewmodel.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:timezone/timezone.dart' as tz; + +class DailyChallengeCard extends ConsumerWidget { + const DailyChallengeCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final challenge = ref.watch(dailyChallengeTodayProvider).value; + if (challenge == null) return const SizedBox.shrink(); + + final isCompleted = ref.watch( + authProvider.select( + (s) => + s.value?.completedDailyCodingChallenges.any( + (completed) => completed.id == challenge.id, + ) ?? + false, + ), + ); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + child: Semantics( + label: isCompleted + ? 'Daily challenge completed. View past challenges.' + : 'Daily challenge card', + container: true, + child: Container( + padding: const EdgeInsets.all(20), + width: double.infinity, + decoration: BoxDecoration( + color: FccColors.gray10, + borderRadius: const BorderRadius.all(Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.10), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isCompleted + ? "Today's challenge completed!" + : "Today's challenge", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: FccColors.gray80, + ), + textAlign: TextAlign.center, + ), + if (isCompleted) ...[ + const SizedBox(height: 14), + const _Countdown(), + const SizedBox(height: 14), + _CtaButton( + label: 'View past challenges', + tooltip: 'View past daily challenges', + icon: Icons.history, + onPressed: () => const LearnDailyChallengeArchiveRoute() + .push(context), + ), + ] else ...[ + const SizedBox(height: 4), + Text( + challenge.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: FccColors.gray90, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + const Text( + 'Do you have the skills to complete this challenge?', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 18, color: FccColors.gray85), + ), + const SizedBox(height: 20), + _CtaButton( + label: 'Start the challenge', + tooltip: 'Start the daily challenge now', + icon: Icons.arrow_forward_ios, + onPressed: () => LearnDailyChallengeRoute( + day: challenge.monthDay, + ).push(context), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _CtaButton extends StatelessWidget { + const _CtaButton({ + required this.label, + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String label; + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Tooltip( + message: tooltip, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: FccColors.yellow50, + foregroundColor: FccColors.gray90, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: onPressed, + icon: Icon(icon, size: 20), + label: Text( + label, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + ); + } +} + +// NOTE: A new daily challenge unlocks at midnight US Central time. +class _Countdown extends ConsumerStatefulWidget { + const _Countdown(); + + @override + ConsumerState<_Countdown> createState() => _CountdownState(); +} + +class _CountdownState extends ConsumerState<_Countdown> { + late final Timer _timer; + late Duration _timeLeft = _untilMidnight(); + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + final next = _untilMidnight(); + final passedMidnight = next > _timeLeft; + if (passedMidnight) ref.invalidate(dailyChallengeTodayProvider); + setState(() => _timeLeft = next); + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + Duration _untilMidnight() { + final chicago = tz.getLocation('America/Chicago'); + final now = tz.TZDateTime.now(chicago); + return tz.TZDateTime( + chicago, + now.year, + now.month, + now.day + 1, + ).difference(now); + } + + @override + Widget build(BuildContext context) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final timeLeft = + '${twoDigits(_timeLeft.inHours)}:${twoDigits(_timeLeft.inMinutes % 60)}:${twoDigits(_timeLeft.inSeconds % 60)}'; + + return Semantics( + label: 'Countdown timer. Next challenge in $timeLeft', + liveRegion: true, + excludeSemantics: true, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Next challenge in: $timeLeft', + style: const TextStyle( + color: FccColors.gray90, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/landing/landing_view.dart b/mobile-app/lib/learn/ui/landing/landing_view.dart new file mode 100644 index 000000000..0bf0de17c --- /dev/null +++ b/mobile-app/lib/learn/ui/landing/landing_view.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/constants/curriculum.dart'; +import 'package:freecodecamp/learn/controllers/curriculum_controller.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/services/curriculum_service.dart'; +import 'package:freecodecamp/learn/ui/landing/daily_challenge_card.dart'; +import 'package:freecodecamp/learn/ui/landing/landing_viewmodel.dart'; +import 'package:freecodecamp/learn/ui/landing/superblock_button.dart'; +import 'package:freecodecamp/routing/auth.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/widgets/drawer/drawer.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class LearnLandingView extends ConsumerWidget { + const LearnLandingView({super.key}); + + // NOTE: Errors are swallowed because each provider renders its own error state. + Future _refresh(WidgetRef ref) => Future.wait([ + ref.refresh(curriculumStagesProvider.future), + ref.refresh(dailyChallengeTodayProvider.future), + ]).then((_) {}, onError: (_) {}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stages = ref.watch(curriculumStagesProvider); + final username = ref.watch(authProvider.select((s) => s.value?.username)); + + return Scaffold( + backgroundColor: FccColors.gray90, + appBar: AppBar(title: const Text('LEARN')), + drawer: const DrawerWidget(), + body: RefreshIndicator( + backgroundColor: FccColors.gray90, + color: Colors.white, + onRefresh: () => _refresh(ref), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + if (username != null) + Text( + 'Welcome back, ${username.startsWith('fcc') ? 'User' : username}', + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 20, + ), + ), + const _Quote(), + if (username == null) const _LoginButton(), + const DailyChallengeCard(), + ...switch (stages) { + AsyncData(:final value) => [ + for (final stage in value) ..._stage(context, stage), + ], + AsyncError() => [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 64), + child: ErrorRetry( + message: + 'Oops! Something went wrong. Please try again in a moment.', + onRetry: () => _refresh(ref), + ), + ), + ], + _ => [ + const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ], + }, + ], + ), + ), + ), + ); + } + + List _stage(BuildContext context, CurriculumStage stage) { + return [ + Text(stage.title, style: const TextStyle(fontSize: 21)), + for (final superBlock in stage.superBlocks) + SuperBlockButton( + key: ValueKey(superBlock.dashedName), + title: superBlock.title, + svgAsset: + superBlockIcons[superBlock.dashedName] ?? defaultSuperBlockIcon, + isEnabled: superBlock.isPublic, + onPressed: () => _openSuperBlock(context, superBlock), + ), + // NOTE: The daily challenge archive sits after the core curriculum. + if (stage.key == 'core') ...[ + const Text( + 'A year of coding challenges', + style: TextStyle(fontSize: 21), + ), + SuperBlockButton( + title: 'View challenge archive', + icon: Icons.calendar_today, + onPressed: () => + const LearnDailyChallengeArchiveRoute().push(context), + ), + ], + ]; + } + + void _openSuperBlock(BuildContext context, SuperBlockSummary superBlock) { + if (superBlock.isPublic) { + LearnSuperBlockRoute( + superBlock: superBlock.dashedName, + ).push(context); + return; + } + + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + superBlock.isDisabledByOverride + ? 'Temporarily unavailable, come back soon.' + : 'Not available, use the web version', + ), + duration: const Duration(milliseconds: 2500), + ), + ); + } +} + +class _LoginButton extends StatelessWidget { + const _LoginButton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: FccColors.yellow50, + minimumSize: const Size.fromHeight(50), + shape: const RoundedRectangleBorder(), + ), + onPressed: () => const LoginRoute().push(context), + child: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Sign in to save your progress', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.black, fontSize: 20), + ), + ), + ), + ); + } +} + +class _Quote extends ConsumerWidget { + const _Quote(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final quote = ref.watch(motivationalQuoteProvider).value; + if (quote == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + '"${quote.quote}"', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + '- ${quote.author}', + style: const TextStyle(fontStyle: FontStyle.italic, fontSize: 18), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/landing/landing_viewmodel.dart b/mobile-app/lib/learn/ui/landing/landing_viewmodel.dart new file mode 100644 index 000000000..129ccc9c5 --- /dev/null +++ b/mobile-app/lib/learn/ui/landing/landing_viewmodel.dart @@ -0,0 +1,14 @@ +import 'package:freecodecamp/learn/models/daily_challenge_model.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_service.dart'; +import 'package:freecodecamp/learn/services/quote_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'landing_viewmodel.g.dart'; + +@riverpod +Future dailyChallengeToday(Ref ref) => + ref.watch(learnDailyChallengeServiceProvider).getToday(); + +@riverpod +Future motivationalQuote(Ref ref) => + ref.watch(learnQuoteServiceProvider).getRandomQuote(); diff --git a/mobile-app/lib/learn/ui/landing/landing_viewmodel.g.dart b/mobile-app/lib/learn/ui/landing/landing_viewmodel.g.dart new file mode 100644 index 000000000..a83cbc041 --- /dev/null +++ b/mobile-app/lib/learn/ui/landing/landing_viewmodel.g.dart @@ -0,0 +1,93 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'landing_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(dailyChallengeToday) +final dailyChallengeTodayProvider = DailyChallengeTodayProvider._(); + +final class DailyChallengeTodayProvider + extends + $FunctionalProvider< + AsyncValue, + DailyChallengeOverview, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + DailyChallengeTodayProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dailyChallengeTodayProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dailyChallengeTodayHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return dailyChallengeToday(ref); + } +} + +String _$dailyChallengeTodayHash() => + r'deb320db8627a5fdc11f65c4c8cd99c72a253765'; + +@ProviderFor(motivationalQuote) +final motivationalQuoteProvider = MotivationalQuoteProvider._(); + +final class MotivationalQuoteProvider + extends + $FunctionalProvider< + AsyncValue, + MotivationalQuote, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + MotivationalQuoteProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'motivationalQuoteProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$motivationalQuoteHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return motivationalQuote(ref); + } +} + +String _$motivationalQuoteHash() => r'92f518bd2c1edf843c6598880e5bd945610a7c59'; diff --git a/mobile-app/lib/learn/ui/landing/superblock_button.dart b/mobile-app/lib/learn/ui/landing/superblock_button.dart new file mode 100644 index 000000000..05ff5e19a --- /dev/null +++ b/mobile-app/lib/learn/ui/landing/superblock_button.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +class SuperBlockButton extends StatelessWidget { + const SuperBlockButton({ + super.key, + required this.title, + required this.onPressed, + this.icon, + this.svgAsset, + this.isEnabled = true, + }); + + final String title; + final VoidCallback onPressed; + final IconData? icon; + final String? svgAsset; + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: isEnabled ? 1 : 0.7, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), + constraints: const BoxConstraints(minHeight: 80), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(5), + backgroundColor: FccColors.gray80, + side: const BorderSide(width: 2, color: FccColors.gray75), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + ), + onPressed: onPressed, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: SizedBox( + width: 36, + height: 36, + child: svgAsset != null + ? SvgPicture.asset( + svgAsset!, + fit: BoxFit.contain, + colorFilter: const ColorFilter.mode( + FccColors.gray00, + BlendMode.srcIn, + ), + ) + : Icon(icon, color: FccColors.gray00, size: 36), + ), + ), + Expanded( + child: Text(title, style: const TextStyle(fontSize: 20)), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Icon(Icons.arrow_forward_ios), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/module/module_view.dart b/mobile-app/lib/learn/ui/module/module_view.dart new file mode 100644 index 000000000..0be7d084a --- /dev/null +++ b/mobile-app/lib/learn/ui/module/module_view.dart @@ -0,0 +1,49 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/learn/controllers/curriculum_controller.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/widgets/block_card.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class ModuleView extends ConsumerWidget { + const ModuleView({super.key, required this.superBlock, required this.module}); + + final String superBlock; + final String module; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final provider = superBlockProvider(superBlock); + final found = ref.watch( + provider.select( + (s) => s.whenData( + (superBlock) => switch (superBlock) { + ChapterSuperBlock(:final chapters) => + chapters + .expand((chapter) => chapter.modules) + .firstWhereOrNull((m) => m.dashedName == module), + BlockSuperBlock() => null, + }, + ), + ), + ); + + return Scaffold( + appBar: AppBar(title: Text(found.value?.name ?? '')), + body: found.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Something went wrong!', + onRetry: () => ref.invalidate(provider), + ), + data: (module) => module == null + ? const ErrorRetry(message: 'No blocks available right now.') + : BlockList( + blocks: module.blocks, + padding: const EdgeInsets.all(8), + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/scene_fullscreen/scene_fullscreen_view.dart b/mobile-app/lib/learn/ui/scene_fullscreen/scene_fullscreen_view.dart new file mode 100644 index 000000000..9262303b3 --- /dev/null +++ b/mobile-app/lib/learn/ui/scene_fullscreen/scene_fullscreen_view.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_canvas.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_controls.dart'; +import 'package:go_router/go_router.dart'; + +class SceneFullscreenView extends StatefulWidget { + const SceneFullscreenView({ + super.key, + required this.scene, + required this.title, + }); + + final Scene scene; + final String title; + + @override + State createState() => _SceneFullscreenViewState(); +} + +class _SceneFullscreenViewState extends State { + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + extendBody: true, + bottomNavigationBar: SceneControls( + scene: widget.scene, + title: widget.title, + fullscreen: true, + ), + body: Stack( + children: [ + Positioned.fill( + child: SceneCanvas(scene: widget.scene, title: widget.title), + ), + Positioned( + top: 16, + right: 16, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: IconButton( + tooltip: 'Exit fullscreen', + icon: const Icon( + Icons.fullscreen_exit, + color: FccColors.gray00, + size: 32, + ), + onPressed: () => context.pop(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/superblock/superblock_view.dart b/mobile-app/lib/learn/ui/superblock/superblock_view.dart new file mode 100644 index 000000000..9e3a61a40 --- /dev/null +++ b/mobile-app/lib/learn/ui/superblock/superblock_view.dart @@ -0,0 +1,303 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/constants/curriculum.dart'; +import 'package:freecodecamp/learn/controllers/curriculum_controller.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/learn/ui/widgets/block_card.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class SuperBlockView extends ConsumerStatefulWidget { + const SuperBlockView({super.key, required this.dashedName}); + + final String dashedName; + + @override + ConsumerState createState() => _SuperBlockViewState(); +} + +class _SuperBlockViewState extends ConsumerState { + @override + void initState() { + super.initState(); + unawaited(ref.read(authProvider.notifier).refresh()); + } + + @override + Widget build(BuildContext context) { + final provider = superBlockProvider(widget.dashedName); + final superBlock = ref.watch(provider); + final title = ref.watch( + curriculumStagesProvider.select( + (s) => s.value + ?.expand((stage) => stage.superBlocks) + .firstWhereOrNull((sb) => sb.dashedName == widget.dashedName) + ?.title, + ), + ); + + return Scaffold( + backgroundColor: FccColors.gray90, + appBar: AppBar(title: Text(title ?? '')), + body: superBlock.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Something went wrong!', + onRetry: () => ref.invalidate(provider), + ), + data: (superBlock) => RefreshIndicator( + onRefresh: () => + ref.refresh(provider.future).then((_) {}, onError: (_) {}), + backgroundColor: FccColors.gray90, + color: Colors.white, + child: switch (superBlock) { + BlockSuperBlock(:final blocks) => _Blocks(blocks: blocks), + ChapterSuperBlock(:final chapters) => _Chapters( + superBlock: widget.dashedName, + chapters: chapters + .where((c) => !hiddenExamChapters.contains(c.dashedName)) + .toList(), + ), + }, + ), + ), + ); + } +} + +class _Empty extends StatelessWidget { + const _Empty(); + + @override + Widget build(BuildContext context) { + // NOTE: A ListView so pull-to-refresh still works. + return ListView( + children: const [ + Padding( + padding: EdgeInsets.all(64), + child: Center(child: Text('No blocks available right now.')), + ), + ], + ); + } +} + +class _Blocks extends StatelessWidget { + const _Blocks({required this.blocks}); + + final List blocks; + + @override + Widget build(BuildContext context) { + if (blocks.isEmpty) return const _Empty(); + + return BlockList( + blocks: blocks, + padding: const EdgeInsets.fromLTRB(8, 16, 8, 32), + ); + } +} + +class _Chapters extends StatelessWidget { + const _Chapters({required this.superBlock, required this.chapters}); + + final String superBlock; + final List chapters; + + @override + Widget build(BuildContext context) { + if (chapters.isEmpty) return const _Empty(); + + final isDev = dotenv.getBool('DEVELOPMENT_MODE', fallback: false); + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + for (final chapter in chapters) + _ChapterCard( + superBlock: superBlock, + chapter: chapter, + isLocked: devOnlyChapters.contains(chapter.dashedName) && !isDev, + ), + ], + ); + } +} + +class _ChapterCard extends StatelessWidget { + const _ChapterCard({ + required this.superBlock, + required this.chapter, + required this.isLocked, + }); + + final String superBlock; + final Chapter chapter; + final bool isLocked; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + decoration: const BoxDecoration( + color: FccColors.gray85, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text( + chapter.name, + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + ), + if (chapter.comingSoon || isLocked) + Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(5), + ), + child: const Text('Coming Soon'), + ), + if (!isLocked) + for (final module in chapter.modules) + if (!module.comingSoon) + _ModuleButton(superBlock: superBlock, module: module), + ], + ), + ); + } +} + +class _ModuleButton extends ConsumerWidget { + const _ModuleButton({required this.superBlock, required this.module}); + + final String superBlock; + final Module module; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final challenges = [for (final block in module.blocks) ...block.challenges]; + final isSingleStep = challenges.length == 1; + final isReview = module.blocks.first.label == BlockLabel.review; + final isCertProject = module.moduleType == ModuleType.certProject; + final color = isReview + ? FccColors.orange30 + : isCertProject + ? FccColors.green40 + : FccColors.blue30; + final textColor = isSingleStep ? color : Colors.white; + + final completedCount = ref.watch( + authProvider.select((s) { + final completed = {...?s.value?.completedChallenges.map((c) => c.id)}; + return challenges.where((c) => completed.contains(c.id)).length; + }), + ); + + final subtitle = isCertProject + ? 'Certification Project' + : isReview + ? 'Review' + : null; + + return Container( + margin: const EdgeInsets.all(5), + constraints: const BoxConstraints(minHeight: 100), + decoration: BoxDecoration( + color: color.withValues(alpha: isSingleStep ? 0.3 : 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: color.withValues(alpha: isSingleStep ? 0.5 : 0.4), + width: 2, + ), + ), + child: TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.all(12), + alignment: Alignment.centerLeft, + minimumSize: const Size.fromHeight(100), + backgroundColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + overlayColor: Colors.white, + ), + onPressed: () { + final block = module.blocks.first; + if (isSingleStep && block.challenges.isNotEmpty) { + LearnChallengeRoute( + superBlock: superBlock, + block: block.dashedName, + challengeId: block.challenges.first.id, + ).push(context); + } else { + LearnModuleRoute( + superBlock: superBlock, + module: module.dashedName, + ).push(context); + } + }, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + module.name, + style: TextStyle( + fontSize: 21, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + subtitle, + style: TextStyle( + color: color.withValues(alpha: 0.8), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (!isSingleStep && !isReview) + Text( + '$completedCount/${challenges.length} Steps Complete', + style: const TextStyle( + color: FccColors.gray15, + fontSize: 18, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + isSingleStep + ? Icons.play_arrow_rounded + : Icons.arrow_forward_ios_outlined, + size: 25, + color: textColor, + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/widgets/block_card.dart b/mobile-app/lib/learn/ui/widgets/block_card.dart new file mode 100644 index 000000000..7c85f6d14 --- /dev/null +++ b/mobile-app/lib/learn/ui/widgets/block_card.dart @@ -0,0 +1,504 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/models/curriculum_model.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/widgets/drawer/scroll_shadow.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +const _cardColor = FccColors.gray85; +const _borderColor = FccColors.gray75; +const _tileColor = FccColors.gray80; +const _completedTileColor = FccColors.blue90Translucent; +const _completedBorderColor = FccColors.blue10; + +(IconData?, Color) _labelStyle(BlockLabel label) => switch (label) { + BlockLabel.lecture => (Icons.menu_book_outlined, FccColors.blue30), + BlockLabel.quiz => (Icons.help_outline, FccColors.orange30), + BlockLabel.lab => (Icons.science_outlined, FccColors.green40), + BlockLabel.workshop => (Icons.build_outlined, FccColors.purple10), + BlockLabel.exam => (Icons.school, FccColors.red15), + BlockLabel.review => (Icons.edit_document, FccColors.yellow10), + BlockLabel.warmup => (Icons.fitbit_outlined, FccColors.blue30), + BlockLabel.learn => (Icons.school_outlined, FccColors.green40), + BlockLabel.practice => (Icons.edit_outlined, FccColors.purple10), + BlockLabel.legacy => (null, FccColors.blue05), +}; + +void _openChallenge(BuildContext context, Block block, String challengeId) { + LearnChallengeRoute( + superBlock: block.superBlock, + block: block.dashedName, + challengeId: challengeId, + ).push(context); +} + +void _showUnavailable(BuildContext context) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Temporarily unavailable, come back soon.'), + duration: Duration(milliseconds: 2500), + ), + ); +} + +class BlockList extends StatefulWidget { + const BlockList({super.key, required this.blocks, required this.padding}); + + final List blocks; + final EdgeInsets padding; + + @override + State createState() => _BlockListState(); +} + +class _BlockListState extends State { + final _toggled = {}; + + @override + Widget build(BuildContext context) { + return ListView.builder( + padding: widget.padding, + itemCount: widget.blocks.length, + itemBuilder: (context, index) { + final block = widget.blocks[index]; + + return BlockCard( + key: ValueKey(block.dashedName), + block: block, + isOpen: (index == 0) != _toggled.contains(block.dashedName), + onToggle: () => setState(() { + if (!_toggled.remove(block.dashedName)) { + _toggled.add(block.dashedName); + } + }), + ); + }, + ); + } +} + +class BlockCard extends ConsumerWidget { + const BlockCard({ + super.key, + required this.block, + required this.isOpen, + required this.onToggle, + }); + + final Block block; + final bool isOpen; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final completedChallenges = ref.watch( + authProvider.select((s) => s.value?.completedChallenges), + ); + final completed = {...?completedChallenges?.map((c) => c.id)}; + + final (icon, color) = _labelStyle(block.label); + final isDisabled = block.isDisabledByOverride; + final isOpen = this.isOpen && !isDisabled; + final onToggle = isDisabled + ? () => _showUnavailable(context) + : this.onToggle; + + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Opacity( + opacity: isDisabled ? 0.7 : 1, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _borderColor), + color: _cardColor, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: color, size: 30), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + block.name, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ), + ], + ), + ...switch (block.layout) { + BlockLayout.challengeGrid => [ + _Progress(block: block, completed: completed, color: color), + _Description(block: block), + _ToggleButton( + isOpen: isOpen, + noun: 'Steps', + onPressed: onToggle, + ), + if (isOpen) _StepGrid(block: block, completed: completed), + ], + BlockLayout.challengeList => [ + _Progress( + block: block, + completed: completed, + color: FccColors.blue30, + ), + _Description(block: block), + _ToggleButton( + isOpen: isOpen, + noun: 'Steps', + onPressed: onToggle, + ), + if (isOpen) + for (final challenge in block.challenges) + _ChallengeCard( + title: challenge.title, + isCompleted: completed.contains(challenge.id), + onTap: () => + _openChallenge(context, block, challenge.id), + ), + ], + BlockLayout.challengeDialogue => [ + _Description(block: block), + _ToggleButton( + isOpen: isOpen, + noun: 'Tasks', + onPressed: onToggle, + ), + if (isOpen) _Dialogues(block: block, completed: completed), + ], + BlockLayout.challengeLink => [ + if (block.challenges.every((c) => completed.contains(c.id))) + _CompletedLabel(color: color), + _Description(block: block), + Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: double.infinity, + child: TextButton( + onPressed: isDisabled || block.challenges.isEmpty + ? () => _showUnavailable(context) + : () => _openChallenge( + context, + block, + block.challenges.first.id, + ), + style: TextButton.styleFrom( + backgroundColor: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + ), + child: const Text( + 'Start', + style: TextStyle( + fontWeight: FontWeight.bold, + color: FccColors.gray90, + ), + ), + ), + ), + ), + ], + }, + ], + ), + ), + ), + ); + } +} + +class _CompletedLabel extends StatelessWidget { + const _CompletedLabel({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon(Icons.check_circle, color: color), + ), + Text( + 'Completed', + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } +} + +class _Progress extends StatelessWidget { + const _Progress({ + required this.block, + required this.completed, + required this.color, + }); + + final Block block; + final Set completed; + final Color color; + + @override + Widget build(BuildContext context) { + final total = block.challenges.length; + final done = block.challenges.where((c) => completed.contains(c.id)).length; + if (done == total) return _CompletedLabel(color: color); + + return Padding( + padding: const EdgeInsets.all(10), + child: LinearProgressIndicator( + minHeight: 12, + value: done == 0 ? 0.01 : done / total, + valueColor: AlwaysStoppedAnimation(color), + backgroundColor: _tileColor, + borderRadius: BorderRadius.circular(10), + ), + ); + } +} + +class _Description extends StatelessWidget { + const _Description({required this.block}); + + final Block block; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: HTMLParser(context: context).parse( + '

${block.description.join(' ')}

', + isSelectable: false, + customStyles: { + '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( + color: FccColors.gray05, + ), + 'p': Style(margin: Margins.zero), + }, + ), + ); + } +} + +class _ToggleButton extends StatelessWidget { + const _ToggleButton({ + required this.isOpen, + required this.noun, + required this.onPressed, + }); + + final bool isOpen; + final String noun; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + backgroundColor: _cardColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: const BorderSide(color: _borderColor), + ), + ), + child: Text(isOpen ? 'Hide $noun' : 'Show $noun'), + ), + ); + } +} + +class _ChallengeCard extends StatelessWidget { + const _ChallengeCard({ + required this.title, + required this.isCompleted, + required this.onTap, + this.isHeader = false, + }); + + final String title; + final bool isCompleted; + final VoidCallback onTap; + final bool isHeader; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: isCompleted ? _completedBorderColor : _borderColor, + ), + ), + color: isCompleted ? _completedTileColor : _tileColor, + child: InkWell( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + child: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: isHeader ? FontWeight.bold : null, + ), + ), + ), + ), + ); + } +} + +class _TileGrid extends StatelessWidget { + const _TileGrid({ + required this.block, + required this.challenges, + required this.completed, + this.scrollable = false, + }); + + final Block block; + final List challenges; + final Set completed; + final bool scrollable; + + int _stepNumber(ChallengeOrder challenge, int index) { + final match = RegExp(r'\d+').firstMatch(challenge.title); + return match != null ? int.parse(match.group(0)!) : index + 1; + } + + @override + Widget build(BuildContext context) { + return GridView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: scrollable + ? const AlwaysScrollableScrollPhysics() + : const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + mainAxisSpacing: 3, + crossAxisSpacing: 3, + ), + itemCount: challenges.length, + itemBuilder: (context, index) { + final challenge = challenges[index]; + final isCompleted = completed.contains(challenge.id); + + return TextButton( + onPressed: () => _openChallenge(context, block, challenge.id), + style: TextButton.styleFrom( + backgroundColor: isCompleted ? _completedTileColor : _tileColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: isCompleted ? _completedBorderColor : _borderColor, + ), + ), + ), + child: Text( + '${_stepNumber(challenge, index)}', + semanticsLabel: + '${challenge.title}${isCompleted ? ', completed' : ''}', + ), + ); + }, + ); + } +} + +class _StepGrid extends StatelessWidget { + const _StepGrid({required this.block, required this.completed}); + + final Block block; + final Set completed; + + @override + Widget build(BuildContext context) { + // NOTE: Past four rows the grid scrolls inside a fixed height. + final scrollable = block.challenges.length > 24; + + return Container( + padding: const EdgeInsets.all(8), + constraints: const BoxConstraints(maxHeight: 300), + child: ScrollShadow( + child: _TileGrid( + block: block, + challenges: block.challenges, + completed: completed, + scrollable: scrollable, + ), + ), + ); + } +} + +class _Dialogues extends StatelessWidget { + const _Dialogues({required this.block, required this.completed}); + + final Block block; + final Set completed; + + @override + Widget build(BuildContext context) { + final sections = <({ChallengeOrder header, List tasks})>[]; + for (final (index, challenge) in block.challenges.indexed) { + if (index == 0 || challenge.title.contains('Dialogue')) { + sections.add((header: challenge, tasks: [])); + } else { + sections.last.tasks.add(challenge); + } + } + + return Column( + children: [ + for (final (:header, :tasks) in sections) ...[ + _ChallengeCard( + title: header.title, + isHeader: true, + isCompleted: completed.contains(header.id), + onTap: () => _openChallenge(context, block, header.id), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 12), + child: _TileGrid( + block: block, + challenges: tasks, + completed: completed, + ), + ), + ], + ], + ); + } +} diff --git a/mobile-app/lib/learn/ui/widgets/scene/scene_canvas.dart b/mobile-app/lib/learn/ui/widgets/scene/scene_canvas.dart new file mode 100644 index 000000000..9d6b28e40 --- /dev/null +++ b/mobile-app/lib/learn/ui/widgets/scene/scene_canvas.dart @@ -0,0 +1,149 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/controllers/scene_controller.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; + +class SceneCanvas extends ConsumerWidget { + const SceneCanvas({super.key, required this.scene, required this.title}); + + final Scene scene; + final String title; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(sceneProvider(scene, title)); + final assets = ref.watch(sceneAssetsProvider).value; + if (assets == null) return const ColoredBox(color: FccColors.gray75); + + final line = state.line; + // NOTE: Lower z is further back, so it paints first. + final characters = [...state.characters] + ..sort((a, b) => b.position.z.compareTo(a.position.z)); + + return LayoutBuilder( + builder: (context, constraints) => Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: CachedNetworkImage( + imageUrl: sceneBackgroundUrl(assets, state.background), + fit: BoxFit.cover, + placeholder: (_, _) => const ColoredBox(color: FccColors.gray75), + errorWidget: (_, _, _) => const ColoredBox( + color: FccColors.gray75, + child: Center(child: Icon(Icons.broken_image, size: 48)), + ), + ), + ), + for (final character in characters) + if (characterSprites(assets, character) case final sprites?) + _Character( + key: ValueKey(character.name), + state: character, + sprites: sprites, + canvas: constraints.biggest, + ), + if (state.showCaptions && line != null) + Positioned( + left: 0, + right: 0, + bottom: MediaQuery.paddingOf(context).bottom, + child: _Caption(line: line), + ), + ], + ), + ); + } +} + +class _Caption extends StatelessWidget { + const _Caption({required this.line}); + + final SceneLine line; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withValues(alpha: 0.7), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: line.align == 'right' + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + Text( + line.character, + style: const TextStyle( + color: FccColors.blue50, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + line.text, + style: const TextStyle(color: FccColors.gray00, fontSize: 14), + ), + ], + ), + ); + } +} + +// NOTE: Positions are percentages of the canvas; z scales the character and orders the stack. +class _Character extends StatelessWidget { + const _Character({ + super.key, + required this.state, + required this.sprites, + required this.canvas, + }); + + final CharacterState state; + final CharacterSprites sprites; + final Size canvas; + + Widget _layer(String url) => Positioned.fill( + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.contain, + placeholder: (_, _) => const SizedBox.expand(), + errorWidget: (_, _, _) => const SizedBox.shrink(), + ), + ); + + @override + Widget build(BuildContext context) { + final height = canvas.height * state.position.z.toDouble(); + final width = height * 2 / 3; + final left = state.position.x.toDouble() / 100 * canvas.width - width / 2; + final bottom = + -(height - canvas.height) / 2 - + state.position.y.toDouble() / 100 * canvas.height; + + return AnimatedPositioned( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + left: left, + bottom: bottom, + width: width, + height: height, + child: AnimatedOpacity( + opacity: state.opacity, + duration: const Duration(milliseconds: 500), + child: Stack( + children: [ + _layer(sprites.base), + _layer(sprites.brows), + _layer(sprites.eyes), + if (sprites.glasses case final glasses?) _layer(glasses), + _layer(sprites.mouth), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/learn/ui/widgets/scene/scene_controls.dart b/mobile-app/lib/learn/ui/widgets/scene/scene_controls.dart new file mode 100644 index 000000000..ba7f6519c --- /dev/null +++ b/mobile-app/lib/learn/ui/widgets/scene/scene_controls.dart @@ -0,0 +1,118 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/controllers/scene_controller.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/services/audio_service.dart'; + +class SceneControls extends ConsumerWidget { + const SceneControls({ + super.key, + required this.scene, + required this.title, + this.onFullscreen, + this.fullscreen = false, + }); + + final Scene scene; + final String title; + final VoidCallback? onFullscreen; + final bool fullscreen; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final handler = ref.watch(audioHandlerProvider); + final notifier = ref.read(sceneProvider(scene, title).notifier); + final showCaptions = ref.watch( + sceneProvider(scene, title).select((s) => s.showCaptions), + ); + final active = ref.watch( + sceneProvider(scene, title).select((s) => s.isActive), + ); + final iconColor = fullscreen ? FccColors.gray00 : null; + + return StreamBuilder( + stream: handler.playbackState, + builder: (context, snapshot) { + final state = snapshot.data ?? PlaybackState(); + final completed = + active && state.processingState == AudioProcessingState.completed; + final playing = active && state.playing && !completed; + + final progress = StreamBuilder( + stream: handler.positionStream, + builder: (context, position) { + final total = handler.duration?.inMilliseconds ?? 0; + final current = position.data?.inMilliseconds ?? 0; + + return LinearProgressIndicator( + backgroundColor: FccColors.gray75, + valueColor: const AlwaysStoppedAnimation(FccColors.blue50), + value: !active || total == 0 + ? 0 + : (current / total).clamp(0.0, 1.0), + minHeight: fullscreen ? 6 : 8, + ); + }, + ); + + final play = IconButton( + tooltip: completed ? 'Replay' : (playing ? 'Pause' : 'Play'), + onPressed: playing ? notifier.pause : notifier.play, + icon: Icon( + completed + ? Icons.replay + : (playing ? Icons.pause : Icons.play_arrow), + color: iconColor, + size: fullscreen ? 32 : null, + ), + ); + + final captions = IconButton( + tooltip: showCaptions ? 'Hide captions' : 'Show captions', + onPressed: notifier.toggleCaptions, + icon: Icon( + showCaptions ? Icons.closed_caption : Icons.closed_caption_off, + color: iconColor, + size: fullscreen ? 28 : null, + ), + ); + + if (fullscreen) { + return Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + play, + const SizedBox(width: 12), + Expanded(child: progress), + const SizedBox(width: 12), + captions, + ], + ), + ); + } + + return Column( + children: [ + progress, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + play, + captions, + IconButton( + tooltip: 'Fullscreen', + onPressed: onFullscreen, + icon: const Icon(Icons.fullscreen), + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/mobile-app/lib/learn/ui/widgets/scene/scene_view.dart b/mobile-app/lib/learn/ui/widgets/scene/scene_view.dart new file mode 100644 index 000000000..0d9929780 --- /dev/null +++ b/mobile-app/lib/learn/ui/widgets/scene/scene_view.dart @@ -0,0 +1,70 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/learn/controllers/scene_controller.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/models/scene_assets_model.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_canvas.dart'; +import 'package:freecodecamp/learn/ui/widgets/scene/scene_controls.dart'; +import 'package:freecodecamp/routing/learn.dart'; + +class SceneView extends ConsumerStatefulWidget { + const SceneView({super.key, required this.scene, required this.title}); + + final Scene scene; + final String title; + + @override + ConsumerState createState() => _SceneViewState(); +} + +class _SceneViewState extends ConsumerState { + bool _precached = false; + + void _precache(SceneAssets assets) { + _precached = true; + final backgrounds = { + widget.scene.setup.background, + for (final c in widget.scene.commands) ?c.background, + }; + for (final bg in backgrounds) { + precacheImage( + CachedNetworkImageProvider(sceneBackgroundUrl(assets, bg)), + context, + ); + } + } + + @override + Widget build(BuildContext context) { + final assets = ref.watch(sceneAssetsProvider).value; + if (assets != null && !_precached) _precache(assets); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), + child: Column( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black, + border: Border.all(color: FccColors.gray75, width: 2), + ), + child: SceneCanvas(scene: widget.scene, title: widget.title), + ), + ), + const SizedBox(height: 16), + SceneControls( + scene: widget.scene, + title: widget.title, + onFullscreen: () => LearnSceneFullscreenRoute( + $extra: (widget.scene, widget.title), + ).push(context), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart index bc7a7b893..047d21503 100644 --- a/mobile-app/lib/main.dart +++ b/mobile-app/lib/main.dart @@ -1,105 +1,135 @@ -import 'dart:developer'; +import 'dart:async'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; import 'package:freecodecamp/firebase_options.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/firebase/analytics_service.dart'; -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/service/navigation/quick_actions_service.dart'; -import 'package:freecodecamp/service/news/api_service.dart'; -import 'package:freecodecamp/service/podcast/notification_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/utils/upgrade_controller.dart'; -import 'package:stacked_services/stacked_services.dart'; +import 'package:freecodecamp/learn/services/daily_challenge_reminder_service.dart'; +import 'package:freecodecamp/podcasts/services/library_service.dart'; +import 'package:freecodecamp/routing/router.dart'; +import 'package:freecodecamp/services/analytics_service.dart'; +import 'package:freecodecamp/services/audio_service.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/services/notification_service.dart'; +import 'package:freecodecamp/services/remote_config_service.dart'; +import 'package:timezone/data/latest.dart' as tz; import 'package:upgrader/upgrader.dart'; -Future main({bool testing = false}) async { - WidgetsFlutterBinding.ensureInitialized(); - setupLocator(); - locator().init(); - - await DioService().init(); - await AppAudioService().init(); - await AuthenticationService().init(); - await NewsApiService().init(); - var fbApp = await Firebase.initializeApp( +Future main() async { + // NOTE: The native splash stays until the first frame, which MyApp allows once the session is restored. + WidgetsFlutterBinding.ensureInitialized().deferFirstFrame(); + await dotenv.load(); + final firebaseApp = await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); - if (!testing) { - if (kReleaseMode) { - FlutterError.onError = - FirebaseCrashlytics.instance.recordFlutterFatalError; - PlatformDispatcher.instance.onError = (error, stack) { - FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); - return true; - }; - await fbApp.setAutomaticDataCollectionEnabled(true); - } else { - await fbApp.setAutomaticDataCollectionEnabled(false); - } + if (kReleaseMode) { + FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError; + PlatformDispatcher.instance.onError = (error, stack) { + FirebaseCrashlytics.instance.recordError(error, stack); + return true; + }; } - await RemoteConfigService().init(); - await locator().init(); - await locator().init(); + await firebaseApp.setAutomaticDataCollectionEnabled(kReleaseMode); + tz.initializeTimeZones(); + + await initRemoteConfig(); + + final audioHandler = await initAudioService(); + + runApp( + ProviderScope( + overrides: [audioHandlerProvider.overrideWithValue(audioHandler)], + child: const MyApp(), + ), + ); +} - runApp(const FreeCodeCampMobileApp()); - WidgetsBinding.instance.addPostFrameCallback((_) async { - await locator().requestPermission(); - await locator().setupNotifications(); - }); +class MyApp extends ConsumerStatefulWidget { + const MyApp({super.key}); - await QuickActionsService().init(); + @override + ConsumerState createState() => _MyAppState(); } -class FreeCodeCampMobileApp extends StatelessWidget { - const FreeCodeCampMobileApp({super.key}); +class _MyAppState extends ConsumerState { + final _upgrader = Upgrader(); + + @override + void initState() { + super.initState(); + + router.routerDelegate.addListener(_logScreenView); + ref.listenManual(remoteConfigProvider, (_, next) { + _upgrader.updateState( + _upgrader.state.copyWith( + minAppVersion: Upgrader.parseVersion( + next.minAppVersion, + 'minAppVersion', + false, + ), + ), + ); + }, fireImmediately: true); + + unawaited( + ref + .read(authProvider.future) + .timeout(const Duration(seconds: 10)) + .catchError((_) => null) + .whenComplete(_onSessionRestored), + ); + } + + void _onSessionRestored() { + WidgetsBinding.instance.allowFirstFrame(); + // NOTE: Post-frame callbacks also fire for deferred frames, so these wait until the splash is gone. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_scheduleReminders()); + // NOTE: Deletes audio and artwork that no store row points at + unawaited(ref.read(podcastLibraryServiceProvider).sweepOrphanedFiles()); + }); + } + + Future _scheduleReminders() async { + await ref.read(notificationServiceProvider).requestPermission(); + final user = await ref.read(authProvider.future).catchError((_) => null); + await ref.read(learnDailyChallengeReminderServiceProvider).reschedule(user); + } + + void _logScreenView() { + unawaited( + ref + .read(analyticsProvider) + .logScreenView(screenName: router.state.matchedLocation), + ); + } + + @override + void dispose() { + router.routerDelegate.removeListener(_logScreenView); + super.dispose(); + } @override Widget build(BuildContext context) { - return StreamBuilder( - initialData: locator().locale, - stream: locator().localeStream, - builder: (context, snapshot) { - log('locale: ${snapshot.data}'); - - return MaterialApp( - title: 'freeCodeCamp', - theme: FccTheme.themeDark, - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: locator().locales, - locale: snapshot.data ?? locator().locale, - debugShowCheckedModeBanner: false, - navigatorKey: StackedService.navigatorKey, - onGenerateRoute: StackedRouter().onGenerateRoute, - navigatorObservers: [ - locator().getAnalyticsObserver() - ], - builder: (context, child) { - return UpgradeAlert( - navigatorKey: StackedService.navigatorKey, - dialogStyle: Platform.isIOS - ? UpgradeDialogStyle.cupertino - : UpgradeDialogStyle.material, - showIgnore: false, - upgrader: upgraderController, - child: Overlay( - initialEntries: [OverlayEntry(builder: (context) => child!)], - ), - ); - }, - ); - }, + return MaterialApp.router( + routerConfig: router, + title: 'freeCodeCamp', + theme: FccTheme.themeDark, + builder: (context, child) => UpgradeAlert( + navigatorKey: router.routerDelegate.navigatorKey, + dialogStyle: Platform.isIOS + ? UpgradeDialogStyle.cupertino + : UpgradeDialogStyle.material, + showIgnore: false, + upgrader: _upgrader, + child: child, + ), ); } } diff --git a/mobile-app/lib/models/code-radio/code_radio_model.dart b/mobile-app/lib/models/code-radio/code_radio_model.dart deleted file mode 100644 index f4e0364e4..000000000 --- a/mobile-app/lib/models/code-radio/code_radio_model.dart +++ /dev/null @@ -1,57 +0,0 @@ -class CodeRadio { - const CodeRadio({ - required this.id, - required this.listenUrl, - required this.totalListeners, - required this.duration, - required this.elapsed, - required this.nowPlaying, - required this.nextPlaying, - }); - - final int id; - final String listenUrl; - - final int totalListeners; - - final int duration; - final int elapsed; - - final Song nowPlaying; - final Song nextPlaying; - - factory CodeRadio.fromJson(Map data) { - return CodeRadio( - id: data['station']['id'], - listenUrl: data['station']['listen_url'], - totalListeners: data['listeners']['total'], - elapsed: data['now_playing']['elapsed'], - duration: data['now_playing']['duration'], - nowPlaying: Song.fromJson(data['now_playing']['song']), - nextPlaying: Song.fromJson(data['playing_next']['song'])); - } -} - -class Song { - const Song( - {required this.title, - required this.artist, - required this.album, - required this.artUrl, - required this.id}); - - final String title; - final String artist; - final String album; - final String artUrl; - final String id; - - factory Song.fromJson(Map data) { - return Song( - title: data['title'], - artist: data['artist'], - album: data['album'], - artUrl: data['art'], - id: data['id']); - } -} diff --git a/mobile-app/lib/models/learn/challenge_model.dart b/mobile-app/lib/models/learn/challenge_model.dart deleted file mode 100644 index f107a3122..000000000 --- a/mobile-app/lib/models/learn/challenge_model.dart +++ /dev/null @@ -1,729 +0,0 @@ -import 'package:freecodecamp/enums/ext_type.dart'; - -// NOTE: For reference -enum ChallengeType { - html, // 0 - js, // 1 - backend, // 2 - zipline, // 3 - frontEndProject, // 3 - backEndProject, // 4 - jsProject, // 5 - modern, // 6 - step, // 7 - quiz, // 8 - invalid, // 9 - pythonProject, // 10 - video, // 11 - codeAllyPractice, // 12 - codeAllyCert, // 13 - multifileCertProject, // 14 - theOdinProject, // 15 - colab, // 16 - exam // 17 -} - -enum HelpCategory { - javascript('JavaScript'), - htmlCss('HTML-CSS'), - python('Python'), - backend('Backend Development'), - cSharp('C-Sharp'), - english('English'), - chinese('Chinese Curriculum'), - spanish('Spanish Curriculum'), - odin('Odin'), - euler('Euler'), - rosetta('Rosetta'); - - static HelpCategory fromValue(String value) { - return HelpCategory.values.firstWhere( - (category) => category.value == value, - orElse: () => throw ArgumentError('Invalid help category value: $value'), - ); - } - - final String value; - const HelpCategory(this.value); -} - -class Challenge { - final String id; - final String block; - final String title; - final String description; - final String instructions; - final String dashedName; - final String superBlock; - final String? videoId; - final int challengeType; - final HelpCategory helpCategory; - final String? explanation; - final String transcript; - - final List tests; - final List files; - final Hooks hooks; - - // English Challenges - final FillInTheBlank? fillInTheBlank; - final EnglishScene? audio; - final Scene? scene; - - // Nodules for interactive challenges - final List? nodules; - - final List? questions; - - // Challenge Type 15 - Odin - final List? assignments; - - // Challenge Type 8 - Quiz - // This list contains multiple sets of quiz - final List? quizzes; - - Challenge({ - required this.id, - required this.block, - required this.title, - required this.description, - required this.instructions, - required this.dashedName, - required this.superBlock, - this.videoId, - required this.challengeType, - required this.tests, - required this.files, - required this.helpCategory, - this.explanation, - required this.transcript, - this.nodules, - this.questions, - this.assignments, - this.fillInTheBlank, - this.audio, - this.scene, - this.quizzes, - required this.hooks, - }); - - factory Challenge.fromJson(Map data) { - return Challenge( - id: data['id'], - block: data['block'], - title: data['title'], - description: data['description'] ?? '', - instructions: data['instructions'] ?? '', - dashedName: data['dashedName'], - superBlock: data['superBlock'], - videoId: data['videoId'], - challengeType: data['challengeType'], - helpCategory: HelpCategory.fromValue(data['helpCategory']), - explanation: data['explanation'] ?? '', - transcript: data['transcript'] ?? '', - fillInTheBlank: data['fillInTheBlank'] != null - ? FillInTheBlank.fromJson(data['fillInTheBlank']) - : null, - audio: data['scene'] != null - ? EnglishScene.fromJson(data['scene']['setup']['audio']) - : null, - tests: (data['tests'] ?? []) - .map((file) => ChallengeTest.fromJson(file)) - .toList(), - files: (data['challengeFiles'] ?? []) - .map((file) => ChallengeFile.fromJson(file)) - .toList(), - nodules: (data['nodules'] ?? []) - .map((nodule) => Nodule.fromJson(nodule)) - .toList(), - questions: (data['questions'] as List).isNotEmpty - ? (data['questions'] as List) - .map((q) => Question.fromJson(q)) - .toList() - : null, - assignments: data['assignments'] != null - ? (data['assignments'] as List).cast() - : null, - scene: data['scene'] != null ? Scene.fromJson(data['scene']) : null, - hooks: Hooks.fromJson( - data['hooks'] ?? {'beforeAll': ''}, - ), - quizzes: (data['quizzes'] != null) - ? (data['quizzes'] as List) - .map((quiz) => Quiz.fromJson(quiz)) - .toList() - : null, - ); - } - - static toJson(Challenge challenge) { - return { - 'id': challenge.id, - 'block': challenge.block, - 'title': challenge.title, - 'description': challenge.description, - 'instructions': challenge.instructions, - 'dashedName': challenge.dashedName, - 'superBlock': challenge.superBlock, - 'videoId': challenge.videoId, - 'challengeType': challenge.challengeType, - 'helpCategory': challenge.helpCategory.value, - 'transcript': challenge.transcript, - 'tests': challenge.tests - .map( - (challengeTest) => { - 'text': challengeTest.instruction, - 'testString': challengeTest.javaScript - }, - ) - .toList(), - 'challengeFiles': challenge.files - .map( - (challengeFile) => { - 'ext': challengeFile.ext.name, - 'name': challengeFile.name, - 'head': challengeFile.head, - 'tail': challengeFile.tail, - 'contents': challengeFile.contents, - 'editableRegionBoundaries': - challengeFile.editableRegionBoundaries, - 'history': challengeFile.history, - 'fileKey': challengeFile.fileKey, - }, - ) - .toList(), - 'questions': challenge.questions - ?.map((question) => { - 'text': question.text, - 'answers': question.answers, - 'solution': question.solution, - }) - .toList(), - 'quizzes': challenge.quizzes - ?.map((quiz) => { - 'questions': quiz.questions - .map((question) => { - 'text': question.text, - 'answers': question.answers, - 'solution': question.solution, - }) - .toList(), - }) - .toList(), - }; - } -} - -class Hooks { - final String beforeAll; - final String beforeEach; - final String afterEach; - - Hooks({ - required this.beforeAll, - required this.beforeEach, - required this.afterEach, - }); - - factory Hooks.fromJson(Map data) { - return Hooks( - beforeAll: data['beforeAll'] ?? '', - beforeEach: data['beforeEach'] ?? '', - afterEach: data['afterEach'] ?? '', - ); - } -} - -enum NoduleType { - paragraph('paragraph'), - interactiveEditor('interactiveEditor'); - - final String type; - - const NoduleType(this.type); -} - -class ParagraphNodule implements Nodule { - @override - final NoduleType type = NoduleType.paragraph; - final String contents; - - ParagraphNodule(this.contents); -} - -class InteractiveEditorNodule implements Nodule { - @override - final NoduleType type = NoduleType.interactiveEditor; - final List files; - - InteractiveEditorNodule(this.files); -} - -sealed class Nodule { - abstract final NoduleType type; - - factory Nodule.fromJson(Map data) { - final noduleType = NoduleType.values.firstWhere( - (type) => type.type == data['type'], - orElse: () => throw ArgumentError('Invalid nodule type: ${data['type']}'), - ); - - final noduleData = data['data']; - final noduleContents = data['contents']; - final noduleFiles = data['files']; - if (noduleType == NoduleType.paragraph) { - return ParagraphNodule(noduleContents ?? noduleData); - } else if (noduleType == NoduleType.interactiveEditor) { - return InteractiveEditorNodule( - (noduleFiles ?? noduleData) - .map( - (item) => InterActiveFile.fromJson(item), - ) - .toList(), - ); - } else { - throw ArgumentError( - 'Invalid nodule data type: ${noduleData.runtimeType}'); - } - } -} - -class InterActiveFile { - final String name; - final String contents; - final String ext; - - InterActiveFile({ - required this.ext, - required this.name, - required this.contents, - }); - - factory InterActiveFile.fromJson(Map data) { - return InterActiveFile( - ext: data['ext'], - name: data['name'], - contents: data['contents'], - ); - } -} - -class Question { - final String text; - final List answers; - final int solution; - - Question({ - required this.text, - required this.answers, - required this.solution, - }); - - factory Question.fromJson(Map data) { - return Question( - text: data['text'], - answers: (data['answers'] ?? []) - .map( - (answer) => Answer.fromJson(answer), - ) - .toList(), - solution: data['solution'], - ); - } -} - -class Answer { - final String answer; - String? feedback; - - Answer({ - required this.answer, - this.feedback, - }); - - factory Answer.fromJson(Map data) { - return Answer( - answer: data['answer'], - feedback: data['feedback'], - ); - } -} - -class ChallengeTest { - final String instruction; - final String javaScript; - - ChallengeTest({ - required this.instruction, - required this.javaScript, - }); - - factory ChallengeTest.fromJson(Map data) { - return ChallengeTest( - instruction: data['text'], - javaScript: data['testString'], - ); - } -} - -class ChallengeFile { - final Ext ext; - final String name; - final String? head; - final String? tail; - final String contents; - final List history; - List editableRegionBoundaries; - final String fileKey; - - ChallengeFile({ - required this.ext, - required this.name, - this.head, - this.tail, - required this.editableRegionBoundaries, - required this.contents, - required this.history, - required this.fileKey, - }); - - factory ChallengeFile.fromJson(Map data) { - return ChallengeFile( - ext: parseExt(data['ext']), - name: data['name'], - head: data['head'], - tail: data['tail'], - contents: data['contents'], - editableRegionBoundaries: data['editableRegionBoundaries'] ?? [], - history: ((data['history'] ?? []) as List).cast(), - fileKey: data['fileKey'] ?? data['name'] + data['ext'], - ); - } -} - -class FillInTheBlank { - final String sentence; - final List blanks; - - const FillInTheBlank({required this.sentence, required this.blanks}); - - factory FillInTheBlank.fromJson(Map data) { - return FillInTheBlank( - sentence: data['sentence'], - blanks: data['blanks'] - .map( - (blank) => Blank.fromJson(blank), - ) - .toList(), - ); - } -} - -class Blank { - final String answer; - final String feedback; - - const Blank({ - required this.answer, - required this.feedback, - }); - - factory Blank.fromJson(Map data) { - return Blank( - answer: data['answer'], - feedback: data['feedback'] ?? '', - ); - } -} - -class QuizQuestion { - final String text; - final List answers; - final int solution; - final QuizAudioData? audioData; - - const QuizQuestion({ - required this.text, - required this.answers, - required this.solution, - this.audioData, - }); - - factory QuizQuestion.fromJson(Map data) { - // Quiz data has answer and distractors as separate fields rather than in a single array. - // We combine them into one so that the question schema - // is consistent with the other MCQ challenges. - final allAnswers = [ - ...(data['distractors'] as List) - .map((item) => Answer(answer: item)), - Answer(answer: data['answer']) - ]; - - // shuffle so the correct answer is not always the last - allAnswers.shuffle(); - - // Solution is the index of the correct answer in the shuffled list. - // + 1 here so that it matches the 1-based index used in the UI logic. - final solutionIndex = - allAnswers.indexWhere((a) => a.answer == data['answer']) + 1; - - return QuizQuestion( - text: data['text'], - answers: allAnswers, - solution: solutionIndex, - audioData: data['audioData'] != null - ? QuizAudioData.fromJson(data['audioData']) - : null, - ); - } -} - -class Quiz { - final List questions; - - const Quiz({ - required this.questions, - }); - - factory Quiz.fromJson(Map data) { - return Quiz( - questions: (data['questions'] as List) - .map((item) => QuizQuestion.fromJson(item)) - .toList(), - ); - } -} - -class Scene { - final SceneSetup setup; - final List commands; - - const Scene({ - required this.setup, - required this.commands, - }); - - factory Scene.fromJson(Map data) { - return Scene( - setup: SceneSetup.fromJson(data['setup']), - commands: data['commands'] - .map( - (command) => SceneCommand.fromjson(command), - ) - .toList(), - ); - } -} - -class SceneSetup { - final String background; - final bool? alwaysShowDialogue; - final EnglishScene audio; - final List characters; - - const SceneSetup({ - required this.background, - required this.audio, - required this.characters, - this.alwaysShowDialogue, - }); - - factory SceneSetup.fromJson(Map data) { - return SceneSetup( - background: data['background'], - audio: EnglishScene.fromJson(data['audio']), - characters: data['characters'] - .map( - (character) => SceneCharacter.fromJson(character), - ) - .toList(), - ); - } -} - -class SceneCommand { - final String? background; - final String character; - final SceneCharacterPosition? position; - final num? opacity; - final num startTime; - final num? finishTime; - final SceneDialogue? dialogue; - - const SceneCommand({ - this.background, - required this.character, - this.position, - this.opacity, - required this.startTime, - this.finishTime, - this.dialogue, - }); - - factory SceneCommand.fromjson(Map data) { - return SceneCommand( - background: data['background'], - character: data['character'], - position: data['position'] != null - ? SceneCharacterPosition.fromJson(data['position']) - : null, - opacity: data['opacity'], - startTime: data['startTime'], - finishTime: data['finishTime'], - dialogue: data['dialogue'] != null - ? SceneDialogue.fromJson( - data['dialogue'], - ) - : null, - ); - } -} - -class SceneCharacter { - final String character; - final num? opacity; - final SceneCharacterPosition position; - - const SceneCharacter({ - required this.character, - this.opacity, - required this.position, - }); - - factory SceneCharacter.fromJson(Map data) { - return SceneCharacter( - character: data['character'], - opacity: data['opacity'], - position: SceneCharacterPosition.fromJson( - data['position'], - ), - ); - } -} - -class SceneCharacterPosition { - final num x; - final num y; - final num z; - - const SceneCharacterPosition({ - required this.x, - required this.y, - required this.z, - }); - - factory SceneCharacterPosition.fromJson(Map data) { - return SceneCharacterPosition(x: data['x'], y: data['y'], z: data['z']); - } -} - -class SceneDialogue { - final String text; - final String align; - - const SceneDialogue({ - required this.text, - required this.align, - }); - - factory SceneDialogue.fromJson(Map data) { - return SceneDialogue( - text: data['text'], - align: data['align'], - ); - } -} - -abstract class AudioClip { - String get fileName; - String? get startTimeStamp; - String? get finishTimeStamp; -} - -class EnglishScene implements AudioClip { - @override - final String fileName; - final String startTime; - @override - final String? startTimeStamp; - @override - final String? finishTimeStamp; - - const EnglishScene({ - required this.fileName, - required this.startTime, - required this.startTimeStamp, - required this.finishTimeStamp, - }); - - factory EnglishScene.fromJson(Map data) { - return EnglishScene( - fileName: data['filename'], - startTime: data['startTime'].toString(), - startTimeStamp: data['startTimestamp']?.toString(), - finishTimeStamp: data['finishTimestamp']?.toString(), - ); - } -} - -class QuizTranscriptLine { - final String character; - final String text; - - const QuizTranscriptLine({ - required this.character, - required this.text, - }); - - factory QuizTranscriptLine.fromJson(Map data) { - return QuizTranscriptLine( - character: data['character'], - text: data['text'], - ); - } -} - -class QuizAudio implements AudioClip { - @override - final String fileName; - @override - final String? startTimeStamp; - @override - final String? finishTimeStamp; - - const QuizAudio({ - required this.fileName, - this.startTimeStamp, - this.finishTimeStamp, - }); - - factory QuizAudio.fromJson(Map data) { - return QuizAudio( - fileName: data['filename'], - startTimeStamp: data['startTimestamp']?.toString(), - finishTimeStamp: data['finishTimestamp']?.toString(), - ); - } -} - -class QuizAudioData { - final QuizAudio audio; - final List transcript; - - const QuizAudioData({ - required this.audio, - required this.transcript, - }); - - factory QuizAudioData.fromJson(Map data) { - final audioData = data['audio']; - return QuizAudioData( - audio: QuizAudio.fromJson(audioData), - transcript: (data['transcript'] as List) - .map( - (item) => QuizTranscriptLine.fromJson(item), - ) - .toList(), - ); - } -} diff --git a/mobile-app/lib/models/learn/completed_challenge_model.dart b/mobile-app/lib/models/learn/completed_challenge_model.dart deleted file mode 100644 index 1c3c4458c..000000000 --- a/mobile-app/lib/models/learn/completed_challenge_model.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:freecodecamp/models/learn/challenge_model.dart'; - -class CompletedChallenge { - final String id; - final String? solution; - final String? githubLink; - final int? challengeType; - final DateTime completedDate; - final List files; - - CompletedChallenge({ - required this.id, - this.solution, - this.githubLink, - this.challengeType, - required this.completedDate, - required this.files, - }); - - factory CompletedChallenge.fromJson(Map data) { - return CompletedChallenge( - id: data['id'], - solution: data['solution'], - githubLink: data['githubLink'], - challengeType: data['challengeType'], - completedDate: DateTime.fromMillisecondsSinceEpoch(data['completedDate']), - files: (data['files'] as List) - .map((file) => ChallengeFile.fromJson(file)) - .toList(), - ); - } -} - -class CompletedDailyChallenge { - final String id; - final DateTime completedDate; - final List languages; - - CompletedDailyChallenge({ - required this.id, - required this.completedDate, - required this.languages, - }); - - factory CompletedDailyChallenge.fromJson(Map data) { - return CompletedDailyChallenge( - id: data['id'], - completedDate: DateTime.fromMillisecondsSinceEpoch(data['completedDate']), - languages: List.from(data['languages']), - ); - } -} diff --git a/mobile-app/lib/models/learn/curriculum_model.dart b/mobile-app/lib/models/learn/curriculum_model.dart deleted file mode 100644 index 0cdf17463..000000000 --- a/mobile-app/lib/models/learn/curriculum_model.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'package:freecodecamp/ui/views/learn/utils/learn_globals.dart'; - -enum SuperBlocks { - respWebDesignV9('responsive-web-design-v9'), - javascriptV9('javascript-v9'), - frontEndDevLibsV9('front-end-development-libraries-v9'), - pythonV9('python-v9'), - relationalDbV9('relational-databases-v9'), - backEndDevApisV9('back-end-development-and-apis-v9'), - respWebDesignNew('2022/responsive-web-design'), - respWebDesign('responsive-web-design'), - jsAlgoDataStruct('javascript-algorithms-and-data-structures'), - jsAlgoDataStructNew('javascript-algorithms-and-data-structures-v8'), - frontEndDevLibs('front-end-development-libraries'), - dataVis('data-visualization'), - relationalDb('relational-database'), - backEndDevApis('back-end-development-and-apis'), - qualityAssurance('quality-assurance'), - sciCompPy('scientific-computing-with-python'), - dataAnalysisPy('data-analysis-with-python'), - infoSec('information-security'), - machineLearningPy('machine-learning-with-python'), - codingInterviewPrep('coding-interview-prep'), - theOdinProject('the-odin-project'), - projectEuler('project-euler'), - collegeAlgebraPy('college-algebra-with-python'), - foundationalCSharp('foundational-c-sharp-with-microsoft'), - a2English('a2-english-for-developers'), - b1English('b1-english-for-developers'), - a2Spanish('a2-professional-spanish'), - a1Spanish('a1-professional-spanish'), - a1Chinese('a1-professional-chinese'), - rosettaCode('rosetta-code'), - pythonForEverybody('python-for-everybody'), - devPlayground('dev-playground'); - - final String value; - const SuperBlocks(this.value); - - static SuperBlocks fromValue(String value) { - return SuperBlocks.values.firstWhere( - (superBlock) => superBlock.value == value, - orElse: () => throw ArgumentError('Invalid super block value: $value'), - ); - } -} - -class SuperBlock { - final String dashedName; - final String name; - final List? blocks; - final List? chapters; - - SuperBlock( - {required this.dashedName, - required this.name, - this.blocks, - this.chapters}); - - factory SuperBlock.fromJson( - Map data, - String dashedName, - String name, - ) { - if (chapterBasedSuperBlocks.contains(dashedName)) { - return SuperBlock( - dashedName: dashedName, - name: name, - chapters: (data[data.keys.first]['chapters']).map((chapter) { - return Chapter( - name: chapter['name'], - dashedName: chapter['dashedName'], - comingSoon: chapter['comingSoon'] ?? false, - chapterType: chapter['chapterType'] != null - ? ChapterType.fromValue(chapter['chapterType']) - : null, - modules: (chapter['modules']).map((module) { - return Module( - name: module['name'] ?? 'No name', - dashedName: module['dashedName'], - comingSoon: module['comingSoon'] ?? false, - moduleType: module['moduleType'] != null - ? ModuleType.fromValue(module['moduleType']) - : null, - blocks: (module['blocks']).map((block) { - return Block.fromJson( - block['meta'], - block['intro'], - block['meta']['dashedName'], - dashedName, - name, - ); - }).toList()); - }).toList(), - ); - }).toList(), - ); - } - - return SuperBlock( - dashedName: dashedName, - name: name, - blocks: (data[data.keys.first]['blocks']).map((block) { - return Block.fromJson( - block['meta'], - block['intro'], - block['meta']['dashedName'], - dashedName, - name, - ); - }).toList() - ..sort( - // `order` is guaranteed for block-based super blocks. - (Block a, Block b) => a.order!.compareTo(b.order!), - ), - ); - } - - static Map toMap(SuperBlock superBlock) { - return { - 'dashedName': superBlock.dashedName, - 'name': superBlock.name, - }; - } -} - -enum BlockLabel { - lecture('lecture'), - workshop('workshop'), - lab('lab'), - review('review'), - quiz('quiz'), - exam('exam'), - warmup('warm-up'), - learn('learn'), - practice('practice'), - legacy('legacy'); - - final String value; - const BlockLabel(this.value); - - static BlockLabel fromValue(String value) { - return BlockLabel.values.firstWhere( - (blockLabel) => blockLabel.value == value, - orElse: () => BlockLabel.legacy, - ); - } -} - -enum BlockLayout { - challengeList, - challengeGrid, - challengeDialogue, - challengeLink, - project, -} - -class Block { - final String name; - final String dashedName; - final SuperBlock superBlock; - final BlockLayout layout; - final BlockLabel label; - final List description; - // Blocks in chapter-based super blocks don't have `order`. - final int? order; - // Blocks can be disabled by manual override on Firebase - final bool isDisabledByOverride; - - final List challenges; - final List challengeTiles; - - Block({ - required this.superBlock, - required this.layout, - required this.label, - required this.name, - required this.dashedName, - required this.description, - required this.challenges, - required this.challengeTiles, - this.order, - this.isDisabledByOverride = false, - }); - - factory Block.fromJson( - Map data, - List description, - String dashedName, - String superBlockDashedName, - String superBlockName, - ) { - // set challengeTiles as a custom field as there needs - // to be a raw version (challenges). - - data['challengeTiles'] = []; - - // Clean up description by filtering out empty or whitespace-only strings - final cleanedDescription = description - .where((item) => item != null && item.toString().trim().isNotEmpty) - .toList(); - - // If description is empty, provide a default message - final finalDescription = cleanedDescription.isEmpty - ? ['Continue to improve your skills'] - : cleanedDescription; - - BlockLayout handleLayout(String? layout) { - switch (layout) { - case 'project-list': - case 'challenge-list': - case 'legacy-challenge-list': - return BlockLayout.challengeList; - case 'dialogue-grid': - return BlockLayout.challengeDialogue; - case 'challenge-grid': - case 'legacy-challenge-grid': - return BlockLayout.challengeGrid; - case 'link': - case 'legacy-link': - return BlockLayout.challengeLink; - default: - return BlockLayout.challengeGrid; - } - } - - BlockLabel blockLabelFromString(String type) { - return BlockLabel.fromValue(type); - } - - return Block( - superBlock: SuperBlock( - dashedName: superBlockDashedName, - name: superBlockName, - ), - layout: handleLayout(data['blockLayout']), - // Support both blockLabel and blockType for backward compatibility - label: (data['blockLabel'] ?? data['blockType']) != null - ? blockLabelFromString(data['blockLabel'] ?? data['blockType']) - : BlockLabel.legacy, - name: data['name'], - dashedName: dashedName, - description: finalDescription, - order: data['order'], - challenges: (data['challengeOrder'] as List) - .map( - (dynamic challenge) => ChallengeOrder( - id: challenge[0] ?? challenge['id'], - title: challenge[1] ?? challenge['title'], - ), - ) - .toList(), - challengeTiles: (data['challengeOrder'] as List) - .map( - (dynamic challenge) => ChallengeListTile( - id: challenge[0] ?? challenge['id'], - name: challenge[1] ?? challenge['title'], - dashedName: challenge[1] ?? - challenge['title'] - .toLowerCase() - .replaceAll(' ', '-') - .replaceAll(RegExp(r"[@':]"), ''), - ), - ) - .toList(), - ); - } -} - -class ChallengeListTile { - final String id; - final String name; - final String dashedName; - - ChallengeListTile({ - required this.id, - required this.name, - required this.dashedName, - }); -} - -class SuperBlockButtonData { - final String path; - final String name; - final bool public; - final bool disabledByManualOverride; - - SuperBlockButtonData({ - required this.path, - required this.name, - required this.public, - this.disabledByManualOverride = false, - }); -} - -class ChallengeOrder { - final String id; - final String title; - - ChallengeOrder({ - required this.id, - required this.title, - }); -} - -enum ChapterType { - exam('exam'); - - static ChapterType fromValue(String value) { - return ChapterType.values.firstWhere( - (chapterType) => chapterType.value == value, - orElse: () => throw ArgumentError('Invalid chapter type value: $value'), - ); - } - - final String value; - const ChapterType(this.value); -} - -class Chapter { - final String name; - final String dashedName; - final bool? comingSoon; - final ChapterType? chapterType; - final List? modules; - - Chapter({ - required this.name, - required this.dashedName, - this.comingSoon, - this.chapterType, - this.modules, - }); -} - -enum ModuleType { - review('review'), - exam('exam'), - certProject('cert-project'); - - static ModuleType fromValue(String value) { - return ModuleType.values.firstWhere( - (moduleType) => moduleType.value == value, - orElse: () => throw ArgumentError('Invalid module type value: $value'), - ); - } - - final String value; - const ModuleType(this.value); -} - -class Module { - final String name; - final String dashedName; - final bool? comingSoon; - final ModuleType? moduleType; - final List? blocks; - - Module({ - required this.name, - required this.dashedName, - this.comingSoon, - this.moduleType, - this.blocks, - }); -} diff --git a/mobile-app/lib/models/learn/daily_challenge_model.dart b/mobile-app/lib/models/learn/daily_challenge_model.dart deleted file mode 100644 index d20894c46..000000000 --- a/mobile-app/lib/models/learn/daily_challenge_model.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; - -class DailyChallengeOverview { - final String id; - final int challengeNumber; - final DateTime date; - final String title; - - DailyChallengeOverview({ - required this.id, - required this.challengeNumber, - required this.date, - required this.title, - }); - - factory DailyChallengeOverview.fromJson(Map json) { - return DailyChallengeOverview( - id: json['id'], - challengeNumber: json['challengeNumber'], - date: DateTime.parse(json['date']), - title: json['title'], - ); - } -} - -class DailyChallenge { - final String id; - final int challengeNumber; // 1-based index - final DateTime date; - final String title; - final String description; - final DailyChallengeLanguageData javascript; - final DailyChallengeLanguageData python; - - DailyChallenge({ - required this.id, - required this.challengeNumber, - required this.date, - required this.title, - required this.description, - required this.javascript, - required this.python, - }); - - factory DailyChallenge.fromJson(Map data) { - return DailyChallenge( - id: data['id'], - challengeNumber: data['challengeNumber'], - date: DateTime.parse(data['date']), - title: data['title'], - description: data['description'], - javascript: - DailyChallengeLanguageData.fromJson(data['javascript'], 'javascript'), - python: DailyChallengeLanguageData.fromJson(data['python'], 'python'), - ); - } -} - -class DailyChallengeLanguageData { - final List tests; - final List challengeFiles; - - DailyChallengeLanguageData({ - required this.tests, - required this.challengeFiles, - }); - - factory DailyChallengeLanguageData.fromJson( - Map data, String language) { - return DailyChallengeLanguageData( - tests: (data['tests'] ?? []) - .map((test) => ChallengeTest.fromJson(test)) - .toList(), - challengeFiles: (data['challengeFiles'] ?? []).map((file) { - // Add ext and name based on language - Map fileData = Map.from(file); - if (language == DailyChallengeLanguage.javascript.value) { - fileData['ext'] = 'js'; - fileData['name'] = 'script'; - } else if (language == DailyChallengeLanguage.python.value) { - fileData['ext'] = 'py'; - fileData['name'] = 'main'; - } - return ChallengeFile.fromJson(fileData); - }).toList(), - ); - } -} - -/// Model representing a block of daily challenges for a specific month -class DailyChallengeBlock { - final String monthYear; // e.g., "January 2025" — used as an internal key - final List challenges; - final String description; - - DailyChallengeBlock({ - required this.monthYear, - required this.challenges, - required this.description, - }); - - // Year-agnostic display name, e.g. "January" - String get displayMonth => monthYear.split(' ').first; - - // Daily challenges don't have a block or a super block. - // Return a `Block` here to allow daily challenges - // to reuse the code for standard challenges. - Block toCurriculumBlock() { - final blockName = 'Daily Challenges $displayMonth'; - final blockDashedName = - 'daily-challenges-${monthYear.toLowerCase().replaceAll(' ', '-')}'; - return Block( - name: blockName, - dashedName: blockDashedName, - superBlock: SuperBlock( - dashedName: 'daily-challenges', - name: 'Daily Challenges', - ), - layout: BlockLayout.challengeGrid, - label: BlockLabel.legacy, - description: [description], - challenges: challenges - .map((overview) => ChallengeOrder( - id: overview.id, - title: overview.title, - )) - .toList(), - challengeTiles: challenges - .map((overview) => ChallengeListTile( - id: overview.id, - name: overview.title, - dashedName: overview.title.toLowerCase().replaceAll(' ', '-'), - )) - .toList(), - ); - } -} - -enum DailyChallengeLanguage { - javascript('javascript'), - python('python'); - - final String value; - const DailyChallengeLanguage(this.value); - - @override - String toString() => value; - - static DailyChallengeLanguage fromString(String value) { - return DailyChallengeLanguage.values.firstWhere( - (e) => e.value == value, - orElse: () => throw ArgumentError('Invalid language: $value'), - ); - } -} diff --git a/mobile-app/lib/models/learn/motivational_quote_model.dart b/mobile-app/lib/models/learn/motivational_quote_model.dart deleted file mode 100644 index 5f318cc99..000000000 --- a/mobile-app/lib/models/learn/motivational_quote_model.dart +++ /dev/null @@ -1,10 +0,0 @@ -class MotivationalQuote { - final String quote; - final String author; - - MotivationalQuote({required this.quote, required this.author}); - - factory MotivationalQuote.fromJson(Map data) { - return MotivationalQuote(quote: data['quote'], author: data['author']); - } -} diff --git a/mobile-app/lib/models/learn/saved_challenge_model.dart b/mobile-app/lib/models/learn/saved_challenge_model.dart deleted file mode 100644 index 14472b771..000000000 --- a/mobile-app/lib/models/learn/saved_challenge_model.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:freecodecamp/models/learn/challenge_model.dart'; - -class SavedChallenge { - final String id; - final List files; - - SavedChallenge({ - required this.id, - required this.files, - }); - - factory SavedChallenge.fromJson(Map data) { - return SavedChallenge( - id: data['id'], - files: (data['files'] as List) - .map((file) => ChallengeFile.fromJson(file)) - .toList(), - ); - } -} diff --git a/mobile-app/lib/models/learn/scene_assets_model.dart b/mobile-app/lib/models/learn/scene_assets_model.dart deleted file mode 100644 index 0cb041c15..000000000 --- a/mobile-app/lib/models/learn/scene_assets_model.dart +++ /dev/null @@ -1,67 +0,0 @@ -class SceneAssets { - final String domain; - final String backgrounds; - final String sounds; - final List availableBackgrounds; - final List availableAudios; - final Map characterAssets; - - SceneAssets({ - required this.domain, - required this.backgrounds, - required this.sounds, - required this.availableBackgrounds, - required this.availableAudios, - required this.characterAssets, - }); - - factory SceneAssets.fromJson(Map json) { - final characterAssetsMap = {}; - final rawCharacterAssets = json['characterAssets'] as Map; - - for (final entry in rawCharacterAssets.entries) { - characterAssetsMap[entry.key] = CharacterAssets.fromJson(entry.value); - } - - return SceneAssets( - domain: json['domain'], - backgrounds: json['backgrounds'], - sounds: json['sounds'], - availableBackgrounds: List.from(json['availableBackgrounds']), - availableAudios: List.from(json['availableAudios']), - characterAssets: characterAssetsMap, - ); - } -} - -class CharacterAssets { - final String base; - final String brows; - final String eyesClosed; - final String eyesOpen; - final String? glasses; - final String mouthClosed; - final String mouthOpen; - - CharacterAssets({ - required this.base, - required this.brows, - required this.eyesClosed, - required this.eyesOpen, - this.glasses, - required this.mouthClosed, - required this.mouthOpen, - }); - - factory CharacterAssets.fromJson(Map json) { - return CharacterAssets( - base: json['base'], - brows: json['brows'], - eyesClosed: json['eyesClosed'], - eyesOpen: json['eyesOpen'], - glasses: json['glasses'], - mouthClosed: json['mouthClosed'], - mouthOpen: json['mouthOpen'], - ); - } -} diff --git a/mobile-app/lib/models/main/portfolio_model.dart b/mobile-app/lib/models/main/portfolio_model.dart deleted file mode 100644 index 6d87d1457..000000000 --- a/mobile-app/lib/models/main/portfolio_model.dart +++ /dev/null @@ -1,25 +0,0 @@ -class Portfolio { - final String id; - // Below properties may be empty string instead of null, CONFIRM - final String? title; - final String? url; - final String? image; - final String? description; - - Portfolio({ - required this.id, - this.title, - this.url, - this.image, - this.description, - }); - - factory Portfolio.fromJson(Map data) { - return Portfolio( - id: data['id'], - title: data['title'], - url: data['url'], - image: data['image'], - description: data['description']); - } -} diff --git a/mobile-app/lib/models/main/profile_ui_model.dart b/mobile-app/lib/models/main/profile_ui_model.dart deleted file mode 100644 index 7249717bd..000000000 --- a/mobile-app/lib/models/main/profile_ui_model.dart +++ /dev/null @@ -1,38 +0,0 @@ -class ProfileUI { - final bool? isLocked; - final bool? showAbout; - final bool? showCerts; - final bool? showDonation; - final bool? showHeatMap; - final bool? showLocation; - final bool? showName; - final bool? showPoints; - final bool? showPortfolio; - final bool? showTimeLine; - - ProfileUI( - {required this.isLocked, - required this.showAbout, - required this.showCerts, - required this.showDonation, - required this.showHeatMap, - required this.showLocation, - required this.showName, - required this.showPoints, - required this.showPortfolio, - required this.showTimeLine}); - - factory ProfileUI.fromJson(Map data) { - return ProfileUI( - isLocked: data['isLocked'], - showAbout: data['showAbout'], - showCerts: data['showCerts'], - showDonation: data['showDonation'], - showHeatMap: data['showHeatMap'], - showLocation: data['showLocation'], - showName: data['showName'], - showPoints: data['showPoints'], - showPortfolio: data['showPortfolio'], - showTimeLine: data['showTimeLine']); - } -} diff --git a/mobile-app/lib/models/main/user_model.dart b/mobile-app/lib/models/main/user_model.dart deleted file mode 100644 index 876bd39d1..000000000 --- a/mobile-app/lib/models/main/user_model.dart +++ /dev/null @@ -1,293 +0,0 @@ -import 'package:freecodecamp/enums/theme_type.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/saved_challenge_model.dart'; -import 'package:freecodecamp/models/main/portfolio_model.dart'; -import 'package:freecodecamp/models/main/profile_ui_model.dart'; - -class UserModel { - final NewsUserModel newsUserModel; - final ForumUserModel forumUserModel; - final FccUserModel fccUserModel; - - UserModel( - {required this.forumUserModel, - required this.fccUserModel, - required this.newsUserModel}); -} - -class NewsUserModel {} - -class ForumUserModel {} - -class FccUserModel { - final String id; - final String email; - final String username; - final String name; - final String picture; - final String currentChallengeId; - - final String? githubProfile; - final String? linkedin; - final String? twitter; - final String? website; - final String? location; - final String? about; - - final bool emailVerified; - final bool isEmailVerified; - final bool? sendQuincyEmail; - - final bool isCheater; - final bool isDonating; - - final bool isHonest; - final bool isFrontEndCert; - final bool isDataVisCert; - final bool isBackEndCert; - final bool isFullStackCert; - final bool isRespWebDesignCert; - final bool is2018DataVisCert; - final bool isFrontEndLibsCert; - final bool isJsAlgoDataStructCert; - final bool isApisMicroservicesCert; - final bool isInfosecQaCert; - final bool isQaCertV7; - final bool isInfosecCertV7; - final bool? is2018FullStackCert; - final bool isSciCompPyCertV7; - final bool isDataAnalysisPyCertV7; - final bool isMachineLearningPyCertV7; - final bool isRelationalDatabaseCertV8; - final bool isCollegeAlgebraPyCertV8; - final bool isFoundationalCSharpCertV8; - - final bool? isBanned; - - final DateTime joinDate; - - final int points; // May be null, confirm - - final Map calendar; - // Below member is used for heatmap, don't send back to server - final Map heatMapCal; - - // We can add this in later after confirming it - // final List? badges; - final List completedChallenges; - final List completedDailyCodingChallenges; - final List savedChallenges; - final List portfolio; - final List yearsTopContributor; // If number, parsing it to string - - final Themes theme; - final ProfileUI profileUI; - - FccUserModel({ - required this.id, - required this.email, - required this.username, - required this.name, - required this.picture, - required this.currentChallengeId, - this.githubProfile, - this.linkedin, - this.twitter, - this.website, - this.location, - this.about, - required this.emailVerified, - required this.isEmailVerified, - this.sendQuincyEmail, - required this.isCheater, - required this.isDonating, - required this.isHonest, - required this.isFrontEndCert, - required this.isDataVisCert, - required this.isBackEndCert, - required this.isFullStackCert, - required this.isRespWebDesignCert, - required this.is2018DataVisCert, - required this.isFrontEndLibsCert, - required this.isJsAlgoDataStructCert, - required this.isApisMicroservicesCert, - required this.isInfosecQaCert, - required this.isQaCertV7, - required this.isInfosecCertV7, - this.is2018FullStackCert, - required this.isSciCompPyCertV7, - required this.isDataAnalysisPyCertV7, - required this.isMachineLearningPyCertV7, - required this.isRelationalDatabaseCertV8, - required this.isCollegeAlgebraPyCertV8, - required this.isFoundationalCSharpCertV8, - this.isBanned, - required this.joinDate, - required this.points, - required this.calendar, - required this.heatMapCal, - required this.completedDailyCodingChallenges, - required this.completedChallenges, - required this.savedChallenges, - required this.portfolio, - required this.yearsTopContributor, - required this.theme, - required this.profileUI, - }); - - factory FccUserModel.fromJson(Map data) { - //data['user'][data['result']] - return FccUserModel( - id: data['id'], - email: data['email'], - username: data['username'], - name: data['name'] ?? '', - picture: data['picture'], - currentChallengeId: data['currentChallengeId'], - githubProfile: data['githubProfile'], - linkedin: data['linkedin'], - twitter: data['twitter'], - website: data['website'], - location: data['location'], - about: data['about'], - emailVerified: data['emailVerified'], - isEmailVerified: data['isEmailVerified'], - sendQuincyEmail: data['sendQuincyEmail'], - isCheater: data['isCheater'], - isDonating: data['isDonating'] ?? false, - isHonest: data['isHonest'], - isFrontEndCert: data['isFrontEndCert'], - isDataVisCert: data['isDataVisCert'], - isBackEndCert: data['isBackEndCert'], - isFullStackCert: data['isFullStackCert'], - isRespWebDesignCert: data['isRespWebDesignCert'], - is2018DataVisCert: data['is2018DataVisCert'], - isFrontEndLibsCert: data['isFrontEndLibsCert'], - isJsAlgoDataStructCert: data['isJsAlgoDataStructCert'], - isApisMicroservicesCert: data['isApisMicroservicesCert'], - isInfosecQaCert: data['isInfosecQaCert'], - isQaCertV7: data['isQaCertV7'], - isInfosecCertV7: data['isInfosecCertV7'], - is2018FullStackCert: data['is2018FullStackCert'], - isSciCompPyCertV7: data['isSciCompPyCertV7'], - isDataAnalysisPyCertV7: data['isDataAnalysisPyCertV7'], - isMachineLearningPyCertV7: data['isMachineLearningPyCertV7'], - isRelationalDatabaseCertV8: data['isRelationalDatabaseCertV8'], - isCollegeAlgebraPyCertV8: data['isCollegeAlgebraPyCertV8'], - isFoundationalCSharpCertV8: data['isFoundationalCSharpCertV8'], - isBanned: data['isBanned'], - joinDate: DateTime.parse(data['joinDate']), - points: data['points'], - calendar: parseCalendar( - Map.castFrom(data['calendar'])), - heatMapCal: genHeatMapCal( - Map.castFrom(data['calendar'])), - completedChallenges: (data['completedChallenges'] as List) - .map( - (challenge) => CompletedChallenge.fromJson(challenge)) - .toList(), - completedDailyCodingChallenges: - (data['completedDailyCodingChallenges'] as List) - .map( - (challenge) => CompletedDailyChallenge.fromJson(challenge)) - .toList(), - savedChallenges: (data['savedChallenges'] as List) - .map( - (challenge) => SavedChallenge.fromJson(challenge)) - .toList(), - portfolio: (data['portfolio'] as List) - .map((portfolio) => Portfolio.fromJson(portfolio)) - .toList(), - yearsTopContributor: (data['yearsTopContributor'] as List) - .map((year) => year.toString()) - .toList(), - theme: parseThemes(data['theme']), - profileUI: ProfileUI.fromJson(data['profileUI']), - ); - } - - // IMPORTANT : When the user model, changes this Map has to be changed manually to match it. - // when the user model changes on Main the same has to be done. - - static Future> returnSchemaKeys() async { - return { - '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', - 'location': 'The Cloud', - 'joinDate': '2018-06-10T14:40:07.000Z', - 'points': 100, - '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, - 'isEmailVerified': 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': [] - }; - } -} - -Map parseCalendar(Map calendar) { - calendar.remove('NaN'); - return calendar.map( - (key, val) { - return MapEntry( - DateTime.fromMillisecondsSinceEpoch(int.parse(key) * 1000), - val, - ); - }, - ); -} - -Map genHeatMapCal(Map calendar) { - Map parsedCal = parseCalendar(calendar); - Map heatMapCal = {}; - - parsedCal.forEach((key, val) { - DateTime newKey = DateTime(key.year, key.month, key.day); - if (heatMapCal.containsKey(newKey)) { - heatMapCal[newKey] = heatMapCal[newKey]! + val; - } else { - heatMapCal[newKey] = val; - } - }); - - return heatMapCal; -} diff --git a/mobile-app/lib/models/news/bookmarked_tutorial_model.dart b/mobile-app/lib/models/news/bookmarked_tutorial_model.dart deleted file mode 100644 index b9702e43e..000000000 --- a/mobile-app/lib/models/news/bookmarked_tutorial_model.dart +++ /dev/null @@ -1,20 +0,0 @@ -class BookmarkedTutorial { - late String id; - late String tutorialTitle; - late String authorName; - late String tutorialText; - - BookmarkedTutorial.fromMap(Map map) { - id = map['articleId']; - tutorialTitle = map['articleTitle']; - authorName = map['authorName']; - tutorialText = map['articleText']; - } - - BookmarkedTutorial({ - required this.id, - required this.tutorialTitle, - required this.authorName, - required this.tutorialText, - }); -} diff --git a/mobile-app/lib/models/news/tutorial_model.dart b/mobile-app/lib/models/news/tutorial_model.dart deleted file mode 100644 index 4f94c7496..000000000 --- a/mobile-app/lib/models/news/tutorial_model.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/ui/views/news/widgets/tag_widget.dart'; - -class Tutorial { - final String id; - final String slug; - final String title; - final String? featureImage; - final String? profileImage; - final String authorId; - final String authorName; - final String authorSlug; - final String? createdAt; - final List tagNames; - final List rawTags; - final String? url; - final String? text; - - Tutorial({ - required this.id, - required this.slug, - required this.featureImage, - required this.title, - required this.profileImage, - required this.authorId, - required this.authorName, - required this.authorSlug, - this.createdAt, - this.tagNames = const [], - this.rawTags = const [], - this.url, - this.text, - }); - - static List returnTags( - list, { - bool compact = false, - }) { - List tags = []; - - for (int i = 0; i < list.length; i++) { - tags.add(TagButton( - tagName: list[i]['name'], - tagSlug: list[i]['slug'] ?? list[i]['id'], - key: UniqueKey(), - compact: compact, - )); - } - return tags; - } - - // this factory is for the endpoint where all tutorial thumbnails are received - - factory Tutorial.fromJson(dynamic data) { - return Tutorial( - createdAt: data['publishedAt'], - featureImage: data['coverImage']['url'], - title: data['title'], - profileImage: data['author']['profilePicture'], - authorId: data['author']['id'], - authorName: data['author']['name'], - authorSlug: data['author']['username'], - tagNames: returnTags(data['tags']), - rawTags: data['tags'] ?? [], - id: data['id'], - slug: data['slug'], - ); - } - - factory Tutorial.fromSearch(Map data) { - return Tutorial( - createdAt: data['publishedAt'], - featureImage: data['featureImage'], - title: data['title'], - profileImage: data['author']['profileImage'], - authorId: data['author']['id'], - authorName: data['author']['name'], - authorSlug: returnSlug(data['author']['url']), - tagNames: returnTags(data['tags']), - rawTags: data['tags'] ?? [], - id: data['objectID'], - slug: data['slug'], - ); - } - - static String returnSlug(String url) { - List splitUrl = url.split('/'); - - return splitUrl[splitUrl.length - 2]; - } - - // this is factory is for the post view - - factory Tutorial.toPostFromJson(Map json) { - return Tutorial( - authorId: json['author']['id'], - authorName: json['author']['name'], - authorSlug: json['author']['username'], - profileImage: json['author']['profilePicture'], - tagNames: returnTags(json['tags']), - rawTags: json['tags'] ?? [], - id: json['id'], - title: json['title'], - url: json['url'], - text: json['content']['html'], - featureImage: json['coverImage']['url'], - slug: json['slug'], - ); - } -} - -class Author { - const Author({ - required this.slug, - required this.id, - required this.name, - required this.profileImage, - required this.bio, - required this.website, - required this.location, - required this.facebook, - required this.twitter, - }); - - final String slug; - final String id; - final String name; - final String? profileImage; - final String? bio; - // TODO: Below items are not displayed in the UI. To be added - final String? website; - final String? location; - final String? facebook; - final String? twitter; - - factory Author.toAuthorFromJson(Map data) { - return Author( - slug: data['username'], - id: data['id'], - name: data['name'], - profileImage: data['profilePicture'], - bio: data['bio']['text'], - website: data['socialMediaLinks']['website'], - location: data['location'], - facebook: data['socialMediaLinks']['facebook'], - twitter: data['socialMediaLinks']['twitter'], - ); - } -} diff --git a/mobile-app/lib/models/podcasts/episodes_model.dart b/mobile-app/lib/models/podcasts/episodes_model.dart deleted file mode 100644 index 34e078ea0..000000000 --- a/mobile-app/lib/models/podcasts/episodes_model.dart +++ /dev/null @@ -1,94 +0,0 @@ -// This class name might be changed in the future -class Episodes { - final String id; - final String podcastId; - final String title; - final String? description; - final DateTime? publicationDate; - final String? contentUrl; - final Duration? duration; - - Episodes({ - required this.id, - required this.podcastId, - required this.title, - this.description, - this.publicationDate, - this.contentUrl, - this.duration, - }); - - factory Episodes.fromAPIJson(Map json) => Episodes( - id: json['_id'] as String, - podcastId: json['podcastId'] as String, - title: json['title'] as String, - description: json['description'] as String?, - publicationDate: json['publicationDate'] == null - ? null - : DateTime.parse(json['publicationDate']), - contentUrl: json['audioUrl'] as String?, - duration: - json['duration'] == null ? null : parseDuration(json['duration']), - ); - - factory Episodes.fromDBJson(Map json) => Episodes( - id: json['id'] as String, - podcastId: json['podcastId'] as String, - title: json['title'] as String, - description: json['description'] as String?, - publicationDate: json['publicationDate'] == null - ? null - : DateTime.parse(json['publicationDate']), - contentUrl: json['contentUrl'] as String?, - duration: - json['duration'] == null ? null : parseDuration(json['duration']), - ); - - Map toJson() => { - 'id': id, - 'podcastId': podcastId, - 'title': title, - 'description': description, - 'publicationDate': publicationDate?.toIso8601String(), - 'contentUrl': contentUrl, - 'duration': duration.toString(), - }; - - @override - String toString() { - return '''Episodes { - id: $id, - podcastId: $podcastId, - title: $title, - description: ${description!.substring(0, 100)}, - publicationDate: $publicationDate, - contentUrl: $contentUrl, - duration: $duration, - }'''; - } -} - -Duration parseDuration(String s) { - int hours = 0; - int minutes = 0; - int seconds = 0; - int microsec = 0; - List parts = s.split(':'); - if (parts.length > 2) { - hours = int.tryParse(parts[parts.length - 3]) ?? 0; - } - if (parts.length > 1) { - minutes = int.tryParse(parts[parts.length - 2]) ?? 0; - } - List secondsParts = parts[parts.length - 1].split('.'); - seconds = int.tryParse(secondsParts[0]) ?? 0; - if (secondsParts.length > 1) { - microsec = int.tryParse(secondsParts[1]) ?? 0; - } - return Duration( - hours: hours, - minutes: minutes, - seconds: seconds, - microseconds: microsec, - ); -} diff --git a/mobile-app/lib/models/podcasts/podcasts_model.dart b/mobile-app/lib/models/podcasts/podcasts_model.dart deleted file mode 100644 index c92a4ff00..000000000 --- a/mobile-app/lib/models/podcasts/podcasts_model.dart +++ /dev/null @@ -1,68 +0,0 @@ -class Podcasts { - final String id; - final String url; - final String? link; - final String? title; - final String? description; - final String? image; - final String? copyright; - final int? numEps; - - Podcasts({ - required this.id, - required this.url, - this.link, - this.title, - this.description, - this.image, - this.copyright, - this.numEps, - }); - - factory Podcasts.fromAPIJson(Map json) => Podcasts( - id: json['_id'] as String, - url: json['feedUrl'] as String, - link: json['podcastLink'] as String?, - title: json['title'] as String?, - description: json['description'] as String?, - image: json['imageLink'] as String?, - copyright: json['copyright'] as String?, - numEps: json['numOfEps'] as int? - ); - - factory Podcasts.fromDBJson(Map json) => Podcasts( - id: json['id'] as String, - url: json['url'] as String, - link: json['link'] as String?, - title: json['title'] as String?, - description: json['description'] as String?, - image: json['image'] as String?, - copyright: json['copyright'] as String?, - numEps: json['numEps'] as int? - ); - - Map toJson() => { - 'id': id, - 'url': url, - 'link': link, - 'title': title, - 'description': description, - 'image': image, - 'copyright': copyright, - 'numEps': numEps - }; - - @override - String toString() { - return '''Podcasts { - id: $id, - url: $url, - link: $link, - title: $title, - description: ${description!.substring(0, 100)}, - image: $image, - copyright: $copyright - numEps: $numEps - }'''; - } -} diff --git a/mobile-app/lib/constants/radio_articles.dart b/mobile-app/lib/news/constants.dart similarity index 64% rename from mobile-app/lib/constants/radio_articles.dart rename to mobile-app/lib/news/constants.dart index 3ad740b8e..7e1286038 100644 --- a/mobile-app/lib/constants/radio_articles.dart +++ b/mobile-app/lib/news/constants.dart @@ -1,4 +1,5 @@ -List radioArticles = [ +// NOTE: Post IDs for radio-related articles, hidden from search results on iOS. +const radioArticles = { '66bb9216add24ba427325101', '66b90438941d2f900bad52b1', '66b8d2d40c9c1d363b7c4213', @@ -6,4 +7,4 @@ List radioArticles = [ '66b8d2d957c651c38343a959', '66b8d2d7064c610cf26d29ae', '66b8d46af8e5d39507c4c100', -]; +}; diff --git a/mobile-app/lib/news/controllers/bookmarks_controller.dart b/mobile-app/lib/news/controllers/bookmarks_controller.dart new file mode 100644 index 000000000..06f524b6f --- /dev/null +++ b/mobile-app/lib/news/controllers/bookmarks_controller.dart @@ -0,0 +1,25 @@ +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/news/services/bookmark_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bookmarks_controller.g.dart'; + +@riverpod +class NewsBookmarksNotifier extends _$NewsBookmarksNotifier { + @override + Future> build() { + return ref.watch(newsBookmarkServiceProvider).getBookmarks(); + } + + Future toggle(BookmarkedPost post) async { + final service = ref.read(newsBookmarkServiceProvider); + + if (await service.isBookmarked(post.id)) { + await service.removeBookmark(post.id); + } else { + await service.addBookmark(post); + } + + state = AsyncData(await service.getBookmarks()); + } +} diff --git a/mobile-app/lib/news/controllers/bookmarks_controller.g.dart b/mobile-app/lib/news/controllers/bookmarks_controller.g.dart new file mode 100644 index 000000000..aeb78e67f --- /dev/null +++ b/mobile-app/lib/news/controllers/bookmarks_controller.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmarks_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NewsBookmarksNotifier) +final newsBookmarksProvider = NewsBookmarksNotifierProvider._(); + +final class NewsBookmarksNotifierProvider + extends + $AsyncNotifierProvider> { + NewsBookmarksNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsBookmarksProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsBookmarksNotifierHash(); + + @$internal + @override + NewsBookmarksNotifier create() => NewsBookmarksNotifier(); +} + +String _$newsBookmarksNotifierHash() => + r'48e14a8a3b117cf8bbb8e9de2dcd446c95162b56'; + +abstract class _$NewsBookmarksNotifier + extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + List + >, + AsyncValue>, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/news/controllers/feed_controller.dart b/mobile-app/lib/news/controllers/feed_controller.dart new file mode 100644 index 000000000..e36ed0d9a --- /dev/null +++ b/mobile-app/lib/news/controllers/feed_controller.dart @@ -0,0 +1,92 @@ +import 'package:freecodecamp/news/models/post_summary_model.dart'; +import 'package:freecodecamp/news/services/api_service.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'feed_controller.freezed.dart'; +part 'feed_controller.g.dart'; + +// Which posts a feed shows. Exactly one mode, checked by the compiler. +@freezed +sealed class NewsFeedSource with _$NewsFeedSource { + const factory NewsFeedSource.all() = AllPosts; + const factory NewsFeedSource.tag(String slug) = TagPosts; + const factory NewsFeedSource.author(String id) = AuthorPosts; +} + +@freezed +abstract class NewsFeedState with _$NewsFeedState { + const factory NewsFeedState({ + @Default([]) List posts, + @Default('') String cursor, + @Default(true) bool hasNextPage, + @Default(false) bool isLoadingMore, + // NOTE: Set when appending a page failed; the posts already loaded stay valid. + Object? loadMoreError, + }) = _NewsFeedState; +} + +@riverpod +class NewsFeedNotifier extends _$NewsFeedNotifier { + // NOTE: A refresh keeps this notifier mounted, so a page fetched before it is told apart by this. + int _generation = 0; + + @override + FutureOr build(NewsFeedSource source) async { + _generation++; + final page = await _fetch(ref.watch(newsApiServiceProvider), ''); + + return NewsFeedState( + posts: page.posts, + cursor: page.endCursor, + hasNextPage: page.hasNextPage, + ); + } + + Future _fetch( + NewsApiService service, + String cursor, + ) => switch (source) { + AllPosts() => service.getAllPosts(afterCursor: cursor), + TagPosts(:final slug) => service.getPostsByTag(slug, afterCursor: cursor), + AuthorPosts(:final id) => service.getPostsByAuthor(id, afterCursor: cursor), + }; + + Future fetchNextPage({bool isRetry = false}) async { + final current = state.value; + if (current == null || !current.hasNextPage || current.isLoadingMore) { + return; + } + + // A failed append waits for an explicit retry instead of refiring on scroll. + if (current.loadMoreError != null && !isRetry) return; + + final generation = _generation; + state = AsyncData( + current.copyWith(isLoadingMore: true, loadMoreError: null), + ); + + try { + final page = await _fetch( + ref.read(newsApiServiceProvider), + current.cursor, + ); + if (!ref.mounted || generation != _generation) return; + + state = AsyncData( + current.copyWith( + posts: [...current.posts, ...page.posts], + cursor: page.endCursor, + hasNextPage: page.hasNextPage, + isLoadingMore: false, + ), + ); + } catch (error) { + if (!ref.mounted || generation != _generation) return; + // Keep the loaded posts; only the append failed. + state = AsyncData( + current.copyWith(isLoadingMore: false, loadMoreError: error), + ); + } + } +} diff --git a/mobile-app/lib/news/controllers/feed_controller.freezed.dart b/mobile-app/lib/news/controllers/feed_controller.freezed.dart new file mode 100644 index 000000000..870033411 --- /dev/null +++ b/mobile-app/lib/news/controllers/feed_controller.freezed.dart @@ -0,0 +1,617 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'feed_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$NewsFeedSource { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewsFeedSource); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'NewsFeedSource()'; +} + + +} + +/// @nodoc +class $NewsFeedSourceCopyWith<$Res> { +$NewsFeedSourceCopyWith(NewsFeedSource _, $Res Function(NewsFeedSource) __); +} + + +/// Adds pattern-matching-related methods to [NewsFeedSource]. +extension NewsFeedSourcePatterns on NewsFeedSource { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( AllPosts value)? all,TResult Function( TagPosts value)? tag,TResult Function( AuthorPosts value)? author,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case AllPosts() when all != null: +return all(_that);case TagPosts() when tag != null: +return tag(_that);case AuthorPosts() when author != null: +return author(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( AllPosts value) all,required TResult Function( TagPosts value) tag,required TResult Function( AuthorPosts value) author,}){ +final _that = this; +switch (_that) { +case AllPosts(): +return all(_that);case TagPosts(): +return tag(_that);case AuthorPosts(): +return author(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( AllPosts value)? all,TResult? Function( TagPosts value)? tag,TResult? Function( AuthorPosts value)? author,}){ +final _that = this; +switch (_that) { +case AllPosts() when all != null: +return all(_that);case TagPosts() when tag != null: +return tag(_that);case AuthorPosts() when author != null: +return author(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? all,TResult Function( String slug)? tag,TResult Function( String id)? author,required TResult orElse(),}) {final _that = this; +switch (_that) { +case AllPosts() when all != null: +return all();case TagPosts() when tag != null: +return tag(_that.slug);case AuthorPosts() when author != null: +return author(_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() all,required TResult Function( String slug) tag,required TResult Function( String id) author,}) {final _that = this; +switch (_that) { +case AllPosts(): +return all();case TagPosts(): +return tag(_that.slug);case AuthorPosts(): +return author(_that.id);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? all,TResult? Function( String slug)? tag,TResult? Function( String id)? author,}) {final _that = this; +switch (_that) { +case AllPosts() when all != null: +return all();case TagPosts() when tag != null: +return tag(_that.slug);case AuthorPosts() when author != null: +return author(_that.id);case _: + return null; + +} +} + +} + +/// @nodoc + + +class AllPosts implements NewsFeedSource { + const AllPosts(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AllPosts); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'NewsFeedSource.all()'; +} + + +} + + + + +/// @nodoc + + +class TagPosts implements NewsFeedSource { + const TagPosts(this.slug); + + + final String slug; + +/// Create a copy of NewsFeedSource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TagPostsCopyWith get copyWith => _$TagPostsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TagPosts&&(identical(other.slug, slug) || other.slug == slug)); +} + + +@override +int get hashCode => Object.hash(runtimeType,slug); + +@override +String toString() { + return 'NewsFeedSource.tag(slug: $slug)'; +} + + +} + +/// @nodoc +abstract mixin class $TagPostsCopyWith<$Res> implements $NewsFeedSourceCopyWith<$Res> { + factory $TagPostsCopyWith(TagPosts value, $Res Function(TagPosts) _then) = _$TagPostsCopyWithImpl; +@useResult +$Res call({ + String slug +}); + + + + +} +/// @nodoc +class _$TagPostsCopyWithImpl<$Res> + implements $TagPostsCopyWith<$Res> { + _$TagPostsCopyWithImpl(this._self, this._then); + + final TagPosts _self; + final $Res Function(TagPosts) _then; + +/// Create a copy of NewsFeedSource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? slug = null,}) { + return _then(TagPosts( +null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class AuthorPosts implements NewsFeedSource { + const AuthorPosts(this.id); + + + final String id; + +/// Create a copy of NewsFeedSource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthorPostsCopyWith get copyWith => _$AuthorPostsCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthorPosts&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'NewsFeedSource.author(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthorPostsCopyWith<$Res> implements $NewsFeedSourceCopyWith<$Res> { + factory $AuthorPostsCopyWith(AuthorPosts value, $Res Function(AuthorPosts) _then) = _$AuthorPostsCopyWithImpl; +@useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$AuthorPostsCopyWithImpl<$Res> + implements $AuthorPostsCopyWith<$Res> { + _$AuthorPostsCopyWithImpl(this._self, this._then); + + final AuthorPosts _self; + final $Res Function(AuthorPosts) _then; + +/// Create a copy of NewsFeedSource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? id = null,}) { + return _then(AuthorPosts( +null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$NewsFeedState { + + List get posts; String get cursor; bool get hasNextPage; bool get isLoadingMore; Object? get loadMoreError; +/// Create a copy of NewsFeedState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$NewsFeedStateCopyWith get copyWith => _$NewsFeedStateCopyWithImpl(this as NewsFeedState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is NewsFeedState&&const DeepCollectionEquality().equals(other.posts, posts)&&(identical(other.cursor, cursor) || other.cursor == cursor)&&(identical(other.hasNextPage, hasNextPage) || other.hasNextPage == hasNextPage)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&const DeepCollectionEquality().equals(other.loadMoreError, loadMoreError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(posts),cursor,hasNextPage,isLoadingMore,const DeepCollectionEquality().hash(loadMoreError)); + +@override +String toString() { + return 'NewsFeedState(posts: $posts, cursor: $cursor, hasNextPage: $hasNextPage, isLoadingMore: $isLoadingMore, loadMoreError: $loadMoreError)'; +} + + +} + +/// @nodoc +abstract mixin class $NewsFeedStateCopyWith<$Res> { + factory $NewsFeedStateCopyWith(NewsFeedState value, $Res Function(NewsFeedState) _then) = _$NewsFeedStateCopyWithImpl; +@useResult +$Res call({ + List posts, String cursor, bool hasNextPage, bool isLoadingMore, Object? loadMoreError +}); + + + + +} +/// @nodoc +class _$NewsFeedStateCopyWithImpl<$Res> + implements $NewsFeedStateCopyWith<$Res> { + _$NewsFeedStateCopyWithImpl(this._self, this._then); + + final NewsFeedState _self; + final $Res Function(NewsFeedState) _then; + +/// Create a copy of NewsFeedState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? posts = null,Object? cursor = null,Object? hasNextPage = null,Object? isLoadingMore = null,Object? loadMoreError = freezed,}) { + return _then(_self.copyWith( +posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable +as List,cursor: null == cursor ? _self.cursor : cursor // ignore: cast_nullable_to_non_nullable +as String,hasNextPage: null == hasNextPage ? _self.hasNextPage : hasNextPage // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreError: freezed == loadMoreError ? _self.loadMoreError : loadMoreError , + )); +} + +} + + +/// Adds pattern-matching-related methods to [NewsFeedState]. +extension NewsFeedStatePatterns on NewsFeedState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _NewsFeedState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _NewsFeedState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _NewsFeedState value) $default,){ +final _that = this; +switch (_that) { +case _NewsFeedState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _NewsFeedState value)? $default,){ +final _that = this; +switch (_that) { +case _NewsFeedState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List posts, String cursor, bool hasNextPage, bool isLoadingMore, Object? loadMoreError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _NewsFeedState() when $default != null: +return $default(_that.posts,_that.cursor,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List posts, String cursor, bool hasNextPage, bool isLoadingMore, Object? loadMoreError) $default,) {final _that = this; +switch (_that) { +case _NewsFeedState(): +return $default(_that.posts,_that.cursor,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List posts, String cursor, bool hasNextPage, bool isLoadingMore, Object? loadMoreError)? $default,) {final _that = this; +switch (_that) { +case _NewsFeedState() when $default != null: +return $default(_that.posts,_that.cursor,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _NewsFeedState implements NewsFeedState { + const _NewsFeedState({final List posts = const [], this.cursor = '', this.hasNextPage = true, this.isLoadingMore = false, this.loadMoreError}): _posts = posts; + + + final List _posts; +@override@JsonKey() List get posts { + if (_posts is EqualUnmodifiableListView) return _posts; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_posts); +} + +@override@JsonKey() final String cursor; +@override@JsonKey() final bool hasNextPage; +@override@JsonKey() final bool isLoadingMore; +@override final Object? loadMoreError; + +/// Create a copy of NewsFeedState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$NewsFeedStateCopyWith<_NewsFeedState> get copyWith => __$NewsFeedStateCopyWithImpl<_NewsFeedState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _NewsFeedState&&const DeepCollectionEquality().equals(other._posts, _posts)&&(identical(other.cursor, cursor) || other.cursor == cursor)&&(identical(other.hasNextPage, hasNextPage) || other.hasNextPage == hasNextPage)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&const DeepCollectionEquality().equals(other.loadMoreError, loadMoreError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_posts),cursor,hasNextPage,isLoadingMore,const DeepCollectionEquality().hash(loadMoreError)); + +@override +String toString() { + return 'NewsFeedState(posts: $posts, cursor: $cursor, hasNextPage: $hasNextPage, isLoadingMore: $isLoadingMore, loadMoreError: $loadMoreError)'; +} + + +} + +/// @nodoc +abstract mixin class _$NewsFeedStateCopyWith<$Res> implements $NewsFeedStateCopyWith<$Res> { + factory _$NewsFeedStateCopyWith(_NewsFeedState value, $Res Function(_NewsFeedState) _then) = __$NewsFeedStateCopyWithImpl; +@override @useResult +$Res call({ + List posts, String cursor, bool hasNextPage, bool isLoadingMore, Object? loadMoreError +}); + + + + +} +/// @nodoc +class __$NewsFeedStateCopyWithImpl<$Res> + implements _$NewsFeedStateCopyWith<$Res> { + __$NewsFeedStateCopyWithImpl(this._self, this._then); + + final _NewsFeedState _self; + final $Res Function(_NewsFeedState) _then; + +/// Create a copy of NewsFeedState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? posts = null,Object? cursor = null,Object? hasNextPage = null,Object? isLoadingMore = null,Object? loadMoreError = freezed,}) { + return _then(_NewsFeedState( +posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable +as List,cursor: null == cursor ? _self.cursor : cursor // ignore: cast_nullable_to_non_nullable +as String,hasNextPage: null == hasNextPage ? _self.hasNextPage : hasNextPage // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreError: freezed == loadMoreError ? _self.loadMoreError : loadMoreError , + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/news/controllers/feed_controller.g.dart b/mobile-app/lib/news/controllers/feed_controller.g.dart new file mode 100644 index 000000000..c86587f4d --- /dev/null +++ b/mobile-app/lib/news/controllers/feed_controller.g.dart @@ -0,0 +1,99 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NewsFeedNotifier) +final newsFeedProvider = NewsFeedNotifierFamily._(); + +final class NewsFeedNotifierProvider + extends $AsyncNotifierProvider { + NewsFeedNotifierProvider._({ + required NewsFeedNotifierFamily super.from, + required NewsFeedSource super.argument, + }) : super( + retry: null, + name: r'newsFeedProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsFeedNotifierHash(); + + @override + String toString() { + return r'newsFeedProvider' + '' + '($argument)'; + } + + @$internal + @override + NewsFeedNotifier create() => NewsFeedNotifier(); + + @override + bool operator ==(Object other) { + return other is NewsFeedNotifierProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$newsFeedNotifierHash() => r'a485ea16eae289dee3a26758570aeaf736d47e0b'; + +final class NewsFeedNotifierFamily extends $Family + with + $ClassFamilyOverride< + NewsFeedNotifier, + AsyncValue, + NewsFeedState, + FutureOr, + NewsFeedSource + > { + NewsFeedNotifierFamily._() + : super( + retry: null, + name: r'newsFeedProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + NewsFeedNotifierProvider call(NewsFeedSource source) => + NewsFeedNotifierProvider._(argument: source, from: this); + + @override + String toString() => r'newsFeedProvider'; +} + +abstract class _$NewsFeedNotifier extends $AsyncNotifier { + late final _$args = ref.$arg as NewsFeedSource; + NewsFeedSource get source => _$args; + + FutureOr build(NewsFeedSource source); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, NewsFeedState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, NewsFeedState>, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/mobile-app/lib/news/models/author_model.dart b/mobile-app/lib/news/models/author_model.dart new file mode 100644 index 000000000..c8ef9f895 --- /dev/null +++ b/mobile-app/lib/news/models/author_model.dart @@ -0,0 +1,39 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'author_model.freezed.dart'; +part 'author_model.g.dart'; + +@freezed +abstract class Bio with _$Bio { + const factory Bio({required String text}) = _Bio; + + factory Bio.fromJson(Map json) => _$BioFromJson(json); +} + +@freezed +abstract class SocialMediaLinks with _$SocialMediaLinks { + const factory SocialMediaLinks({ + String? website, + String? twitter, + String? facebook, + }) = _SocialMediaLinks; + + factory SocialMediaLinks.fromJson(Map json) => + _$SocialMediaLinksFromJson(json); +} + +@freezed +abstract class Author with _$Author { + const factory Author({ + required String username, + required String id, + required String name, + String? profilePicture, + Bio? bio, + String? location, + // TODO: Below items are not displayed in the UI. To be added + SocialMediaLinks? socialMediaLinks, + }) = _Author; + + factory Author.fromJson(Map json) => _$AuthorFromJson(json); +} diff --git a/mobile-app/lib/news/models/author_model.freezed.dart b/mobile-app/lib/news/models/author_model.freezed.dart new file mode 100644 index 000000000..44e37478a --- /dev/null +++ b/mobile-app/lib/news/models/author_model.freezed.dart @@ -0,0 +1,875 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'author_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Bio { + + String get text; +/// Create a copy of Bio +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BioCopyWith get copyWith => _$BioCopyWithImpl(this as Bio, _$identity); + + /// Serializes this Bio to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Bio&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,text); + +@override +String toString() { + return 'Bio(text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class $BioCopyWith<$Res> { + factory $BioCopyWith(Bio value, $Res Function(Bio) _then) = _$BioCopyWithImpl; +@useResult +$Res call({ + String text +}); + + + + +} +/// @nodoc +class _$BioCopyWithImpl<$Res> + implements $BioCopyWith<$Res> { + _$BioCopyWithImpl(this._self, this._then); + + final Bio _self; + final $Res Function(Bio) _then; + +/// Create a copy of Bio +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? text = null,}) { + return _then(_self.copyWith( +text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Bio]. +extension BioPatterns on Bio { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Bio value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Bio() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Bio value) $default,){ +final _that = this; +switch (_that) { +case _Bio(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Bio value)? $default,){ +final _that = this; +switch (_that) { +case _Bio() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String text)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Bio() when $default != null: +return $default(_that.text);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String text) $default,) {final _that = this; +switch (_that) { +case _Bio(): +return $default(_that.text);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String text)? $default,) {final _that = this; +switch (_that) { +case _Bio() when $default != null: +return $default(_that.text);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Bio implements Bio { + const _Bio({required this.text}); + factory _Bio.fromJson(Map json) => _$BioFromJson(json); + +@override final String text; + +/// Create a copy of Bio +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BioCopyWith<_Bio> get copyWith => __$BioCopyWithImpl<_Bio>(this, _$identity); + +@override +Map toJson() { + return _$BioToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Bio&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,text); + +@override +String toString() { + return 'Bio(text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class _$BioCopyWith<$Res> implements $BioCopyWith<$Res> { + factory _$BioCopyWith(_Bio value, $Res Function(_Bio) _then) = __$BioCopyWithImpl; +@override @useResult +$Res call({ + String text +}); + + + + +} +/// @nodoc +class __$BioCopyWithImpl<$Res> + implements _$BioCopyWith<$Res> { + __$BioCopyWithImpl(this._self, this._then); + + final _Bio _self; + final $Res Function(_Bio) _then; + +/// Create a copy of Bio +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? text = null,}) { + return _then(_Bio( +text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$SocialMediaLinks { + + String? get website; String? get twitter; String? get facebook; +/// Create a copy of SocialMediaLinks +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SocialMediaLinksCopyWith get copyWith => _$SocialMediaLinksCopyWithImpl(this as SocialMediaLinks, _$identity); + + /// Serializes this SocialMediaLinks to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SocialMediaLinks&&(identical(other.website, website) || other.website == website)&&(identical(other.twitter, twitter) || other.twitter == twitter)&&(identical(other.facebook, facebook) || other.facebook == facebook)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,website,twitter,facebook); + +@override +String toString() { + return 'SocialMediaLinks(website: $website, twitter: $twitter, facebook: $facebook)'; +} + + +} + +/// @nodoc +abstract mixin class $SocialMediaLinksCopyWith<$Res> { + factory $SocialMediaLinksCopyWith(SocialMediaLinks value, $Res Function(SocialMediaLinks) _then) = _$SocialMediaLinksCopyWithImpl; +@useResult +$Res call({ + String? website, String? twitter, String? facebook +}); + + + + +} +/// @nodoc +class _$SocialMediaLinksCopyWithImpl<$Res> + implements $SocialMediaLinksCopyWith<$Res> { + _$SocialMediaLinksCopyWithImpl(this._self, this._then); + + final SocialMediaLinks _self; + final $Res Function(SocialMediaLinks) _then; + +/// Create a copy of SocialMediaLinks +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? website = freezed,Object? twitter = freezed,Object? facebook = freezed,}) { + return _then(_self.copyWith( +website: freezed == website ? _self.website : website // ignore: cast_nullable_to_non_nullable +as String?,twitter: freezed == twitter ? _self.twitter : twitter // ignore: cast_nullable_to_non_nullable +as String?,facebook: freezed == facebook ? _self.facebook : facebook // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SocialMediaLinks]. +extension SocialMediaLinksPatterns on SocialMediaLinks { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SocialMediaLinks value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SocialMediaLinks() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SocialMediaLinks value) $default,){ +final _that = this; +switch (_that) { +case _SocialMediaLinks(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SocialMediaLinks value)? $default,){ +final _that = this; +switch (_that) { +case _SocialMediaLinks() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? website, String? twitter, String? facebook)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SocialMediaLinks() when $default != null: +return $default(_that.website,_that.twitter,_that.facebook);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? website, String? twitter, String? facebook) $default,) {final _that = this; +switch (_that) { +case _SocialMediaLinks(): +return $default(_that.website,_that.twitter,_that.facebook);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? website, String? twitter, String? facebook)? $default,) {final _that = this; +switch (_that) { +case _SocialMediaLinks() when $default != null: +return $default(_that.website,_that.twitter,_that.facebook);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SocialMediaLinks implements SocialMediaLinks { + const _SocialMediaLinks({this.website, this.twitter, this.facebook}); + factory _SocialMediaLinks.fromJson(Map json) => _$SocialMediaLinksFromJson(json); + +@override final String? website; +@override final String? twitter; +@override final String? facebook; + +/// Create a copy of SocialMediaLinks +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SocialMediaLinksCopyWith<_SocialMediaLinks> get copyWith => __$SocialMediaLinksCopyWithImpl<_SocialMediaLinks>(this, _$identity); + +@override +Map toJson() { + return _$SocialMediaLinksToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SocialMediaLinks&&(identical(other.website, website) || other.website == website)&&(identical(other.twitter, twitter) || other.twitter == twitter)&&(identical(other.facebook, facebook) || other.facebook == facebook)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,website,twitter,facebook); + +@override +String toString() { + return 'SocialMediaLinks(website: $website, twitter: $twitter, facebook: $facebook)'; +} + + +} + +/// @nodoc +abstract mixin class _$SocialMediaLinksCopyWith<$Res> implements $SocialMediaLinksCopyWith<$Res> { + factory _$SocialMediaLinksCopyWith(_SocialMediaLinks value, $Res Function(_SocialMediaLinks) _then) = __$SocialMediaLinksCopyWithImpl; +@override @useResult +$Res call({ + String? website, String? twitter, String? facebook +}); + + + + +} +/// @nodoc +class __$SocialMediaLinksCopyWithImpl<$Res> + implements _$SocialMediaLinksCopyWith<$Res> { + __$SocialMediaLinksCopyWithImpl(this._self, this._then); + + final _SocialMediaLinks _self; + final $Res Function(_SocialMediaLinks) _then; + +/// Create a copy of SocialMediaLinks +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? website = freezed,Object? twitter = freezed,Object? facebook = freezed,}) { + return _then(_SocialMediaLinks( +website: freezed == website ? _self.website : website // ignore: cast_nullable_to_non_nullable +as String?,twitter: freezed == twitter ? _self.twitter : twitter // ignore: cast_nullable_to_non_nullable +as String?,facebook: freezed == facebook ? _self.facebook : facebook // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$Author { + + String get username; String get id; String get name; String? get profilePicture; Bio? get bio; String? get location; SocialMediaLinks? get socialMediaLinks; +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AuthorCopyWith get copyWith => _$AuthorCopyWithImpl(this as Author, _$identity); + + /// Serializes this Author to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Author&&(identical(other.username, username) || other.username == username)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.profilePicture, profilePicture) || other.profilePicture == profilePicture)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.location, location) || other.location == location)&&(identical(other.socialMediaLinks, socialMediaLinks) || other.socialMediaLinks == socialMediaLinks)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username,id,name,profilePicture,bio,location,socialMediaLinks); + +@override +String toString() { + return 'Author(username: $username, id: $id, name: $name, profilePicture: $profilePicture, bio: $bio, location: $location, socialMediaLinks: $socialMediaLinks)'; +} + + +} + +/// @nodoc +abstract mixin class $AuthorCopyWith<$Res> { + factory $AuthorCopyWith(Author value, $Res Function(Author) _then) = _$AuthorCopyWithImpl; +@useResult +$Res call({ + String username, String id, String name, String? profilePicture, Bio? bio, String? location, SocialMediaLinks? socialMediaLinks +}); + + +$BioCopyWith<$Res>? get bio;$SocialMediaLinksCopyWith<$Res>? get socialMediaLinks; + +} +/// @nodoc +class _$AuthorCopyWithImpl<$Res> + implements $AuthorCopyWith<$Res> { + _$AuthorCopyWithImpl(this._self, this._then); + + final Author _self; + final $Res Function(Author) _then; + +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? username = null,Object? id = null,Object? name = null,Object? profilePicture = freezed,Object? bio = freezed,Object? location = freezed,Object? socialMediaLinks = freezed,}) { + return _then(_self.copyWith( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,profilePicture: freezed == profilePicture ? _self.profilePicture : profilePicture // ignore: cast_nullable_to_non_nullable +as String?,bio: freezed == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable +as Bio?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,socialMediaLinks: freezed == socialMediaLinks ? _self.socialMediaLinks : socialMediaLinks // ignore: cast_nullable_to_non_nullable +as SocialMediaLinks?, + )); +} +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BioCopyWith<$Res>? get bio { + if (_self.bio == null) { + return null; + } + + return $BioCopyWith<$Res>(_self.bio!, (value) { + return _then(_self.copyWith(bio: value)); + }); +}/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SocialMediaLinksCopyWith<$Res>? get socialMediaLinks { + if (_self.socialMediaLinks == null) { + return null; + } + + return $SocialMediaLinksCopyWith<$Res>(_self.socialMediaLinks!, (value) { + return _then(_self.copyWith(socialMediaLinks: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [Author]. +extension AuthorPatterns on Author { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Author value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Author() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Author value) $default,){ +final _that = this; +switch (_that) { +case _Author(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Author value)? $default,){ +final _that = this; +switch (_that) { +case _Author() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String username, String id, String name, String? profilePicture, Bio? bio, String? location, SocialMediaLinks? socialMediaLinks)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Author() when $default != null: +return $default(_that.username,_that.id,_that.name,_that.profilePicture,_that.bio,_that.location,_that.socialMediaLinks);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String username, String id, String name, String? profilePicture, Bio? bio, String? location, SocialMediaLinks? socialMediaLinks) $default,) {final _that = this; +switch (_that) { +case _Author(): +return $default(_that.username,_that.id,_that.name,_that.profilePicture,_that.bio,_that.location,_that.socialMediaLinks);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String username, String id, String name, String? profilePicture, Bio? bio, String? location, SocialMediaLinks? socialMediaLinks)? $default,) {final _that = this; +switch (_that) { +case _Author() when $default != null: +return $default(_that.username,_that.id,_that.name,_that.profilePicture,_that.bio,_that.location,_that.socialMediaLinks);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Author implements Author { + const _Author({required this.username, required this.id, required this.name, this.profilePicture, this.bio, this.location, this.socialMediaLinks}); + factory _Author.fromJson(Map json) => _$AuthorFromJson(json); + +@override final String username; +@override final String id; +@override final String name; +@override final String? profilePicture; +@override final Bio? bio; +@override final String? location; +@override final SocialMediaLinks? socialMediaLinks; + +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AuthorCopyWith<_Author> get copyWith => __$AuthorCopyWithImpl<_Author>(this, _$identity); + +@override +Map toJson() { + return _$AuthorToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Author&&(identical(other.username, username) || other.username == username)&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.profilePicture, profilePicture) || other.profilePicture == profilePicture)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.location, location) || other.location == location)&&(identical(other.socialMediaLinks, socialMediaLinks) || other.socialMediaLinks == socialMediaLinks)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,username,id,name,profilePicture,bio,location,socialMediaLinks); + +@override +String toString() { + return 'Author(username: $username, id: $id, name: $name, profilePicture: $profilePicture, bio: $bio, location: $location, socialMediaLinks: $socialMediaLinks)'; +} + + +} + +/// @nodoc +abstract mixin class _$AuthorCopyWith<$Res> implements $AuthorCopyWith<$Res> { + factory _$AuthorCopyWith(_Author value, $Res Function(_Author) _then) = __$AuthorCopyWithImpl; +@override @useResult +$Res call({ + String username, String id, String name, String? profilePicture, Bio? bio, String? location, SocialMediaLinks? socialMediaLinks +}); + + +@override $BioCopyWith<$Res>? get bio;@override $SocialMediaLinksCopyWith<$Res>? get socialMediaLinks; + +} +/// @nodoc +class __$AuthorCopyWithImpl<$Res> + implements _$AuthorCopyWith<$Res> { + __$AuthorCopyWithImpl(this._self, this._then); + + final _Author _self; + final $Res Function(_Author) _then; + +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? username = null,Object? id = null,Object? name = null,Object? profilePicture = freezed,Object? bio = freezed,Object? location = freezed,Object? socialMediaLinks = freezed,}) { + return _then(_Author( +username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,profilePicture: freezed == profilePicture ? _self.profilePicture : profilePicture // ignore: cast_nullable_to_non_nullable +as String?,bio: freezed == bio ? _self.bio : bio // ignore: cast_nullable_to_non_nullable +as Bio?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable +as String?,socialMediaLinks: freezed == socialMediaLinks ? _self.socialMediaLinks : socialMediaLinks // ignore: cast_nullable_to_non_nullable +as SocialMediaLinks?, + )); +} + +/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$BioCopyWith<$Res>? get bio { + if (_self.bio == null) { + return null; + } + + return $BioCopyWith<$Res>(_self.bio!, (value) { + return _then(_self.copyWith(bio: value)); + }); +}/// Create a copy of Author +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SocialMediaLinksCopyWith<$Res>? get socialMediaLinks { + if (_self.socialMediaLinks == null) { + return null; + } + + return $SocialMediaLinksCopyWith<$Res>(_self.socialMediaLinks!, (value) { + return _then(_self.copyWith(socialMediaLinks: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/news/models/author_model.g.dart b/mobile-app/lib/news/models/author_model.g.dart new file mode 100644 index 000000000..c8ee95bb3 --- /dev/null +++ b/mobile-app/lib/news/models/author_model.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'author_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Bio _$BioFromJson(Map json) => + _Bio(text: json['text'] as String); + +Map _$BioToJson(_Bio instance) => { + 'text': instance.text, +}; + +_SocialMediaLinks _$SocialMediaLinksFromJson(Map json) => + _SocialMediaLinks( + website: json['website'] as String?, + twitter: json['twitter'] as String?, + facebook: json['facebook'] as String?, + ); + +Map _$SocialMediaLinksToJson(_SocialMediaLinks instance) => + { + 'website': instance.website, + 'twitter': instance.twitter, + 'facebook': instance.facebook, + }; + +_Author _$AuthorFromJson(Map json) => _Author( + username: json['username'] as String, + id: json['id'] as String, + name: json['name'] as String, + profilePicture: json['profilePicture'] as String?, + bio: json['bio'] == null + ? null + : Bio.fromJson(json['bio'] as Map), + location: json['location'] as String?, + socialMediaLinks: json['socialMediaLinks'] == null + ? null + : SocialMediaLinks.fromJson( + json['socialMediaLinks'] as Map, + ), +); + +Map _$AuthorToJson(_Author instance) => { + 'username': instance.username, + 'id': instance.id, + 'name': instance.name, + 'profilePicture': instance.profilePicture, + 'bio': instance.bio, + 'location': instance.location, + 'socialMediaLinks': instance.socialMediaLinks, +}; diff --git a/mobile-app/lib/news/models/bookmarked_post_model.dart b/mobile-app/lib/news/models/bookmarked_post_model.dart new file mode 100644 index 000000000..01c1319f8 --- /dev/null +++ b/mobile-app/lib/news/models/bookmarked_post_model.dart @@ -0,0 +1,25 @@ +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'bookmarked_post_model.freezed.dart'; +part 'bookmarked_post_model.g.dart'; + +@freezed +abstract class BookmarkedPost with _$BookmarkedPost { + const factory BookmarkedPost({ + required String id, + required String title, + required String authorName, + required String text, + }) = _BookmarkedPost; + + static BookmarkedPost fromPost(Post post) => BookmarkedPost( + id: post.id, + title: post.title, + authorName: post.author.name, + text: post.content.html, + ); + + factory BookmarkedPost.fromJson(Map json) => + _$BookmarkedPostFromJson(json); +} diff --git a/mobile-app/lib/news/models/bookmarked_post_model.freezed.dart b/mobile-app/lib/news/models/bookmarked_post_model.freezed.dart new file mode 100644 index 000000000..6ecd21209 --- /dev/null +++ b/mobile-app/lib/news/models/bookmarked_post_model.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'bookmarked_post_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BookmarkedPost { + + String get id; String get title; String get authorName; String get text; +/// Create a copy of BookmarkedPost +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BookmarkedPostCopyWith get copyWith => _$BookmarkedPostCopyWithImpl(this as BookmarkedPost, _$identity); + + /// Serializes this BookmarkedPost to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BookmarkedPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,authorName,text); + +@override +String toString() { + return 'BookmarkedPost(id: $id, title: $title, authorName: $authorName, text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class $BookmarkedPostCopyWith<$Res> { + factory $BookmarkedPostCopyWith(BookmarkedPost value, $Res Function(BookmarkedPost) _then) = _$BookmarkedPostCopyWithImpl; +@useResult +$Res call({ + String id, String title, String authorName, String text +}); + + + + +} +/// @nodoc +class _$BookmarkedPostCopyWithImpl<$Res> + implements $BookmarkedPostCopyWith<$Res> { + _$BookmarkedPostCopyWithImpl(this._self, this._then); + + final BookmarkedPost _self; + final $Res Function(BookmarkedPost) _then; + +/// Create a copy of BookmarkedPost +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? authorName = null,Object? text = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BookmarkedPost]. +extension BookmarkedPostPatterns on BookmarkedPost { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BookmarkedPost value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BookmarkedPost() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BookmarkedPost value) $default,){ +final _that = this; +switch (_that) { +case _BookmarkedPost(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BookmarkedPost value)? $default,){ +final _that = this; +switch (_that) { +case _BookmarkedPost() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String authorName, String text)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BookmarkedPost() when $default != null: +return $default(_that.id,_that.title,_that.authorName,_that.text);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String authorName, String text) $default,) {final _that = this; +switch (_that) { +case _BookmarkedPost(): +return $default(_that.id,_that.title,_that.authorName,_that.text);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String authorName, String text)? $default,) {final _that = this; +switch (_that) { +case _BookmarkedPost() when $default != null: +return $default(_that.id,_that.title,_that.authorName,_that.text);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BookmarkedPost implements BookmarkedPost { + const _BookmarkedPost({required this.id, required this.title, required this.authorName, required this.text}); + factory _BookmarkedPost.fromJson(Map json) => _$BookmarkedPostFromJson(json); + +@override final String id; +@override final String title; +@override final String authorName; +@override final String text; + +/// Create a copy of BookmarkedPost +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BookmarkedPostCopyWith<_BookmarkedPost> get copyWith => __$BookmarkedPostCopyWithImpl<_BookmarkedPost>(this, _$identity); + +@override +Map toJson() { + return _$BookmarkedPostToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BookmarkedPost&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.authorName, authorName) || other.authorName == authorName)&&(identical(other.text, text) || other.text == text)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,title,authorName,text); + +@override +String toString() { + return 'BookmarkedPost(id: $id, title: $title, authorName: $authorName, text: $text)'; +} + + +} + +/// @nodoc +abstract mixin class _$BookmarkedPostCopyWith<$Res> implements $BookmarkedPostCopyWith<$Res> { + factory _$BookmarkedPostCopyWith(_BookmarkedPost value, $Res Function(_BookmarkedPost) _then) = __$BookmarkedPostCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String authorName, String text +}); + + + + +} +/// @nodoc +class __$BookmarkedPostCopyWithImpl<$Res> + implements _$BookmarkedPostCopyWith<$Res> { + __$BookmarkedPostCopyWithImpl(this._self, this._then); + + final _BookmarkedPost _self; + final $Res Function(_BookmarkedPost) _then; + +/// Create a copy of BookmarkedPost +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? authorName = null,Object? text = null,}) { + return _then(_BookmarkedPost( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,authorName: null == authorName ? _self.authorName : authorName // ignore: cast_nullable_to_non_nullable +as String,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/news/models/bookmarked_post_model.g.dart b/mobile-app/lib/news/models/bookmarked_post_model.g.dart new file mode 100644 index 000000000..b82eb8d03 --- /dev/null +++ b/mobile-app/lib/news/models/bookmarked_post_model.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmarked_post_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BookmarkedPost _$BookmarkedPostFromJson(Map json) => + _BookmarkedPost( + id: json['id'] as String, + title: json['title'] as String, + authorName: json['authorName'] as String, + text: json['text'] as String, + ); + +Map _$BookmarkedPostToJson(_BookmarkedPost instance) => + { + 'id': instance.id, + 'title': instance.title, + 'authorName': instance.authorName, + 'text': instance.text, + }; diff --git a/mobile-app/lib/news/models/post_model.dart b/mobile-app/lib/news/models/post_model.dart new file mode 100644 index 000000000..28300c6a5 --- /dev/null +++ b/mobile-app/lib/news/models/post_model.dart @@ -0,0 +1,50 @@ +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'post_model.freezed.dart'; +part 'post_model.g.dart'; + +@freezed +abstract class Tag with _$Tag { + const factory Tag({ + required String id, + required String name, + required String slug, + }) = _Tag; + + factory Tag.fromJson(Map json) => _$TagFromJson(json); +} + +@freezed +abstract class CoverImage with _$CoverImage { + const factory CoverImage({String? url}) = _CoverImage; + + factory CoverImage.fromJson(Map json) => + _$CoverImageFromJson(json); +} + +@freezed +abstract class Content with _$Content { + const factory Content({required String html}) = _Content; + + factory Content.fromJson(Map json) => + _$ContentFromJson(json); +} + +@freezed +abstract class Post with _$Post { + const factory Post({ + required String id, + required String slug, + required String title, + required String url, + required Author author, + @Default([]) List tags, + CoverImage? coverImage, + required int readTimeInMinutes, + required Content content, + required String publishedAt, + }) = _Post; + + factory Post.fromJson(Map json) => _$PostFromJson(json); +} diff --git a/mobile-app/lib/news/models/post_model.freezed.dart b/mobile-app/lib/news/models/post_model.freezed.dart new file mode 100644 index 000000000..4772f117c --- /dev/null +++ b/mobile-app/lib/news/models/post_model.freezed.dart @@ -0,0 +1,1165 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'post_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Tag { + + String get id; String get name; String get slug; +/// Create a copy of Tag +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TagCopyWith get copyWith => _$TagCopyWithImpl(this as Tag, _$identity); + + /// Serializes this Tag to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Tag&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.slug, slug) || other.slug == slug)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,slug); + +@override +String toString() { + return 'Tag(id: $id, name: $name, slug: $slug)'; +} + + +} + +/// @nodoc +abstract mixin class $TagCopyWith<$Res> { + factory $TagCopyWith(Tag value, $Res Function(Tag) _then) = _$TagCopyWithImpl; +@useResult +$Res call({ + String id, String name, String slug +}); + + + + +} +/// @nodoc +class _$TagCopyWithImpl<$Res> + implements $TagCopyWith<$Res> { + _$TagCopyWithImpl(this._self, this._then); + + final Tag _self; + final $Res Function(Tag) _then; + +/// Create a copy of Tag +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? slug = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Tag]. +extension TagPatterns on Tag { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Tag value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Tag() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Tag value) $default,){ +final _that = this; +switch (_that) { +case _Tag(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Tag value)? $default,){ +final _that = this; +switch (_that) { +case _Tag() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String name, String slug)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Tag() when $default != null: +return $default(_that.id,_that.name,_that.slug);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String name, String slug) $default,) {final _that = this; +switch (_that) { +case _Tag(): +return $default(_that.id,_that.name,_that.slug);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String name, String slug)? $default,) {final _that = this; +switch (_that) { +case _Tag() when $default != null: +return $default(_that.id,_that.name,_that.slug);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Tag implements Tag { + const _Tag({required this.id, required this.name, required this.slug}); + factory _Tag.fromJson(Map json) => _$TagFromJson(json); + +@override final String id; +@override final String name; +@override final String slug; + +/// Create a copy of Tag +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TagCopyWith<_Tag> get copyWith => __$TagCopyWithImpl<_Tag>(this, _$identity); + +@override +Map toJson() { + return _$TagToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Tag&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.slug, slug) || other.slug == slug)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,name,slug); + +@override +String toString() { + return 'Tag(id: $id, name: $name, slug: $slug)'; +} + + +} + +/// @nodoc +abstract mixin class _$TagCopyWith<$Res> implements $TagCopyWith<$Res> { + factory _$TagCopyWith(_Tag value, $Res Function(_Tag) _then) = __$TagCopyWithImpl; +@override @useResult +$Res call({ + String id, String name, String slug +}); + + + + +} +/// @nodoc +class __$TagCopyWithImpl<$Res> + implements _$TagCopyWith<$Res> { + __$TagCopyWithImpl(this._self, this._then); + + final _Tag _self; + final $Res Function(_Tag) _then; + +/// Create a copy of Tag +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? slug = null,}) { + return _then(_Tag( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$CoverImage { + + String? get url; +/// Create a copy of CoverImage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CoverImageCopyWith get copyWith => _$CoverImageCopyWithImpl(this as CoverImage, _$identity); + + /// Serializes this CoverImage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CoverImage&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url); + +@override +String toString() { + return 'CoverImage(url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class $CoverImageCopyWith<$Res> { + factory $CoverImageCopyWith(CoverImage value, $Res Function(CoverImage) _then) = _$CoverImageCopyWithImpl; +@useResult +$Res call({ + String? url +}); + + + + +} +/// @nodoc +class _$CoverImageCopyWithImpl<$Res> + implements $CoverImageCopyWith<$Res> { + _$CoverImageCopyWithImpl(this._self, this._then); + + final CoverImage _self; + final $Res Function(CoverImage) _then; + +/// Create a copy of CoverImage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? url = freezed,}) { + return _then(_self.copyWith( +url: freezed == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CoverImage]. +extension CoverImagePatterns on CoverImage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CoverImage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CoverImage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CoverImage value) $default,){ +final _that = this; +switch (_that) { +case _CoverImage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CoverImage value)? $default,){ +final _that = this; +switch (_that) { +case _CoverImage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? url)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CoverImage() when $default != null: +return $default(_that.url);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? url) $default,) {final _that = this; +switch (_that) { +case _CoverImage(): +return $default(_that.url);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? url)? $default,) {final _that = this; +switch (_that) { +case _CoverImage() when $default != null: +return $default(_that.url);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _CoverImage implements CoverImage { + const _CoverImage({this.url}); + factory _CoverImage.fromJson(Map json) => _$CoverImageFromJson(json); + +@override final String? url; + +/// Create a copy of CoverImage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CoverImageCopyWith<_CoverImage> get copyWith => __$CoverImageCopyWithImpl<_CoverImage>(this, _$identity); + +@override +Map toJson() { + return _$CoverImageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CoverImage&&(identical(other.url, url) || other.url == url)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,url); + +@override +String toString() { + return 'CoverImage(url: $url)'; +} + + +} + +/// @nodoc +abstract mixin class _$CoverImageCopyWith<$Res> implements $CoverImageCopyWith<$Res> { + factory _$CoverImageCopyWith(_CoverImage value, $Res Function(_CoverImage) _then) = __$CoverImageCopyWithImpl; +@override @useResult +$Res call({ + String? url +}); + + + + +} +/// @nodoc +class __$CoverImageCopyWithImpl<$Res> + implements _$CoverImageCopyWith<$Res> { + __$CoverImageCopyWithImpl(this._self, this._then); + + final _CoverImage _self; + final $Res Function(_CoverImage) _then; + +/// Create a copy of CoverImage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? url = freezed,}) { + return _then(_CoverImage( +url: freezed == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$Content { + + String get html; +/// Create a copy of Content +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ContentCopyWith get copyWith => _$ContentCopyWithImpl(this as Content, _$identity); + + /// Serializes this Content to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Content&&(identical(other.html, html) || other.html == html)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,html); + +@override +String toString() { + return 'Content(html: $html)'; +} + + +} + +/// @nodoc +abstract mixin class $ContentCopyWith<$Res> { + factory $ContentCopyWith(Content value, $Res Function(Content) _then) = _$ContentCopyWithImpl; +@useResult +$Res call({ + String html +}); + + + + +} +/// @nodoc +class _$ContentCopyWithImpl<$Res> + implements $ContentCopyWith<$Res> { + _$ContentCopyWithImpl(this._self, this._then); + + final Content _self; + final $Res Function(Content) _then; + +/// Create a copy of Content +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? html = null,}) { + return _then(_self.copyWith( +html: null == html ? _self.html : html // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Content]. +extension ContentPatterns on Content { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Content value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Content() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Content value) $default,){ +final _that = this; +switch (_that) { +case _Content(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Content value)? $default,){ +final _that = this; +switch (_that) { +case _Content() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String html)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Content() when $default != null: +return $default(_that.html);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String html) $default,) {final _that = this; +switch (_that) { +case _Content(): +return $default(_that.html);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String html)? $default,) {final _that = this; +switch (_that) { +case _Content() when $default != null: +return $default(_that.html);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Content implements Content { + const _Content({required this.html}); + factory _Content.fromJson(Map json) => _$ContentFromJson(json); + +@override final String html; + +/// Create a copy of Content +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ContentCopyWith<_Content> get copyWith => __$ContentCopyWithImpl<_Content>(this, _$identity); + +@override +Map toJson() { + return _$ContentToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Content&&(identical(other.html, html) || other.html == html)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,html); + +@override +String toString() { + return 'Content(html: $html)'; +} + + +} + +/// @nodoc +abstract mixin class _$ContentCopyWith<$Res> implements $ContentCopyWith<$Res> { + factory _$ContentCopyWith(_Content value, $Res Function(_Content) _then) = __$ContentCopyWithImpl; +@override @useResult +$Res call({ + String html +}); + + + + +} +/// @nodoc +class __$ContentCopyWithImpl<$Res> + implements _$ContentCopyWith<$Res> { + __$ContentCopyWithImpl(this._self, this._then); + + final _Content _self; + final $Res Function(_Content) _then; + +/// Create a copy of Content +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? html = null,}) { + return _then(_Content( +html: null == html ? _self.html : html // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + + +/// @nodoc +mixin _$Post { + + String get id; String get slug; String get title; String get url; Author get author; List get tags; CoverImage? get coverImage; int get readTimeInMinutes; Content get content; String get publishedAt; +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PostCopyWith get copyWith => _$PostCopyWithImpl(this as Post, _$identity); + + /// Serializes this Post to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Post&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.coverImage, coverImage) || other.coverImage == coverImage)&&(identical(other.readTimeInMinutes, readTimeInMinutes) || other.readTimeInMinutes == readTimeInMinutes)&&(identical(other.content, content) || other.content == content)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,slug,title,url,author,const DeepCollectionEquality().hash(tags),coverImage,readTimeInMinutes,content,publishedAt); + +@override +String toString() { + return 'Post(id: $id, slug: $slug, title: $title, url: $url, author: $author, tags: $tags, coverImage: $coverImage, readTimeInMinutes: $readTimeInMinutes, content: $content, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $PostCopyWith<$Res> { + factory $PostCopyWith(Post value, $Res Function(Post) _then) = _$PostCopyWithImpl; +@useResult +$Res call({ + String id, String slug, String title, String url, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, Content content, String publishedAt +}); + + +$AuthorCopyWith<$Res> get author;$CoverImageCopyWith<$Res>? get coverImage;$ContentCopyWith<$Res> get content; + +} +/// @nodoc +class _$PostCopyWithImpl<$Res> + implements $PostCopyWith<$Res> { + _$PostCopyWithImpl(this._self, this._then); + + final Post _self; + final $Res Function(Post) _then; + +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? title = null,Object? url = null,Object? author = null,Object? tags = null,Object? coverImage = freezed,Object? readTimeInMinutes = null,Object? content = null,Object? publishedAt = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as Author,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,coverImage: freezed == coverImage ? _self.coverImage : coverImage // ignore: cast_nullable_to_non_nullable +as CoverImage?,readTimeInMinutes: null == readTimeInMinutes ? _self.readTimeInMinutes : readTimeInMinutes // ignore: cast_nullable_to_non_nullable +as int,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as Content,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthorCopyWith<$Res> get author { + + return $AuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +}/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CoverImageCopyWith<$Res>? get coverImage { + if (_self.coverImage == null) { + return null; + } + + return $CoverImageCopyWith<$Res>(_self.coverImage!, (value) { + return _then(_self.copyWith(coverImage: value)); + }); +}/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ContentCopyWith<$Res> get content { + + return $ContentCopyWith<$Res>(_self.content, (value) { + return _then(_self.copyWith(content: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [Post]. +extension PostPatterns on Post { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Post value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Post() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Post value) $default,){ +final _that = this; +switch (_that) { +case _Post(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Post value)? $default,){ +final _that = this; +switch (_that) { +case _Post() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String title, String url, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, Content content, String publishedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Post() when $default != null: +return $default(_that.id,_that.slug,_that.title,_that.url,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.content,_that.publishedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String slug, String title, String url, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, Content content, String publishedAt) $default,) {final _that = this; +switch (_that) { +case _Post(): +return $default(_that.id,_that.slug,_that.title,_that.url,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.content,_that.publishedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String title, String url, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, Content content, String publishedAt)? $default,) {final _that = this; +switch (_that) { +case _Post() when $default != null: +return $default(_that.id,_that.slug,_that.title,_that.url,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.content,_that.publishedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Post implements Post { + const _Post({required this.id, required this.slug, required this.title, required this.url, required this.author, final List tags = const [], this.coverImage, required this.readTimeInMinutes, required this.content, required this.publishedAt}): _tags = tags; + factory _Post.fromJson(Map json) => _$PostFromJson(json); + +@override final String id; +@override final String slug; +@override final String title; +@override final String url; +@override final Author author; + final List _tags; +@override@JsonKey() List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + +@override final CoverImage? coverImage; +@override final int readTimeInMinutes; +@override final Content content; +@override final String publishedAt; + +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PostCopyWith<_Post> get copyWith => __$PostCopyWithImpl<_Post>(this, _$identity); + +@override +Map toJson() { + return _$PostToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Post&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.coverImage, coverImage) || other.coverImage == coverImage)&&(identical(other.readTimeInMinutes, readTimeInMinutes) || other.readTimeInMinutes == readTimeInMinutes)&&(identical(other.content, content) || other.content == content)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,slug,title,url,author,const DeepCollectionEquality().hash(_tags),coverImage,readTimeInMinutes,content,publishedAt); + +@override +String toString() { + return 'Post(id: $id, slug: $slug, title: $title, url: $url, author: $author, tags: $tags, coverImage: $coverImage, readTimeInMinutes: $readTimeInMinutes, content: $content, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$PostCopyWith<$Res> implements $PostCopyWith<$Res> { + factory _$PostCopyWith(_Post value, $Res Function(_Post) _then) = __$PostCopyWithImpl; +@override @useResult +$Res call({ + String id, String slug, String title, String url, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, Content content, String publishedAt +}); + + +@override $AuthorCopyWith<$Res> get author;@override $CoverImageCopyWith<$Res>? get coverImage;@override $ContentCopyWith<$Res> get content; + +} +/// @nodoc +class __$PostCopyWithImpl<$Res> + implements _$PostCopyWith<$Res> { + __$PostCopyWithImpl(this._self, this._then); + + final _Post _self; + final $Res Function(_Post) _then; + +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? title = null,Object? url = null,Object? author = null,Object? tags = null,Object? coverImage = freezed,Object? readTimeInMinutes = null,Object? content = null,Object? publishedAt = null,}) { + return _then(_Post( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as Author,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,coverImage: freezed == coverImage ? _self.coverImage : coverImage // ignore: cast_nullable_to_non_nullable +as CoverImage?,readTimeInMinutes: null == readTimeInMinutes ? _self.readTimeInMinutes : readTimeInMinutes // ignore: cast_nullable_to_non_nullable +as int,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as Content,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthorCopyWith<$Res> get author { + + return $AuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +}/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CoverImageCopyWith<$Res>? get coverImage { + if (_self.coverImage == null) { + return null; + } + + return $CoverImageCopyWith<$Res>(_self.coverImage!, (value) { + return _then(_self.copyWith(coverImage: value)); + }); +}/// Create a copy of Post +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$ContentCopyWith<$Res> get content { + + return $ContentCopyWith<$Res>(_self.content, (value) { + return _then(_self.copyWith(content: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/news/models/post_model.g.dart b/mobile-app/lib/news/models/post_model.g.dart new file mode 100644 index 000000000..23679acc2 --- /dev/null +++ b/mobile-app/lib/news/models/post_model.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Tag _$TagFromJson(Map json) => _Tag( + id: json['id'] as String, + name: json['name'] as String, + slug: json['slug'] as String, +); + +Map _$TagToJson(_Tag instance) => { + 'id': instance.id, + 'name': instance.name, + 'slug': instance.slug, +}; + +_CoverImage _$CoverImageFromJson(Map json) => + _CoverImage(url: json['url'] as String?); + +Map _$CoverImageToJson(_CoverImage instance) => + {'url': instance.url}; + +_Content _$ContentFromJson(Map json) => + _Content(html: json['html'] as String); + +Map _$ContentToJson(_Content instance) => { + 'html': instance.html, +}; + +_Post _$PostFromJson(Map json) => _Post( + id: json['id'] as String, + slug: json['slug'] as String, + title: json['title'] as String, + url: json['url'] as String, + author: Author.fromJson(json['author'] as Map), + tags: + (json['tags'] as List?) + ?.map((e) => Tag.fromJson(e as Map)) + .toList() ?? + const [], + coverImage: json['coverImage'] == null + ? null + : CoverImage.fromJson(json['coverImage'] as Map), + readTimeInMinutes: (json['readTimeInMinutes'] as num).toInt(), + content: Content.fromJson(json['content'] as Map), + publishedAt: json['publishedAt'] as String, +); + +Map _$PostToJson(_Post instance) => { + 'id': instance.id, + 'slug': instance.slug, + 'title': instance.title, + 'url': instance.url, + 'author': instance.author, + 'tags': instance.tags, + 'coverImage': instance.coverImage, + 'readTimeInMinutes': instance.readTimeInMinutes, + 'content': instance.content, + 'publishedAt': instance.publishedAt, +}; diff --git a/mobile-app/lib/news/models/post_summary_model.dart b/mobile-app/lib/news/models/post_summary_model.dart new file mode 100644 index 000000000..25ac12e92 --- /dev/null +++ b/mobile-app/lib/news/models/post_summary_model.dart @@ -0,0 +1,23 @@ +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'post_summary_model.freezed.dart'; +part 'post_summary_model.g.dart'; + +@freezed +abstract class PostSummary with _$PostSummary { + const factory PostSummary({ + required String id, + required String slug, + required String title, + required Author author, + @Default([]) List tags, + CoverImage? coverImage, + required int readTimeInMinutes, + required String publishedAt, + }) = _PostSummary; + + factory PostSummary.fromJson(Map json) => + _$PostSummaryFromJson(json); +} diff --git a/mobile-app/lib/news/models/post_summary_model.freezed.dart b/mobile-app/lib/news/models/post_summary_model.freezed.dart new file mode 100644 index 000000000..35656c6c5 --- /dev/null +++ b/mobile-app/lib/news/models/post_summary_model.freezed.dart @@ -0,0 +1,346 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'post_summary_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PostSummary { + + String get id; String get slug; String get title; Author get author; List get tags; CoverImage? get coverImage; int get readTimeInMinutes; String get publishedAt; +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PostSummaryCopyWith get copyWith => _$PostSummaryCopyWithImpl(this as PostSummary, _$identity); + + /// Serializes this PostSummary to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PostSummary&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.title, title) || other.title == title)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.coverImage, coverImage) || other.coverImage == coverImage)&&(identical(other.readTimeInMinutes, readTimeInMinutes) || other.readTimeInMinutes == readTimeInMinutes)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,slug,title,author,const DeepCollectionEquality().hash(tags),coverImage,readTimeInMinutes,publishedAt); + +@override +String toString() { + return 'PostSummary(id: $id, slug: $slug, title: $title, author: $author, tags: $tags, coverImage: $coverImage, readTimeInMinutes: $readTimeInMinutes, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $PostSummaryCopyWith<$Res> { + factory $PostSummaryCopyWith(PostSummary value, $Res Function(PostSummary) _then) = _$PostSummaryCopyWithImpl; +@useResult +$Res call({ + String id, String slug, String title, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, String publishedAt +}); + + +$AuthorCopyWith<$Res> get author;$CoverImageCopyWith<$Res>? get coverImage; + +} +/// @nodoc +class _$PostSummaryCopyWithImpl<$Res> + implements $PostSummaryCopyWith<$Res> { + _$PostSummaryCopyWithImpl(this._self, this._then); + + final PostSummary _self; + final $Res Function(PostSummary) _then; + +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? title = null,Object? author = null,Object? tags = null,Object? coverImage = freezed,Object? readTimeInMinutes = null,Object? publishedAt = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as Author,tags: null == tags ? _self.tags : tags // ignore: cast_nullable_to_non_nullable +as List,coverImage: freezed == coverImage ? _self.coverImage : coverImage // ignore: cast_nullable_to_non_nullable +as CoverImage?,readTimeInMinutes: null == readTimeInMinutes ? _self.readTimeInMinutes : readTimeInMinutes // ignore: cast_nullable_to_non_nullable +as int,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthorCopyWith<$Res> get author { + + return $AuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +}/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CoverImageCopyWith<$Res>? get coverImage { + if (_self.coverImage == null) { + return null; + } + + return $CoverImageCopyWith<$Res>(_self.coverImage!, (value) { + return _then(_self.copyWith(coverImage: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [PostSummary]. +extension PostSummaryPatterns on PostSummary { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PostSummary value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PostSummary() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PostSummary value) $default,){ +final _that = this; +switch (_that) { +case _PostSummary(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PostSummary value)? $default,){ +final _that = this; +switch (_that) { +case _PostSummary() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String title, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, String publishedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PostSummary() when $default != null: +return $default(_that.id,_that.slug,_that.title,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.publishedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String slug, String title, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, String publishedAt) $default,) {final _that = this; +switch (_that) { +case _PostSummary(): +return $default(_that.id,_that.slug,_that.title,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.publishedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String title, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, String publishedAt)? $default,) {final _that = this; +switch (_that) { +case _PostSummary() when $default != null: +return $default(_that.id,_that.slug,_that.title,_that.author,_that.tags,_that.coverImage,_that.readTimeInMinutes,_that.publishedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _PostSummary implements PostSummary { + const _PostSummary({required this.id, required this.slug, required this.title, required this.author, final List tags = const [], this.coverImage, required this.readTimeInMinutes, required this.publishedAt}): _tags = tags; + factory _PostSummary.fromJson(Map json) => _$PostSummaryFromJson(json); + +@override final String id; +@override final String slug; +@override final String title; +@override final Author author; + final List _tags; +@override@JsonKey() List get tags { + if (_tags is EqualUnmodifiableListView) return _tags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tags); +} + +@override final CoverImage? coverImage; +@override final int readTimeInMinutes; +@override final String publishedAt; + +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PostSummaryCopyWith<_PostSummary> get copyWith => __$PostSummaryCopyWithImpl<_PostSummary>(this, _$identity); + +@override +Map toJson() { + return _$PostSummaryToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostSummary&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.title, title) || other.title == title)&&(identical(other.author, author) || other.author == author)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.coverImage, coverImage) || other.coverImage == coverImage)&&(identical(other.readTimeInMinutes, readTimeInMinutes) || other.readTimeInMinutes == readTimeInMinutes)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,slug,title,author,const DeepCollectionEquality().hash(_tags),coverImage,readTimeInMinutes,publishedAt); + +@override +String toString() { + return 'PostSummary(id: $id, slug: $slug, title: $title, author: $author, tags: $tags, coverImage: $coverImage, readTimeInMinutes: $readTimeInMinutes, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$PostSummaryCopyWith<$Res> implements $PostSummaryCopyWith<$Res> { + factory _$PostSummaryCopyWith(_PostSummary value, $Res Function(_PostSummary) _then) = __$PostSummaryCopyWithImpl; +@override @useResult +$Res call({ + String id, String slug, String title, Author author, List tags, CoverImage? coverImage, int readTimeInMinutes, String publishedAt +}); + + +@override $AuthorCopyWith<$Res> get author;@override $CoverImageCopyWith<$Res>? get coverImage; + +} +/// @nodoc +class __$PostSummaryCopyWithImpl<$Res> + implements _$PostSummaryCopyWith<$Res> { + __$PostSummaryCopyWithImpl(this._self, this._then); + + final _PostSummary _self; + final $Res Function(_PostSummary) _then; + +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? title = null,Object? author = null,Object? tags = null,Object? coverImage = freezed,Object? readTimeInMinutes = null,Object? publishedAt = null,}) { + return _then(_PostSummary( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as Author,tags: null == tags ? _self._tags : tags // ignore: cast_nullable_to_non_nullable +as List,coverImage: freezed == coverImage ? _self.coverImage : coverImage // ignore: cast_nullable_to_non_nullable +as CoverImage?,readTimeInMinutes: null == readTimeInMinutes ? _self.readTimeInMinutes : readTimeInMinutes // ignore: cast_nullable_to_non_nullable +as int,publishedAt: null == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthorCopyWith<$Res> get author { + + return $AuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +}/// Create a copy of PostSummary +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CoverImageCopyWith<$Res>? get coverImage { + if (_self.coverImage == null) { + return null; + } + + return $CoverImageCopyWith<$Res>(_self.coverImage!, (value) { + return _then(_self.copyWith(coverImage: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/news/models/post_summary_model.g.dart b/mobile-app/lib/news/models/post_summary_model.g.dart new file mode 100644 index 000000000..9b64735fb --- /dev/null +++ b/mobile-app/lib/news/models/post_summary_model.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_summary_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_PostSummary _$PostSummaryFromJson(Map json) => _PostSummary( + id: json['id'] as String, + slug: json['slug'] as String, + title: json['title'] as String, + author: Author.fromJson(json['author'] as Map), + tags: + (json['tags'] as List?) + ?.map((e) => Tag.fromJson(e as Map)) + .toList() ?? + const [], + coverImage: json['coverImage'] == null + ? null + : CoverImage.fromJson(json['coverImage'] as Map), + readTimeInMinutes: (json['readTimeInMinutes'] as num).toInt(), + publishedAt: json['publishedAt'] as String, +); + +Map _$PostSummaryToJson(_PostSummary instance) => + { + 'id': instance.id, + 'slug': instance.slug, + 'title': instance.title, + 'author': instance.author, + 'tags': instance.tags, + 'coverImage': instance.coverImage, + 'readTimeInMinutes': instance.readTimeInMinutes, + 'publishedAt': instance.publishedAt, + }; diff --git a/mobile-app/lib/news/models/search_post_model.dart b/mobile-app/lib/news/models/search_post_model.dart new file mode 100644 index 000000000..3b987ab8c --- /dev/null +++ b/mobile-app/lib/news/models/search_post_model.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_post_model.freezed.dart'; +part 'search_post_model.g.dart'; + +@freezed +abstract class SearchAuthor with _$SearchAuthor { + const factory SearchAuthor({required String name, String? profileImage}) = + _SearchAuthor; + + factory SearchAuthor.fromJson(Map json) => + _$SearchAuthorFromJson(json); +} + +// Algolia search result post model +@freezed +abstract class SearchPost with _$SearchPost { + const SearchPost._(); + + const factory SearchPost({ + required String objectID, + required String title, + required String url, + required SearchAuthor author, + String? featureImage, + String? publishedAt, + }) = _SearchPost; + + factory SearchPost.fromJson(Map json) => + _$SearchPostFromJson(json); + + // NOTE: Algolia search results do not include the slug, so we derive it from the URL. + String get slug { + final segments = Uri.parse(url).pathSegments.where((s) => s.isNotEmpty); + return segments.isEmpty ? '' : segments.last; + } +} diff --git a/mobile-app/lib/news/models/search_post_model.freezed.dart b/mobile-app/lib/news/models/search_post_model.freezed.dart new file mode 100644 index 000000000..46a06f108 --- /dev/null +++ b/mobile-app/lib/news/models/search_post_model.freezed.dart @@ -0,0 +1,576 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'search_post_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SearchAuthor { + + String get name; String? get profileImage; +/// Create a copy of SearchAuthor +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SearchAuthorCopyWith get copyWith => _$SearchAuthorCopyWithImpl(this as SearchAuthor, _$identity); + + /// Serializes this SearchAuthor to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SearchAuthor&&(identical(other.name, name) || other.name == name)&&(identical(other.profileImage, profileImage) || other.profileImage == profileImage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,profileImage); + +@override +String toString() { + return 'SearchAuthor(name: $name, profileImage: $profileImage)'; +} + + +} + +/// @nodoc +abstract mixin class $SearchAuthorCopyWith<$Res> { + factory $SearchAuthorCopyWith(SearchAuthor value, $Res Function(SearchAuthor) _then) = _$SearchAuthorCopyWithImpl; +@useResult +$Res call({ + String name, String? profileImage +}); + + + + +} +/// @nodoc +class _$SearchAuthorCopyWithImpl<$Res> + implements $SearchAuthorCopyWith<$Res> { + _$SearchAuthorCopyWithImpl(this._self, this._then); + + final SearchAuthor _self; + final $Res Function(SearchAuthor) _then; + +/// Create a copy of SearchAuthor +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? profileImage = freezed,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,profileImage: freezed == profileImage ? _self.profileImage : profileImage // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SearchAuthor]. +extension SearchAuthorPatterns on SearchAuthor { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SearchAuthor value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SearchAuthor() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SearchAuthor value) $default,){ +final _that = this; +switch (_that) { +case _SearchAuthor(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SearchAuthor value)? $default,){ +final _that = this; +switch (_that) { +case _SearchAuthor() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, String? profileImage)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SearchAuthor() when $default != null: +return $default(_that.name,_that.profileImage);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, String? profileImage) $default,) {final _that = this; +switch (_that) { +case _SearchAuthor(): +return $default(_that.name,_that.profileImage);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, String? profileImage)? $default,) {final _that = this; +switch (_that) { +case _SearchAuthor() when $default != null: +return $default(_that.name,_that.profileImage);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SearchAuthor implements SearchAuthor { + const _SearchAuthor({required this.name, this.profileImage}); + factory _SearchAuthor.fromJson(Map json) => _$SearchAuthorFromJson(json); + +@override final String name; +@override final String? profileImage; + +/// Create a copy of SearchAuthor +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SearchAuthorCopyWith<_SearchAuthor> get copyWith => __$SearchAuthorCopyWithImpl<_SearchAuthor>(this, _$identity); + +@override +Map toJson() { + return _$SearchAuthorToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SearchAuthor&&(identical(other.name, name) || other.name == name)&&(identical(other.profileImage, profileImage) || other.profileImage == profileImage)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,profileImage); + +@override +String toString() { + return 'SearchAuthor(name: $name, profileImage: $profileImage)'; +} + + +} + +/// @nodoc +abstract mixin class _$SearchAuthorCopyWith<$Res> implements $SearchAuthorCopyWith<$Res> { + factory _$SearchAuthorCopyWith(_SearchAuthor value, $Res Function(_SearchAuthor) _then) = __$SearchAuthorCopyWithImpl; +@override @useResult +$Res call({ + String name, String? profileImage +}); + + + + +} +/// @nodoc +class __$SearchAuthorCopyWithImpl<$Res> + implements _$SearchAuthorCopyWith<$Res> { + __$SearchAuthorCopyWithImpl(this._self, this._then); + + final _SearchAuthor _self; + final $Res Function(_SearchAuthor) _then; + +/// Create a copy of SearchAuthor +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? profileImage = freezed,}) { + return _then(_SearchAuthor( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,profileImage: freezed == profileImage ? _self.profileImage : profileImage // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + + +/// @nodoc +mixin _$SearchPost { + + String get objectID; String get title; String get url; SearchAuthor get author; String? get featureImage; String? get publishedAt; +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SearchPostCopyWith get copyWith => _$SearchPostCopyWithImpl(this as SearchPost, _$identity); + + /// Serializes this SearchPost to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SearchPost&&(identical(other.objectID, objectID) || other.objectID == objectID)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&(identical(other.featureImage, featureImage) || other.featureImage == featureImage)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,objectID,title,url,author,featureImage,publishedAt); + +@override +String toString() { + return 'SearchPost(objectID: $objectID, title: $title, url: $url, author: $author, featureImage: $featureImage, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SearchPostCopyWith<$Res> { + factory $SearchPostCopyWith(SearchPost value, $Res Function(SearchPost) _then) = _$SearchPostCopyWithImpl; +@useResult +$Res call({ + String objectID, String title, String url, SearchAuthor author, String? featureImage, String? publishedAt +}); + + +$SearchAuthorCopyWith<$Res> get author; + +} +/// @nodoc +class _$SearchPostCopyWithImpl<$Res> + implements $SearchPostCopyWith<$Res> { + _$SearchPostCopyWithImpl(this._self, this._then); + + final SearchPost _self; + final $Res Function(SearchPost) _then; + +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? objectID = null,Object? title = null,Object? url = null,Object? author = null,Object? featureImage = freezed,Object? publishedAt = freezed,}) { + return _then(_self.copyWith( +objectID: null == objectID ? _self.objectID : objectID // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as SearchAuthor,featureImage: freezed == featureImage ? _self.featureImage : featureImage // ignore: cast_nullable_to_non_nullable +as String?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SearchAuthorCopyWith<$Res> get author { + + return $SearchAuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [SearchPost]. +extension SearchPostPatterns on SearchPost { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SearchPost value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SearchPost() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SearchPost value) $default,){ +final _that = this; +switch (_that) { +case _SearchPost(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SearchPost value)? $default,){ +final _that = this; +switch (_that) { +case _SearchPost() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String objectID, String title, String url, SearchAuthor author, String? featureImage, String? publishedAt)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SearchPost() when $default != null: +return $default(_that.objectID,_that.title,_that.url,_that.author,_that.featureImage,_that.publishedAt);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String objectID, String title, String url, SearchAuthor author, String? featureImage, String? publishedAt) $default,) {final _that = this; +switch (_that) { +case _SearchPost(): +return $default(_that.objectID,_that.title,_that.url,_that.author,_that.featureImage,_that.publishedAt);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String objectID, String title, String url, SearchAuthor author, String? featureImage, String? publishedAt)? $default,) {final _that = this; +switch (_that) { +case _SearchPost() when $default != null: +return $default(_that.objectID,_that.title,_that.url,_that.author,_that.featureImage,_that.publishedAt);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SearchPost extends SearchPost { + const _SearchPost({required this.objectID, required this.title, required this.url, required this.author, this.featureImage, this.publishedAt}): super._(); + factory _SearchPost.fromJson(Map json) => _$SearchPostFromJson(json); + +@override final String objectID; +@override final String title; +@override final String url; +@override final SearchAuthor author; +@override final String? featureImage; +@override final String? publishedAt; + +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SearchPostCopyWith<_SearchPost> get copyWith => __$SearchPostCopyWithImpl<_SearchPost>(this, _$identity); + +@override +Map toJson() { + return _$SearchPostToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SearchPost&&(identical(other.objectID, objectID) || other.objectID == objectID)&&(identical(other.title, title) || other.title == title)&&(identical(other.url, url) || other.url == url)&&(identical(other.author, author) || other.author == author)&&(identical(other.featureImage, featureImage) || other.featureImage == featureImage)&&(identical(other.publishedAt, publishedAt) || other.publishedAt == publishedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,objectID,title,url,author,featureImage,publishedAt); + +@override +String toString() { + return 'SearchPost(objectID: $objectID, title: $title, url: $url, author: $author, featureImage: $featureImage, publishedAt: $publishedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SearchPostCopyWith<$Res> implements $SearchPostCopyWith<$Res> { + factory _$SearchPostCopyWith(_SearchPost value, $Res Function(_SearchPost) _then) = __$SearchPostCopyWithImpl; +@override @useResult +$Res call({ + String objectID, String title, String url, SearchAuthor author, String? featureImage, String? publishedAt +}); + + +@override $SearchAuthorCopyWith<$Res> get author; + +} +/// @nodoc +class __$SearchPostCopyWithImpl<$Res> + implements _$SearchPostCopyWith<$Res> { + __$SearchPostCopyWithImpl(this._self, this._then); + + final _SearchPost _self; + final $Res Function(_SearchPost) _then; + +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? objectID = null,Object? title = null,Object? url = null,Object? author = null,Object? featureImage = freezed,Object? publishedAt = freezed,}) { + return _then(_SearchPost( +objectID: null == objectID ? _self.objectID : objectID // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable +as String,author: null == author ? _self.author : author // ignore: cast_nullable_to_non_nullable +as SearchAuthor,featureImage: freezed == featureImage ? _self.featureImage : featureImage // ignore: cast_nullable_to_non_nullable +as String?,publishedAt: freezed == publishedAt ? _self.publishedAt : publishedAt // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of SearchPost +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SearchAuthorCopyWith<$Res> get author { + + return $SearchAuthorCopyWith<$Res>(_self.author, (value) { + return _then(_self.copyWith(author: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/news/models/search_post_model.g.dart b/mobile-app/lib/news/models/search_post_model.g.dart new file mode 100644 index 000000000..9b03d6f51 --- /dev/null +++ b/mobile-app/lib/news/models/search_post_model.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_post_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SearchAuthor _$SearchAuthorFromJson(Map json) => + _SearchAuthor( + name: json['name'] as String, + profileImage: json['profileImage'] as String?, + ); + +Map _$SearchAuthorToJson(_SearchAuthor instance) => + { + 'name': instance.name, + 'profileImage': instance.profileImage, + }; + +_SearchPost _$SearchPostFromJson(Map json) => _SearchPost( + objectID: json['objectID'] as String, + title: json['title'] as String, + url: json['url'] as String, + author: SearchAuthor.fromJson(json['author'] as Map), + featureImage: json['featureImage'] as String?, + publishedAt: json['publishedAt'] as String?, +); + +Map _$SearchPostToJson(_SearchPost instance) => + { + 'objectID': instance.objectID, + 'title': instance.title, + 'url': instance.url, + 'author': instance.author, + 'featureImage': instance.featureImage, + 'publishedAt': instance.publishedAt, + }; diff --git a/mobile-app/lib/news/repositories/api_repository.dart b/mobile-app/lib/news/repositories/api_repository.dart new file mode 100644 index 000000000..c1983848c --- /dev/null +++ b/mobile-app/lib/news/repositories/api_repository.dart @@ -0,0 +1,242 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:graphql/client.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'api_repository.g.dart'; + +typedef RawPaginatedResponse = ({ + String endCursor, + bool hasNextPage, + List> items, +}); + +const _apiUrl = 'https://gql-beta.hashnode.com'; + +const postsPerPage = 20; + +const _postListFields = r''' + fragment PostListFields on Post { + id + slug + title + author { + id + username + name + bio { + text + } + profilePicture + socialMediaLinks { + website + twitter + facebook + } + location + } + tags { + id + name + slug + } + coverImage { + url + } + readTimeInMinutes + publishedAt + } + '''; + +const _postDetailFields = + _postListFields + + r''' + fragment PostDetailFields on Post { + ...PostListFields + url + content { + html + } + } + '''; + +const _getAllPostsQuery = + _postListFields + + r''' + query GetAllPosts($publicationId: ObjectId!, $first: Int!, $after: String) { + publication(id: $publicationId) { + id + posts(first: $first, after: $after) { + edges { + node { + ...PostListFields + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + '''; + +const _getAuthorQuery = r''' + query GetAuthor($authorSlug: String!) { + user(username: $authorSlug) { + id + username + name + bio { + text + } + profilePicture + socialMediaLinks { + website + twitter + facebook + } + location + } + } + '''; + +const _getPostsByAuthorQuery = + _postListFields + + r''' + query GetPostsByAuthorQuery($first: Int!, $after: String, $filter: SearchPostsOfPublicationFilter!) { + searchPostsOfPublication(first: $first, after: $after, filter: $filter) { + edges { + node { + ...PostListFields + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + '''; + +const _getPostsByTagQuery = + _postListFields + + r''' + query GetPostsByTagQuery($publicationId: ObjectId!, $first: Int!, $after: String, $filter: PublicationPostConnectionFilter!) { + publication(id: $publicationId) { + id + posts(first: $first, after: $after, filter: $filter) { + edges { + node { + ...PostListFields + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + '''; + +const _getPostBySlugQuery = + _postDetailFields + + r''' + query GetPostBySlug($publicationId: ObjectId!, $slug: String!) { + publication(id: $publicationId) { + id + post(slug: $slug) { + ...PostDetailFields + } + } + } + '''; + +@riverpod +NewsApiRepository newsApiRepository(Ref ref) => NewsApiRepository(); + +class NewsApiRepository { + final GraphQLClient _client = GraphQLClient( + link: HttpLink(_apiUrl), + cache: GraphQLCache(), + ); + final String _publicationId = dotenv.get('HASHNODE_PUBLICATION_ID'); + + Future> _query( + String document, + Map variables, + ) async { + final result = await _client.query( + QueryOptions(document: gql(document), variables: variables), + ); + + if (result.hasException) { + throw Exception(result.exception.toString()); + } + + return result.data!; + } + + RawPaginatedResponse _toPage(Map connection) => ( + items: (connection['edges'] as List).cast>(), + endCursor: connection['pageInfo']['endCursor'] as String, + hasNextPage: connection['pageInfo']['hasNextPage'] as bool, + ); + + Future getAllPosts({String afterCursor = ''}) async { + final data = await _query(_getAllPostsQuery, { + 'publicationId': _publicationId, + 'first': postsPerPage, + 'after': afterCursor, + }); + + return _toPage(data['publication']['posts'] as Map); + } + + Future> getPostBySlug(String slug) async { + final data = await _query(_getPostBySlugQuery, { + 'publicationId': _publicationId, + 'slug': slug, + }); + + return data['publication']['post'] as Map; + } + + Future> getAuthor(String authorSlug) async { + final data = await _query(_getAuthorQuery, {'authorSlug': authorSlug}); + + return data['user'] as Map; + } + + Future getPostsByAuthor( + String authorId, { + String afterCursor = '', + }) async { + final data = await _query(_getPostsByAuthorQuery, { + 'first': postsPerPage, + 'after': afterCursor, + 'filter': { + 'publicationId': _publicationId, + 'authorIds': [authorId], + }, + }); + + return _toPage(data['searchPostsOfPublication'] as Map); + } + + Future getPostsByTag( + String tagSlug, { + String afterCursor = '', + }) async { + final data = await _query(_getPostsByTagQuery, { + 'publicationId': _publicationId, + 'first': postsPerPage, + 'after': afterCursor, + 'filter': { + 'tagSlugs': [tagSlug], + }, + }); + + return _toPage(data['publication']['posts'] as Map); + } +} diff --git a/mobile-app/lib/news/repositories/api_repository.g.dart b/mobile-app/lib/news/repositories/api_repository.g.dart new file mode 100644 index 000000000..0d7a54504 --- /dev/null +++ b/mobile-app/lib/news/repositories/api_repository.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsApiRepository) +final newsApiRepositoryProvider = NewsApiRepositoryProvider._(); + +final class NewsApiRepositoryProvider + extends + $FunctionalProvider< + NewsApiRepository, + NewsApiRepository, + NewsApiRepository + > + with $Provider { + NewsApiRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsApiRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsApiRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NewsApiRepository create(Ref ref) { + return newsApiRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsApiRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsApiRepositoryHash() => r'c2ab6b0169b271263c24f15aee897d85018bbeda'; diff --git a/mobile-app/lib/news/repositories/bookmark_repository.dart b/mobile-app/lib/news/repositories/bookmark_repository.dart new file mode 100644 index 000000000..c86594399 --- /dev/null +++ b/mobile-app/lib/news/repositories/bookmark_repository.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bookmark_repository.g.dart'; + +typedef BookmarkEntry = Map; + +const _fileName = 'bookmarked-articles.json'; +const _storeVersion = 1; + +@riverpod +NewsBookmarkRepository newsBookmarkRepository(Ref ref) { + return NewsBookmarkRepository(); +} + +class NewsBookmarkRepository { + File? _file; + + Future _resolveFile() async { + final baseDir = await getApplicationDocumentsDirectory(); + return _file ??= File(path.join(baseDir.path, 'storage', _fileName)); + } + + Future> readBookmarks() => _serialized(_read); + + Future updateBookmarks( + List Function(List current) updater, + ) { + return _serialized(() async => _write(updater(await _read()))); + } + + Future> _read() async { + final file = await _resolveFile(); + if (!await file.exists()) return []; + + try { + final decoded = jsonDecode(await file.readAsString()); + if (decoded is Map && decoded['bookmarks'] is List) { + return [ + for (final entry in decoded['bookmarks'] as List) + if (entry is Map) BookmarkEntry.from(entry), + ]; + } + log('Bookmarks: unexpected shape in ${file.path}'); + } catch (e) { + log('Bookmarks: cannot read ${file.path} ($e)'); + } + + return []; + } + + Future _write(List bookmarks) async { + final file = await _resolveFile(); + await file.parent.create(recursive: true); + + final temp = File('${file.path}.tmp'); + await temp.writeAsString( + jsonEncode({'version': _storeVersion, 'bookmarks': bookmarks}), + flush: true, + ); + await temp.rename(file.path); + } + + Future _tail = Future.value(); + + Future _serialized(Future Function() action) { + final next = _tail.then((_) => action()); + _tail = next.then((_) {}, onError: (_) {}); + return next; + } +} diff --git a/mobile-app/lib/news/repositories/bookmark_repository.g.dart b/mobile-app/lib/news/repositories/bookmark_repository.g.dart new file mode 100644 index 000000000..896216548 --- /dev/null +++ b/mobile-app/lib/news/repositories/bookmark_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmark_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsBookmarkRepository) +final newsBookmarkRepositoryProvider = NewsBookmarkRepositoryProvider._(); + +final class NewsBookmarkRepositoryProvider + extends + $FunctionalProvider< + NewsBookmarkRepository, + NewsBookmarkRepository, + NewsBookmarkRepository + > + with $Provider { + NewsBookmarkRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsBookmarkRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsBookmarkRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NewsBookmarkRepository create(Ref ref) { + return newsBookmarkRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsBookmarkRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsBookmarkRepositoryHash() => + r'26f6e95a45517cb80b2397d773582152a38e58f2'; diff --git a/mobile-app/lib/news/repositories/search_repository.dart b/mobile-app/lib/news/repositories/search_repository.dart new file mode 100644 index 000000000..3a19152dc --- /dev/null +++ b/mobile-app/lib/news/repositories/search_repository.dart @@ -0,0 +1,33 @@ +import 'dart:developer'; + +import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_repository.g.dart'; + +const _indexName = 'news'; + +@riverpod +NewsSearchRepository newsSearchRepository(Ref ref) { + final repository = NewsSearchRepository(); + ref.onDispose(repository.dispose); + return repository; +} + +class NewsSearchRepository { + final HitsSearcher _searcher = HitsSearcher( + applicationID: dotenv.get('ALGOLIAAPPID'), + apiKey: dotenv.get('ALGOLIAKEY'), + indexName: _indexName, + ); + + Stream get responses => _searcher.responses; + + void query(String term) => _searcher.query(term); + + void dispose() { + log('Disposing NewsSearchRepository'); + _searcher.dispose(); + } +} diff --git a/mobile-app/lib/news/repositories/search_repository.g.dart b/mobile-app/lib/news/repositories/search_repository.g.dart new file mode 100644 index 000000000..d25e28234 --- /dev/null +++ b/mobile-app/lib/news/repositories/search_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsSearchRepository) +final newsSearchRepositoryProvider = NewsSearchRepositoryProvider._(); + +final class NewsSearchRepositoryProvider + extends + $FunctionalProvider< + NewsSearchRepository, + NewsSearchRepository, + NewsSearchRepository + > + with $Provider { + NewsSearchRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsSearchRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsSearchRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NewsSearchRepository create(Ref ref) { + return newsSearchRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsSearchRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsSearchRepositoryHash() => + r'e98a8d3f4250ccf351e79082c1d019c6bf48bf27'; diff --git a/mobile-app/lib/news/services/api_service.dart b/mobile-app/lib/news/services/api_service.dart new file mode 100644 index 000000000..b37da0158 --- /dev/null +++ b/mobile-app/lib/news/services/api_service.dart @@ -0,0 +1,59 @@ +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freecodecamp/news/models/post_summary_model.dart'; +import 'package:freecodecamp/news/repositories/api_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'api_service.g.dart'; + +typedef PostsPage = ({ + String endCursor, + bool hasNextPage, + List posts, +}); + +@riverpod +NewsApiService newsApiService(Ref ref) { + final repo = ref.watch(newsApiRepositoryProvider); + return NewsApiService(repo); +} + +class NewsApiService { + final NewsApiRepository _repo; + + NewsApiService(this._repo); + + // The posts of a connection arrive wrapped in edge nodes. + PostsPage _toPostsPage(RawPaginatedResponse raw) => ( + posts: raw.items + .map( + (edge) => PostSummary.fromJson(edge['node'] as Map), + ) + .toList(), + endCursor: raw.endCursor, + hasNextPage: raw.hasNextPage, + ); + + Future getAllPosts({String afterCursor = ''}) async => + _toPostsPage(await _repo.getAllPosts(afterCursor: afterCursor)); + + Future getPostBySlug(String slug) async => + Post.fromJson(await _repo.getPostBySlug(slug)); + + Future getAuthor(String authorSlug) async => + Author.fromJson(await _repo.getAuthor(authorSlug)); + + Future getPostsByAuthor( + String authorId, { + String afterCursor = '', + }) async => _toPostsPage( + await _repo.getPostsByAuthor(authorId, afterCursor: afterCursor), + ); + + Future getPostsByTag( + String tagSlug, { + String afterCursor = '', + }) async => _toPostsPage( + await _repo.getPostsByTag(tagSlug, afterCursor: afterCursor), + ); +} diff --git a/mobile-app/lib/news/services/api_service.g.dart b/mobile-app/lib/news/services/api_service.g.dart new file mode 100644 index 000000000..e2f513d06 --- /dev/null +++ b/mobile-app/lib/news/services/api_service.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsApiService) +final newsApiServiceProvider = NewsApiServiceProvider._(); + +final class NewsApiServiceProvider + extends $FunctionalProvider + with $Provider { + NewsApiServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsApiServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsApiServiceHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + NewsApiService create(Ref ref) { + return newsApiService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsApiService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsApiServiceHash() => r'551c870921b060fc2dd8d4f2ca644c0ad229615f'; diff --git a/mobile-app/lib/news/services/bookmark_service.dart b/mobile-app/lib/news/services/bookmark_service.dart new file mode 100644 index 000000000..47227386d --- /dev/null +++ b/mobile-app/lib/news/services/bookmark_service.dart @@ -0,0 +1,67 @@ +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/news/repositories/bookmark_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'bookmark_service.g.dart'; + +// v7's on-disk key names. +const _idKey = 'articleId'; +const _titleKey = 'articleTitle'; +const _authorKey = 'authorName'; +const _textKey = 'articleText'; + +@riverpod +NewsBookmarkService newsBookmarkService(Ref ref) { + final repo = ref.watch(newsBookmarkRepositoryProvider); + return NewsBookmarkService(repo); +} + +class NewsBookmarkService { + final NewsBookmarkRepository _repo; + + NewsBookmarkService(this._repo); + + Future> getBookmarks() async { + final raw = await _repo.readBookmarks(); + return raw.reversed.map(_fromEntry).toList(); + } + + Future isBookmarked(String id) async { + final raw = await _repo.readBookmarks(); + return raw.any((entry) => entry[_idKey] == id); + } + + Future addBookmark(BookmarkedPost post) { + return _repo.updateBookmarks( + (current) => List.from(current) + ..removeWhere((entry) => entry[_idKey] == post.id) + ..add(_toEntry(post)), + ); + } + + Future removeBookmark(String id) { + return _repo.updateBookmarks( + (current) => + List.from(current) + ..removeWhere((entry) => entry[_idKey] == id), + ); + } + + BookmarkEntry _toEntry(BookmarkedPost post) => { + _idKey: post.id, + _titleKey: post.title, + _authorKey: post.authorName, + _textKey: post.text, + }; + + BookmarkedPost _fromEntry(BookmarkEntry entry) => switch (entry) { + { + _idKey: final String id, + _titleKey: final String title, + _authorKey: final String authorName, + _textKey: final String text, + } => + BookmarkedPost(id: id, title: title, authorName: authorName, text: text), + _ => throw FormatException('Malformed bookmark entry: $entry'), + }; +} diff --git a/mobile-app/lib/news/services/bookmark_service.g.dart b/mobile-app/lib/news/services/bookmark_service.g.dart new file mode 100644 index 000000000..8068e081b --- /dev/null +++ b/mobile-app/lib/news/services/bookmark_service.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'bookmark_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsBookmarkService) +final newsBookmarkServiceProvider = NewsBookmarkServiceProvider._(); + +final class NewsBookmarkServiceProvider + extends + $FunctionalProvider< + NewsBookmarkService, + NewsBookmarkService, + NewsBookmarkService + > + with $Provider { + NewsBookmarkServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsBookmarkServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsBookmarkServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NewsBookmarkService create(Ref ref) { + return newsBookmarkService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsBookmarkService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsBookmarkServiceHash() => + r'dddde915bacf3c8e3780ae1b81dd9f6675b63b06'; diff --git a/mobile-app/lib/news/services/search_service.dart b/mobile-app/lib/news/services/search_service.dart new file mode 100644 index 000000000..d3ae6a5b7 --- /dev/null +++ b/mobile-app/lib/news/services/search_service.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; +import 'package:freecodecamp/news/constants.dart'; +import 'package:freecodecamp/news/models/search_post_model.dart'; +import 'package:freecodecamp/news/repositories/search_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_service.g.dart'; + +@riverpod +NewsSearchService newsSearchService(Ref ref) { + final repo = ref.watch(newsSearchRepositoryProvider); + return NewsSearchService(repo); +} + +class NewsSearchService { + final NewsSearchRepository _repository; + + NewsSearchService(this._repository); + + Stream> get results => _repository.responses.map(_toPosts); + + void query(String term) => _repository.query(term); + + List _toPosts(SearchResponse response) { + final hits = Platform.isIOS + ? response.hits.where((hit) => !radioArticles.contains(hit['objectID'])) + : response.hits; + + return hits.map((hit) => SearchPost.fromJson(hit)).toList(); + } +} diff --git a/mobile-app/lib/news/services/search_service.g.dart b/mobile-app/lib/news/services/search_service.g.dart new file mode 100644 index 000000000..86c7c22d0 --- /dev/null +++ b/mobile-app/lib/news/services/search_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsSearchService) +final newsSearchServiceProvider = NewsSearchServiceProvider._(); + +final class NewsSearchServiceProvider + extends + $FunctionalProvider< + NewsSearchService, + NewsSearchService, + NewsSearchService + > + with $Provider { + NewsSearchServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsSearchServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsSearchServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NewsSearchService create(Ref ref) { + return newsSearchService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NewsSearchService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$newsSearchServiceHash() => r'7bc8728f9bbde4042a5f3511d86f59729a5e8074'; diff --git a/mobile-app/lib/news/ui/author_feed/author_feed_view.dart b/mobile-app/lib/news/ui/author_feed/author_feed_view.dart new file mode 100644 index 000000000..fe79b6429 --- /dev/null +++ b/mobile-app/lib/news/ui/author_feed/author_feed_view.dart @@ -0,0 +1,116 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/controllers/feed_controller.dart'; +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freecodecamp/news/ui/author_feed/author_feed_viewmodel.dart'; +import 'package:freecodecamp/news/ui/widgets/post_feed_list.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class NewsAuthorFeedView extends ConsumerWidget { + const NewsAuthorFeedView({super.key, required this.username, this.author}); + + final String username; + final Author? author; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final author = this.author; + + return Scaffold( + appBar: AppBar(title: Text(author?.name ?? username)), + backgroundColor: FccColors.gray90, + body: author != null + ? _buildFeed(author) + : ref + .watch(newsAuthorProvider(username)) + .when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load author.', + onRetry: () => ref.invalidate(newsAuthorProvider(username)), + ), + data: _buildFeed, + ), + ); + } + + Widget _buildFeed(Author author) => PostFeedList( + source: NewsFeedSource.author(author.id), + header: _AuthorDetails(author: author), + ); +} + +class _AuthorDetails extends StatelessWidget { + const _AuthorDetails({required this.author}); + + final Author author; + + @override + Widget build(BuildContext context) { + final location = author.location; + final bio = author.bio?.text; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), + child: Column( + children: [ + _buildProfilePicture(), + const SizedBox(height: 16), + Text( + author.name, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + if (location != null && location.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(location, style: const TextStyle(fontSize: 16)), + ], + if (bio != null && bio.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + bio, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + ], + ], + ), + ); + } + + Widget _buildProfilePicture() { + final imageUrl = author.profilePicture; + + return Container( + width: 160, + height: 160, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: FccColors.gray80, + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ), + child: ClipOval( + child: imageUrl == null + ? Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + const ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) => Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.dart b/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.dart new file mode 100644 index 000000000..15ab44dd5 --- /dev/null +++ b/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.dart @@ -0,0 +1,11 @@ +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freecodecamp/news/services/api_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'author_feed_viewmodel.g.dart'; + +// Only used on a cold deep link; in-app navigation hands the author over as extra +@riverpod +Future newsAuthor(Ref ref, String username) { + return ref.watch(newsApiServiceProvider).getAuthor(username); +} diff --git a/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.g.dart b/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.g.dart new file mode 100644 index 000000000..3738ed4e3 --- /dev/null +++ b/mobile-app/lib/news/ui/author_feed/author_feed_viewmodel.g.dart @@ -0,0 +1,79 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'author_feed_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsAuthor) +final newsAuthorProvider = NewsAuthorFamily._(); + +final class NewsAuthorProvider + extends $FunctionalProvider, Author, FutureOr> + with $FutureModifier, $FutureProvider { + NewsAuthorProvider._({ + required NewsAuthorFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'newsAuthorProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsAuthorHash(); + + @override + String toString() { + return r'newsAuthorProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return newsAuthor(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is NewsAuthorProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$newsAuthorHash() => r'6393ad5923b855669e36c0e372aeb246477dce23'; + +final class NewsAuthorFamily extends $Family + with $FunctionalFamilyOverride, String> { + NewsAuthorFamily._() + : super( + retry: null, + name: r'newsAuthorProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + NewsAuthorProvider call(String username) => + NewsAuthorProvider._(argument: username, from: this); + + @override + String toString() => r'newsAuthorProvider'; +} diff --git a/mobile-app/lib/news/ui/bookmark_feed/bookmark_feed_view.dart b/mobile-app/lib/news/ui/bookmark_feed/bookmark_feed_view.dart new file mode 100644 index 000000000..181cb0c25 --- /dev/null +++ b/mobile-app/lib/news/ui/bookmark_feed/bookmark_feed_view.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/controllers/bookmarks_controller.dart'; +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class NewsBookmarkFeedView extends ConsumerWidget { + const NewsBookmarkFeedView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bookmarks = ref.watch(newsBookmarksProvider); + + return RefreshIndicator( + backgroundColor: FccColors.gray90, + color: Colors.white, + onRefresh: () => ref.refresh(newsBookmarksProvider.future), + child: bookmarks.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load bookmarks.', + onRetry: () => ref.invalidate(newsBookmarksProvider), + ), + data: (posts) => + posts.isEmpty ? const _EmptyState() : _BookmarkList(posts: posts), + ), + ); + } +} + +// Scrollable so RefreshIndicator still works with nothing in the list. +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + return ListView( + children: const [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 32, vertical: 96), + child: Text( + 'Bookmark tutorials to view them here', + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} + +class _BookmarkList extends StatelessWidget { + const _BookmarkList({required this.posts}); + + final List posts; + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemCount: posts.length, + separatorBuilder: (context, i) => const Divider(color: Colors.white), + itemBuilder: (context, index) { + final post = posts[index]; + + return ListTile( + title: Text(post.title), + trailing: const Icon(Icons.arrow_forward_ios_sharp), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8), + child: Text('Written by: ${post.authorName}'), + ), + contentPadding: const EdgeInsets.all(16), + minVerticalPadding: 8, + onTap: () => + NewsBookmarkPostRoute(id: post.id, $extra: post).push(context), + ); + }, + ); + } +} diff --git a/mobile-app/lib/news/ui/bookmark_post/bookmark_post_view.dart b/mobile-app/lib/news/ui/bookmark_post/bookmark_post_view.dart new file mode 100644 index 000000000..667cb9ec2 --- /dev/null +++ b/mobile-app/lib/news/ui/bookmark_post/bookmark_post_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/controllers/bookmarks_controller.dart'; +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/news/ui/widgets/back_to_top_button.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +// Reads the HTML stored at bookmark time, so it works with no network. +class NewsBookmarkPostView extends ConsumerStatefulWidget { + const NewsBookmarkPostView({super.key, required this.post}); + + final BookmarkedPost post; + + @override + ConsumerState createState() => + _NewsBookmarkPostViewState(); +} + +class _NewsBookmarkPostViewState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + // NOTE: Parsed and stored here to avoid re-parsing on every build + List? _htmlWidgets; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final post = widget.post; + + final isBookmarked = ref.watch( + newsBookmarksProvider.select( + (bookmarks) => bookmarks.value?.any((b) => b.id == post.id) ?? false, + ), + ); + + final htmlWidgets = _htmlWidgets ??= HTMLParser( + context: context, + ).parse(post.text); + + return Scaffold( + backgroundColor: FccColors.gray90, + floatingActionButton: BackToTopButton(controller: _scrollController), + body: SafeArea( + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar(title: Text(post.title)), + SliverAppBar( + pinned: true, + backgroundColor: FccColors.gray80, + automaticallyImplyLeading: false, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => + ref.read(newsBookmarksProvider.notifier).toggle(post), + icon: Icon( + isBookmarked + ? Icons.bookmark_added + : Icons.bookmark_add_outlined, + ), + label: Text(isBookmarked ? 'Bookmarked' : 'Bookmark'), + ), + ), + ), + ), + ), + SliverList.builder( + itemCount: htmlWidgets.length, + itemBuilder: (context, index) => + Row(children: [Expanded(child: htmlWidgets[index])]), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/feed/feed_view.dart b/mobile-app/lib/news/ui/feed/feed_view.dart new file mode 100644 index 000000000..3c087a134 --- /dev/null +++ b/mobile-app/lib/news/ui/feed/feed_view.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'package:freecodecamp/news/controllers/feed_controller.dart'; +import 'package:freecodecamp/news/ui/widgets/post_feed_list.dart'; + +class NewsFeedView extends StatelessWidget { + const NewsFeedView({super.key}); + + @override + Widget build(BuildContext context) => + const PostFeedList(source: NewsFeedSource.all()); +} diff --git a/mobile-app/lib/news/ui/image/image_view.dart b/mobile-app/lib/news/ui/image/image_view.dart new file mode 100644 index 000000000..a599c9fb4 --- /dev/null +++ b/mobile-app/lib/news/ui/image/image_view.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:go_router/go_router.dart'; +import 'package:photo_view/photo_view.dart'; + +class NewsImageView extends StatelessWidget { + const NewsImageView({super.key, required this.imgUrl}); + + final String imgUrl; + + bool get _isDataUrl => Uri.parse(imgUrl).scheme == 'data'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + leading: Tooltip( + message: 'Back', + child: InkWell( + onTap: () => context.pop(), + child: const Icon(Icons.arrow_back), + ), + ), + ), + body: PhotoView( + backgroundDecoration: const BoxDecoration( + color: FccColors.gray80, + backgroundBlendMode: BlendMode.color, + ), + imageProvider: _isDataUrl + ? MemoryImage(base64Decode(imgUrl.split(',').last)) + : NetworkImage(imgUrl), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/news_shell.dart b/mobile-app/lib/news/ui/news_shell.dart new file mode 100644 index 000000000..f1b2b8a13 --- /dev/null +++ b/mobile-app/lib/news/ui/news_shell.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/widgets/drawer/drawer.dart'; +import 'package:go_router/go_router.dart'; + +typedef _Tab = ({String title, String path, IconData icon}); + +class NewsShell extends StatelessWidget { + const NewsShell({super.key, required this.child}); + + final Widget child; + + static const List<_Tab> _tabs = [ + ( + title: 'Bookmarks', + path: newsBookmarksPath, + icon: Icons.bookmark_outline_sharp, + ), + (title: 'Tutorials', path: newsFeedPath, icon: Icons.article_sharp), + (title: 'Search', path: newsSearchPath, icon: Icons.search_sharp), + ]; + + static const _feedIndex = 1; + + int _currentIndex(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final index = _tabs.indexWhere((tab) => tab.path == location); + return index == -1 ? _feedIndex : index; + } + + @override + Widget build(BuildContext context) { + final index = _currentIndex(context); + + return Scaffold( + appBar: AppBar(title: Text(_tabs[index].title)), + drawer: const DrawerWidget(), + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: index, + onTap: (i) => context.go(_tabs[i].path), + items: [ + for (final tab in _tabs) + BottomNavigationBarItem( + icon: Icon(tab.icon), + label: tab.title, + tooltip: tab.title, + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/post/post_view.dart b/mobile-app/lib/news/ui/post/post_view.dart new file mode 100644 index 000000000..4feb998f6 --- /dev/null +++ b/mobile-app/lib/news/ui/post/post_view.dart @@ -0,0 +1,250 @@ +import 'dart:developer'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freecodecamp/news/ui/post/post_viewmodel.dart'; +import 'package:freecodecamp/news/ui/widgets/back_to_top_button.dart'; +import 'package:freecodecamp/news/ui/widgets/bookmark_button.dart'; +import 'package:freecodecamp/news/ui/widgets/bottom_button.dart'; +import 'package:freecodecamp/news/ui/widgets/tag_button.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; +import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; + +class NewsPostHeader extends StatelessWidget { + const NewsPostHeader({super.key, required this.post}); + + final Post post; + + @override + Widget build(BuildContext context) { + final imageUrl = post.coverImage?.url; + + return Column( + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: imageUrl == null + ? Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) => Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ), + ), + ), + Row( + children: [ + Expanded( + child: Container( + color: FccColors.gray80, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.title, + style: const TextStyle(fontSize: 24, height: 1.5), + ), + GestureDetector( + onTap: () => NewsAuthorRoute( + username: post.author.username, + $extra: post.author, + ).push(context), + child: Text( + 'Written by ${post.author.name}', + style: const TextStyle(height: 1.5), + ), + ), + if (post.tags.isNotEmpty) + Wrap( + children: [ + for (final tag in post.tags.take(3)) + TagButton(tagName: tag.name, tagSlug: tag.slug), + ], + ), + ], + ), + ), + ), + ], + ), + ], + ); + } +} + +class NewsPostView extends ConsumerStatefulWidget { + const NewsPostView({super.key, required this.slug}); + + final String slug; + + @override + ConsumerState createState() => _NewsPostViewState(); +} + +class _NewsPostViewState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + + final GlobalKey _shareButtonKey = GlobalKey(); + + bool _showBottomBar = true; + + // NOTE: Parsed and stored here to avoid re-parsing on every build + List? _htmlWidgets; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_handleBottomBarVisibility); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + double _lastScrollOffset = 0; + + void _handleBottomBarVisibility() { + final offset = _scrollController.offset; + // Scrolling up reveals the bar, scrolling down hides it. + final shouldShow = offset <= _lastScrollOffset; + _lastScrollOffset = offset; + + if (shouldShow == _showBottomBar) return; + setState(() => _showBottomBar = shouldShow); + } + + @override + Widget build(BuildContext context) { + final postAsync = ref.watch(newsPostProvider(widget.slug)); + + return Scaffold( + backgroundColor: FccColors.gray90, + floatingActionButton: BackToTopButton(controller: _scrollController), + body: postAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + log('Error loading post: $error'); + return ErrorRetry( + message: 'Unable to load post.', + onRetry: () => ref.invalidate(newsPostProvider(widget.slug)), + ); + }, + data: (post) { + final htmlWidgets = _htmlWidgets ??= _parseHtml(post); + + return Stack( + children: [ + ListView.builder( + controller: _scrollController, + itemCount: htmlWidgets.length, + physics: const ClampingScrollPhysics(), + itemBuilder: (context, i) { + return Row(children: [Expanded(child: htmlWidgets[i])]); + }, + ), + _buildBottomButtons(post), + ], + ); + }, + ), + ); + } + + List _parseHtml(Post post) { + final elements = HTMLParser(context: context).parse(post.content.html); + + elements.insert( + 0, + Stack( + children: [ + NewsPostHeader(post: post), + AppBar( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + leading: Tooltip( + message: 'Back', + child: InkWell( + onTap: () => context.pop(), + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: Colors.black.withValues(alpha: 0.5), + ), + child: const Icon(Icons.arrow_back), + ), + ), + ), + ), + ], + ), + ); + + elements.add(const SizedBox(height: 100)); + + return elements; + } + + void _sharePost(Post post) { + final box = + _shareButtonKey.currentContext?.findRenderObject() as RenderBox?; + + SharePlus.instance.share( + ShareParams( + text: '${post.title}\n\n${post.url}', + sharePositionOrigin: box == null + ? null + : box.localToGlobal(Offset.zero) & box.size, + ), + ); + } + + Widget _buildBottomButtons(Post post) { + return Align( + alignment: Alignment.bottomCenter, + child: AnimatedSlide( + offset: _showBottomBar ? Offset.zero : const Offset(0, 1), + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: Padding( + padding: const EdgeInsets.only(bottom: 32), + child: SizedBox( + width: 300, + child: Row( + children: [ + BookmarkButton(post: post), + const SizedBox( + height: 35, + child: VerticalDivider(color: Colors.white, width: 0), + ), + NewsBottomButton( + key: _shareButtonKey, + label: 'Share', + icon: Icons.share, + onPressed: () => _sharePost(post), + rightSided: true, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/post/post_viewmodel.dart b/mobile-app/lib/news/ui/post/post_viewmodel.dart new file mode 100644 index 000000000..53220c0a9 --- /dev/null +++ b/mobile-app/lib/news/ui/post/post_viewmodel.dart @@ -0,0 +1,10 @@ +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freecodecamp/news/services/api_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'post_viewmodel.g.dart'; + +@riverpod +Future newsPost(Ref ref, String slug) { + return ref.watch(newsApiServiceProvider).getPostBySlug(slug); +} diff --git a/mobile-app/lib/news/ui/post/post_viewmodel.g.dart b/mobile-app/lib/news/ui/post/post_viewmodel.g.dart new file mode 100644 index 000000000..6cf06e6d2 --- /dev/null +++ b/mobile-app/lib/news/ui/post/post_viewmodel.g.dart @@ -0,0 +1,79 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(newsPost) +final newsPostProvider = NewsPostFamily._(); + +final class NewsPostProvider + extends $FunctionalProvider, Post, FutureOr> + with $FutureModifier, $FutureProvider { + NewsPostProvider._({ + required NewsPostFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'newsPostProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsPostHash(); + + @override + String toString() { + return r'newsPostProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return newsPost(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is NewsPostProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$newsPostHash() => r'f7390a9239ff0a618776dd692722c37adc28b846'; + +final class NewsPostFamily extends $Family + with $FunctionalFamilyOverride, String> { + NewsPostFamily._() + : super( + retry: null, + name: r'newsPostProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + NewsPostProvider call(String slug) => + NewsPostProvider._(argument: slug, from: this); + + @override + String toString() => r'newsPostProvider'; +} diff --git a/mobile-app/lib/news/ui/search/search_view.dart b/mobile-app/lib/news/ui/search/search_view.dart new file mode 100644 index 000000000..3293dd048 --- /dev/null +++ b/mobile-app/lib/news/ui/search/search_view.dart @@ -0,0 +1,90 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/models/search_post_model.dart'; +import 'package:freecodecamp/news/ui/search/search_viewmodel.dart'; +import 'package:freecodecamp/news/ui/widgets/search_result_tile.dart'; + +class NewsSearchView extends ConsumerStatefulWidget { + const NewsSearchView({super.key}); + + @override + ConsumerState createState() => _NewsSearchViewState(); +} + +class _NewsSearchViewState extends ConsumerState { + final TextEditingController _searchBarController = TextEditingController(); + + @override + void dispose() { + _searchBarController.dispose(); + super.dispose(); + } + + void _onSearchTermChanged(String term) => + ref.read(newsSearchProvider.notifier).search(term); + + @override + Widget build(BuildContext context) { + final results = ref.watch(newsSearchProvider); + + return Column( + children: [ + TextField( + controller: _searchBarController, + onChanged: _onSearchTermChanged, + textInputAction: TextInputAction.search, + decoration: const InputDecoration( + hintText: 'SEARCH TUTORIALS...', + fillColor: FccColors.gray80, + filled: true, + border: InputBorder.none, + ), + ), + const Divider(color: FccColors.gray90, thickness: 4, height: 4), + Expanded( + child: ValueListenableBuilder( + valueListenable: _searchBarController, + builder: (context, value, _) => value.text.isEmpty + ? const Center(child: Text('Search for tutorials')) + : _buildResults(results), + ), + ), + ], + ); + } + + Widget _buildResults(AsyncValue> results) { + return results.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) { + log('Error loading tutorials: $error\n$stackTrace'); + return const Center( + child: Text( + 'There was an error loading tutorials \n please try again', + textAlign: TextAlign.center, + ), + ); + }, + data: (posts) => posts.isEmpty + ? const Center(child: Text('No Tutorials Found')) + : _buildResultsList(posts), + ); + } + + Widget _buildResultsList(List posts) { + return Scrollbar( + thumbVisibility: true, + trackVisibility: true, + child: ListView.separated( + physics: const ClampingScrollPhysics(), + itemCount: posts.length, + separatorBuilder: (context, index) => + Divider(color: FccColors.gray80, thickness: 1, height: 1), + itemBuilder: (context, index) => SearchResultTile(post: posts[index]), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/search/search_viewmodel.dart b/mobile-app/lib/news/ui/search/search_viewmodel.dart new file mode 100644 index 000000000..f093fe92a --- /dev/null +++ b/mobile-app/lib/news/ui/search/search_viewmodel.dart @@ -0,0 +1,45 @@ +import 'package:freecodecamp/news/models/search_post_model.dart'; +import 'package:freecodecamp/news/services/search_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_viewmodel.g.dart'; + +@riverpod +class NewsSearchNotifier extends _$NewsSearchNotifier { + // NOTE: Algolia drops a query identical to its last one, so a repeat is answered from here. + String? _lastTerm; + List? _lastResults; + + @override + AsyncValue> build() { + final subscription = ref + .watch(newsSearchServiceProvider) + .results + .listen( + (results) => state = AsyncData(_lastResults = results), + onError: (Object error, StackTrace stackTrace) => + state = AsyncError(error, stackTrace), + ); + + ref.onDispose(subscription.cancel); + + return const AsyncData([]); + } + + void search(String term) { + if (term.isEmpty) { + state = const AsyncData([]); + return; + } + + if (term == _lastTerm && _lastResults != null) { + state = AsyncData(_lastResults!); + return; + } + + _lastTerm = term; + _lastResults = null; + state = const AsyncLoading(); + ref.read(newsSearchServiceProvider).query(term); + } +} diff --git a/mobile-app/lib/news/ui/search/search_viewmodel.g.dart b/mobile-app/lib/news/ui/search/search_viewmodel.g.dart new file mode 100644 index 000000000..ca54445dc --- /dev/null +++ b/mobile-app/lib/news/ui/search/search_viewmodel.g.dart @@ -0,0 +1,70 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(NewsSearchNotifier) +final newsSearchProvider = NewsSearchNotifierProvider._(); + +final class NewsSearchNotifierProvider + extends + $NotifierProvider>> { + NewsSearchNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'newsSearchProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$newsSearchNotifierHash(); + + @$internal + @override + NewsSearchNotifier create() => NewsSearchNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>>(value), + ); + } +} + +String _$newsSearchNotifierHash() => + r'9a71f563daa8cfb10e82dda064b58242bbdd5522'; + +abstract class _$NewsSearchNotifier + extends $Notifier>> { + AsyncValue> build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref>, AsyncValue>>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue>, + AsyncValue> + >, + AsyncValue>, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/news/ui/tag_feed/tag_feed_view.dart b/mobile-app/lib/news/ui/tag_feed/tag_feed_view.dart new file mode 100644 index 000000000..17c706c1d --- /dev/null +++ b/mobile-app/lib/news/ui/tag_feed/tag_feed_view.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/controllers/feed_controller.dart'; +import 'package:freecodecamp/news/ui/widgets/post_feed_list.dart'; + +class NewsTagFeedView extends StatelessWidget { + const NewsTagFeedView({super.key, required this.tagSlug, this.tagName}); + + final String tagSlug; + final String? tagName; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Tutorials about ${tagName ?? tagSlug}')), + backgroundColor: FccColors.gray90, + body: PostFeedList(source: NewsFeedSource.tag(tagSlug)), + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/back_to_top_button.dart b/mobile-app/lib/news/ui/widgets/back_to_top_button.dart new file mode 100644 index 000000000..a08bbc115 --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/back_to_top_button.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +class BackToTopButton extends StatefulWidget { + const BackToTopButton({ + super.key, + required this.controller, + this.showAfter = 100, + }); + + final ScrollController controller; + final double showAfter; + + @override + State createState() => _BackToTopButtonState(); +} + +class _BackToTopButtonState extends State { + bool _visible = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_handleScroll); + } + + @override + void dispose() { + widget.controller.removeListener(_handleScroll); + super.dispose(); + } + + void _handleScroll() { + if (!widget.controller.hasClients) return; + + final shouldShow = widget.controller.offset >= widget.showAfter; + if (shouldShow == _visible) return; + setState(() => _visible = shouldShow); + } + + void _goToTop() { + widget.controller.animateTo( + 0, + duration: const Duration(milliseconds: 1000), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedScale( + scale: _visible ? 1 : 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Padding( + // Lifts the button clear of the bottom action buttons. + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 64), + child: FloatingActionButton( + onPressed: _goToTop, + shape: RoundedRectangleBorder( + side: const BorderSide(width: 1, color: Colors.white), + borderRadius: BorderRadius.circular(100), + ), + backgroundColor: FccColors.gray80, + child: const Icon( + Icons.keyboard_arrow_up, + size: 40, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/bookmark_button.dart b/mobile-app/lib/news/ui/widgets/bookmark_button.dart new file mode 100644 index 000000000..522334f8a --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/bookmark_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/news/controllers/bookmarks_controller.dart'; +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/news/models/post_model.dart'; +import 'package:freecodecamp/news/ui/widgets/bottom_button.dart'; + +class BookmarkButton extends ConsumerWidget { + const BookmarkButton({super.key, required this.post}); + + final Post post; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bookmarked = ref.watch( + newsBookmarksProvider.select( + (bookmarks) => bookmarks.value?.any((b) => b.id == post.id) ?? false, + ), + ); + + return NewsBottomButton( + label: bookmarked ? 'Bookmarked' : 'Bookmark', + icon: bookmarked ? Icons.bookmark_added : Icons.bookmark_add_outlined, + onPressed: () => ref + .read(newsBookmarksProvider.notifier) + .toggle(BookmarkedPost.fromPost(post)), + rightSided: false, + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/bottom_button.dart b/mobile-app/lib/news/ui/widgets/bottom_button.dart new file mode 100644 index 000000000..475c09408 --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/bottom_button.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +class NewsBottomButton extends StatelessWidget { + const NewsBottomButton({ + super.key, + required this.label, + required this.onPressed, + required this.icon, + required this.rightSided, + }); + + final VoidCallback onPressed; + final String label; + final bool rightSided; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Expanded( + child: ElevatedButton.icon( + icon: Icon(icon, color: Colors.white), + onPressed: onPressed, + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(label, maxLines: 1, softWrap: false), + ), + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(rightSided ? 0 : 10), + topRight: Radius.circular(rightSided ? 10 : 0), + bottomLeft: Radius.circular(rightSided ? 0 : 10), + bottomRight: Radius.circular(rightSided ? 10 : 0), + ), + ), + backgroundColor: FccColors.gray80, + ), + ), + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/post_feed_list.dart b/mobile-app/lib/news/ui/widgets/post_feed_list.dart new file mode 100644 index 000000000..81f72365c --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/post_feed_list.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/controllers/feed_controller.dart'; +import 'package:freecodecamp/news/ui/widgets/post_tile.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class PostFeedList extends ConsumerStatefulWidget { + const PostFeedList({super.key, required this.source, this.header}); + + final NewsFeedSource source; + + // NOTE: The header is optional and can be used to display a widget above the + // list of posts, such as author details or tag information. + final Widget? header; + + @override + ConsumerState createState() => _PostFeedListState(); +} + +class _PostFeedListState extends ConsumerState { + final _scrollController = ScrollController(); + + NewsFeedNotifierProvider get _provider => newsFeedProvider(widget.source); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 1000) { + ref.read(_provider.notifier).fetchNextPage(); + } + } + + @override + Widget build(BuildContext context) { + final feedAsync = ref.watch(_provider); + + return ColoredBox( + color: FccColors.gray90, + child: feedAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load tutorials.', + onRetry: () => ref.invalidate(_provider), + ), + data: (feed) => RefreshIndicator( + onRefresh: () => + ref.refresh(_provider.future).then((_) {}, onError: (_) {}), + backgroundColor: FccColors.gray90, + color: Colors.white, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + if (widget.header case final header?) + SliverToBoxAdapter( + child: Column( + children: [ + header, + const Divider( + color: FccColors.gray80, + thickness: 1, + height: 1, + ), + ], + ), + ), + _buildList(feed), + ], + ), + ), + ), + ); + } + + Widget _buildList(NewsFeedState feed) { + final footer = _buildFooter(feed); + + return SliverList.separated( + itemCount: feed.posts.length + (footer == null ? 0 : 1), + separatorBuilder: (_, _) => + const Divider(color: FccColors.gray80, thickness: 1, height: 1), + itemBuilder: (context, index) => index == feed.posts.length + ? footer + : PostTile( + key: ValueKey(feed.posts[index].id), + post: feed.posts[index], + ), + ); + } + + // Null when there is nothing to append: idle, or every page is loaded. + Widget? _buildFooter(NewsFeedState feed) { + if (feed.loadMoreError != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: ErrorRetry( + message: 'Unable to load more tutorials.', + onRetry: () => + ref.read(_provider.notifier).fetchNextPage(isRetry: true), + ), + ); + } + + if (feed.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ); + } + + return null; + } +} diff --git a/mobile-app/lib/news/ui/widgets/post_tile.dart b/mobile-app/lib/news/ui/widgets/post_tile.dart new file mode 100644 index 000000000..afd26814b --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/post_tile.dart @@ -0,0 +1,168 @@ +import 'dart:developer'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/models/post_summary_model.dart'; +import 'package:freecodecamp/news/ui/widgets/tag_button.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/utils/date_utils.dart'; + +class PostTile extends StatelessWidget { + const PostTile({super.key, required this.post}); + + final PostSummary post; + + @override + Widget build(BuildContext context) { + return InkWell( + splashColor: Colors.transparent, + onTap: () { + NewsPostRoute(slug: post.slug).push(context); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCoverImage(), + const SizedBox(height: 8), + if (post.tags.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Wrap( + spacing: 0, + runSpacing: 4, + children: [ + for (final tag in post.tags.take(3)) + TagButton( + tagName: tag.name, + tagSlug: tag.slug, + compact: true, + ), + ], + ), + ), + Text( + post.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + const SizedBox(height: 8), + _buildAuthorRow(context), + ], + ), + ), + ); + } + + Widget _buildCoverImage() { + final imageUrl = post.coverImage?.url; + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + color: FccColors.gray80, + child: imageUrl == null + ? Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) { + log('Error loading image: $url - $imageUrl - $error'); + return Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ); + }, + ), + ), + ), + ); + } + + Widget _buildAuthorRow(BuildContext context) { + final author = post.author; + final profileUrl = author.profilePicture; + + return Row( + children: [ + Flexible( + child: GestureDetector( + onTap: () { + NewsAuthorRoute( + username: author.username, + $extra: author, + ).push(context); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 24, + height: 24, + child: profileUrl == null + ? Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: profileUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) => Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ), + ), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + author.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ), + ], + ), + ), + ), + Text( + ' • ', + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + Text( + parseDate(post.publishedAt), + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/search_result_tile.dart b/mobile-app/lib/news/ui/widgets/search_result_tile.dart new file mode 100644 index 000000000..876d9bf6d --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/search_result_tile.dart @@ -0,0 +1,131 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/news/models/search_post_model.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/utils/date_utils.dart'; + +class SearchResultTile extends StatelessWidget { + const SearchResultTile({super.key, required this.post}); + + final SearchPost post; + + @override + Widget build(BuildContext context) { + final publishedAt = post.publishedAt; + + return InkWell( + onTap: () => NewsPostRoute(slug: post.slug).push(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCoverImage(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + height: 1.3, + ), + ), + const SizedBox(height: 8), + _buildAuthorRow(), + if (publishedAt != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + parseDate(publishedAt), + style: TextStyle( + fontSize: 12, + color: Colors.white.withValues(alpha: 0.5), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildCoverImage() { + final imageUrl = post.featureImage; + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 120, + height: 80, + child: imageUrl == null + ? Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) => Image.asset( + 'assets/images/freecodecamp-banner.png', + fit: BoxFit.cover, + ), + ), + ), + ); + } + + Widget _buildAuthorRow() { + final profileImageUrl = post.author.profileImage; + + return Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 24, + height: 24, + child: profileImageUrl == null + ? Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ) + : CachedNetworkImage( + imageUrl: profileImageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + ColoredBox(color: FccColors.gray80), + errorWidget: (context, url, error) => Image.asset( + 'assets/images/placeholder-profile-img.png', + fit: BoxFit.cover, + ), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + post.author.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ), + ], + ); + } +} diff --git a/mobile-app/lib/news/ui/widgets/tag_button.dart b/mobile-app/lib/news/ui/widgets/tag_button.dart new file mode 100644 index 000000000..f53224a54 --- /dev/null +++ b/mobile-app/lib/news/ui/widgets/tag_button.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/routing/news.dart'; + +class TagButton extends StatelessWidget { + const TagButton({ + super.key, + required this.tagName, + required this.tagSlug, + this.compact = false, + }); + + final String tagName; + final String tagSlug; + final bool compact; + + static const _colors = [ + FccColors.purple10, + FccColors.yellow50, + FccColors.blue30, + FccColors.green40, + ]; + + Color get _tagColor => _colors[tagSlug.hashCode.abs() % _colors.length]; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.fromLTRB(0, compact ? 0 : 8, compact ? 6 : 8, 0), + child: InkWell( + onTap: () { + NewsTagFeedRoute(tagSlug: tagSlug, $extra: tagName).push(context); + }, + child: Container( + constraints: BoxConstraints( + maxWidth: + MediaQuery.of(context).size.width * (compact ? 0.35 : 0.45), + ), + decoration: ShapeDecoration( + color: _tagColor, + shape: const StadiumBorder(), + ), + child: Padding( + padding: EdgeInsets.symmetric( + vertical: compact ? 2 : 4, + horizontal: compact ? 6 : 8, + ), + child: Tooltip( + message: '#$tagName', + child: Text( + '#$tagName', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: compact ? 11 : 16, + color: Colors.black, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/podcasts/constants.dart b/mobile-app/lib/podcasts/constants.dart new file mode 100644 index 000000000..f205a5c6a --- /dev/null +++ b/mobile-app/lib/podcasts/constants.dart @@ -0,0 +1,14 @@ +// NOTE: Feed URLs for freeCodeCamp's own podcasts, pinned above the rest of +// the list. +const fccPodcastUrls = [ + // English + 'https://freecodecamp.libsyn.com/rss', + // Spanish + 'https://anchor.fm/s/ff0092f4/podcast/rss', + // Chinese + 'https://anchor.fm/s/ff054de4/podcast/rss', + // Portuguese + 'https://anchor.fm/s/ff026c00/podcast/rss', +]; + +const podcastSpeedOptions = [0.75, 1.0, 1.25, 1.5, 2.0]; diff --git a/mobile-app/lib/podcasts/controllers/downloads_controller.dart b/mobile-app/lib/podcasts/controllers/downloads_controller.dart new file mode 100644 index 000000000..ca0a853ec --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/downloads_controller.dart @@ -0,0 +1,93 @@ +import 'dart:developer'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:freecodecamp/podcasts/controllers/library_controller.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/services/library_service.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'downloads_controller.freezed.dart'; +part 'downloads_controller.g.dart'; + +@freezed +sealed class DownloadStatus with _$DownloadStatus { + const factory DownloadStatus.running(double progress) = DownloadRunning; + const factory DownloadStatus.failed() = DownloadFailed; +} + +@riverpod +class PodcastDownloadsNotifier extends _$PodcastDownloadsNotifier { + final _cancelTokens = {}; + + // NOTE: Pinned only while transfers are in flight, so an idle notifier is disposed. + KeepAliveLink? _link; + + @override + Map build() => const {}; + + Future start(Podcast podcast, Episode episode) async { + if (_cancelTokens.containsKey(episode.id)) return; + + final token = CancelToken(); + _cancelTokens[episode.id] = token; + _link ??= ref.keepAlive(); + _set(episode.id, const DownloadStatus.running(0)); + + try { + await ref + .read(podcastLibraryServiceProvider) + .download( + podcast, + episode, + cancelToken: token, + onProgress: (progress) { + if (_cancelTokens.containsKey(episode.id)) { + _set(episode.id, DownloadStatus.running(progress)); + } + }, + ); + + _clear(episode.id); + + // The library owns "downloaded"; this notifier only reports progress. + await ref.read(podcastLibraryProvider.notifier).refresh(); + } on DioException catch (e) { + if (CancelToken.isCancel(e)) { + _clear(episode.id); + } else { + log('Podcast download failed for ${episode.id}: $e'); + _set(episode.id, const DownloadStatus.failed()); + } + } catch (e) { + log('Podcast download failed for ${episode.id}: $e'); + _set(episode.id, const DownloadStatus.failed()); + } finally { + _cancelTokens.remove(episode.id); + _releaseWhenIdle(); + } + } + + void _releaseWhenIdle() { + if (_cancelTokens.isNotEmpty) return; + + _link?.close(); + _link = null; + } + + void cancel(String episodeId) { + _cancelTokens.remove(episodeId)?.cancel('cancelled by user'); + _clear(episodeId); + _releaseWhenIdle(); + } + + void _set(String episodeId, DownloadStatus status) { + state = {...state, episodeId: status}; + } + + void _clear(String episodeId) { + state = {...state}..remove(episodeId); + } +} diff --git a/mobile-app/lib/podcasts/controllers/downloads_controller.freezed.dart b/mobile-app/lib/podcasts/controllers/downloads_controller.freezed.dart new file mode 100644 index 000000000..249bf17bc --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/downloads_controller.freezed.dart @@ -0,0 +1,272 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'downloads_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$DownloadStatus { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadStatus); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'DownloadStatus()'; +} + + +} + +/// @nodoc +class $DownloadStatusCopyWith<$Res> { +$DownloadStatusCopyWith(DownloadStatus _, $Res Function(DownloadStatus) __); +} + + +/// Adds pattern-matching-related methods to [DownloadStatus]. +extension DownloadStatusPatterns on DownloadStatus { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( DownloadRunning value)? running,TResult Function( DownloadFailed value)? failed,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case DownloadRunning() when running != null: +return running(_that);case DownloadFailed() when failed != null: +return failed(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( DownloadRunning value) running,required TResult Function( DownloadFailed value) failed,}){ +final _that = this; +switch (_that) { +case DownloadRunning(): +return running(_that);case DownloadFailed(): +return failed(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( DownloadRunning value)? running,TResult? Function( DownloadFailed value)? failed,}){ +final _that = this; +switch (_that) { +case DownloadRunning() when running != null: +return running(_that);case DownloadFailed() when failed != null: +return failed(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( double progress)? running,TResult Function()? failed,required TResult orElse(),}) {final _that = this; +switch (_that) { +case DownloadRunning() when running != null: +return running(_that.progress);case DownloadFailed() when failed != null: +return failed();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( double progress) running,required TResult Function() failed,}) {final _that = this; +switch (_that) { +case DownloadRunning(): +return running(_that.progress);case DownloadFailed(): +return failed();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( double progress)? running,TResult? Function()? failed,}) {final _that = this; +switch (_that) { +case DownloadRunning() when running != null: +return running(_that.progress);case DownloadFailed() when failed != null: +return failed();case _: + return null; + +} +} + +} + +/// @nodoc + + +class DownloadRunning implements DownloadStatus { + const DownloadRunning(this.progress); + + + final double progress; + +/// Create a copy of DownloadStatus +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DownloadRunningCopyWith get copyWith => _$DownloadRunningCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadRunning&&(identical(other.progress, progress) || other.progress == progress)); +} + + +@override +int get hashCode => Object.hash(runtimeType,progress); + +@override +String toString() { + return 'DownloadStatus.running(progress: $progress)'; +} + + +} + +/// @nodoc +abstract mixin class $DownloadRunningCopyWith<$Res> implements $DownloadStatusCopyWith<$Res> { + factory $DownloadRunningCopyWith(DownloadRunning value, $Res Function(DownloadRunning) _then) = _$DownloadRunningCopyWithImpl; +@useResult +$Res call({ + double progress +}); + + + + +} +/// @nodoc +class _$DownloadRunningCopyWithImpl<$Res> + implements $DownloadRunningCopyWith<$Res> { + _$DownloadRunningCopyWithImpl(this._self, this._then); + + final DownloadRunning _self; + final $Res Function(DownloadRunning) _then; + +/// Create a copy of DownloadStatus +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? progress = null,}) { + return _then(DownloadRunning( +null == progress ? _self.progress : progress // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +/// @nodoc + + +class DownloadFailed implements DownloadStatus { + const DownloadFailed(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadFailed); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'DownloadStatus.failed()'; +} + + +} + + + + +// dart format on diff --git a/mobile-app/lib/podcasts/controllers/downloads_controller.g.dart b/mobile-app/lib/podcasts/controllers/downloads_controller.g.dart new file mode 100644 index 000000000..33ecf5325 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/downloads_controller.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'downloads_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(PodcastDownloadsNotifier) +final podcastDownloadsProvider = PodcastDownloadsNotifierProvider._(); + +final class PodcastDownloadsNotifierProvider + extends + $NotifierProvider< + PodcastDownloadsNotifier, + Map + > { + PodcastDownloadsNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastDownloadsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastDownloadsNotifierHash(); + + @$internal + @override + PodcastDownloadsNotifier create() => PodcastDownloadsNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$podcastDownloadsNotifierHash() => + r'b36ce1140f4480667777d8ab57418ada7ab98b98'; + +abstract class _$PodcastDownloadsNotifier + extends $Notifier> { + Map build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/podcasts/controllers/library_controller.dart b/mobile-app/lib/podcasts/controllers/library_controller.dart new file mode 100644 index 000000000..0d13f69b4 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/library_controller.dart @@ -0,0 +1,29 @@ +import 'package:freecodecamp/podcasts/services/library_service.dart'; +import 'package:freecodecamp/services/audio_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'library_controller.g.dart'; + +@riverpod +class PodcastLibraryNotifier extends _$PodcastLibraryNotifier { + @override + Future build() => + ref.watch(podcastLibraryServiceProvider).getLibrary(); + + Future refresh() async { + final library = await ref.read(podcastLibraryServiceProvider).getLibrary(); + if (ref.mounted) state = AsyncData(library); + } + + Future remove(String podcastId, String episodeId) async { + final handler = ref.read(audioHandlerProvider); + if (handler.episodeId == episodeId) await handler.stop(); + + await ref + .read(podcastLibraryServiceProvider) + .removeEpisode(podcastId, episodeId); + if (!ref.mounted) return; + + await refresh(); + } +} diff --git a/mobile-app/lib/podcasts/controllers/library_controller.g.dart b/mobile-app/lib/podcasts/controllers/library_controller.g.dart new file mode 100644 index 000000000..e9ac869a2 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/library_controller.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(PodcastLibraryNotifier) +final podcastLibraryProvider = PodcastLibraryNotifierProvider._(); + +final class PodcastLibraryNotifierProvider + extends $AsyncNotifierProvider { + PodcastLibraryNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastLibraryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastLibraryNotifierHash(); + + @$internal + @override + PodcastLibraryNotifier create() => PodcastLibraryNotifier(); +} + +String _$podcastLibraryNotifierHash() => + r'eea18b471341bc853fac6f5e169b55277f2967b5'; + +abstract class _$PodcastLibraryNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, PodcastLibrary>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, PodcastLibrary>, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/podcasts/controllers/player_controller.dart b/mobile-app/lib/podcasts/controllers/player_controller.dart new file mode 100644 index 000000000..a3dbe3ca5 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/player_controller.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/services/playback_service.dart'; +import 'package:freecodecamp/services/audio_service.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'player_controller.freezed.dart'; +part 'player_controller.g.dart'; + +@freezed +abstract class PodcastPlayerState with _$PodcastPlayerState { + const factory PodcastPlayerState({ + String? episodeId, + String? loadingEpisodeId, + @Default(false) bool isPlaying, + @Default(false) bool isBuffering, + Duration? duration, + @Default(1.0) double speed, + }) = _PodcastPlayerState; + + const PodcastPlayerState._(); + + bool isCurrent(String id) => episodeId == id; + + bool isPlayingEpisode(String id) => isCurrent(id) && isPlaying; + + bool isBusyWith(String id) => + loadingEpisodeId == id || (isCurrent(id) && isBuffering); +} + +// NOTE: Kept off PodcastPlayerState deliberately. This ticks up to 60x a +// second +@riverpod +Stream podcastPlaybackPosition(Ref ref) => + ref.watch(audioHandlerProvider).positionStream; + +@riverpod +class PodcastPlayerNotifier extends _$PodcastPlayerNotifier { + // NOTE: Pinned while an episode is loaded, released once the handler holds + // none at all. + KeepAliveLink? _link; + + @override + PodcastPlayerState build() { + final handler = ref.watch(audioHandlerProvider); + + ref.watch(podcastPlaybackServiceProvider); + + final subscription = handler.playbackState.listen((playback) { + if (!ref.mounted) return; + state = _fromPlayback( + handler, + playback, + loadingEpisodeId: state.loadingEpisodeId, + ); + _syncKeepAlive(); + }); + + ref.onDispose(() { + subscription.cancel(); + _link?.close(); + _link = null; + }); + + return _fromPlayback(handler, handler.playbackState.value); + } + + void _syncKeepAlive() { + if (state.episodeId != null || state.loadingEpisodeId != null) { + _link ??= ref.keepAlive(); + return; + } + + _link?.close(); + _link = null; + } + + PodcastPlayerState _fromPlayback( + AudioPlayerHandler handler, + PlaybackState playback, { + String? loadingEpisodeId, + }) { + final episodeId = handler.episodeId; + + return PodcastPlayerState( + loadingEpisodeId: loadingEpisodeId, + episodeId: episodeId, + isPlaying: episodeId != null && playback.playing, + isBuffering: + episodeId != null && + const { + AudioProcessingState.loading, + AudioProcessingState.buffering, + }.contains(playback.processingState), + duration: handler.duration, + speed: playback.speed, + ); + } + + Future toggle(Podcast podcast, Episode episode) async { + final handler = ref.read(audioHandlerProvider); + + if (handler.episodeId == episode.id) { + return state.isPlaying ? handler.pause() : handler.play(); + } + + state = state.copyWith(loadingEpisodeId: episode.id); + _syncKeepAlive(); + + try { + final uris = await ref + .read(podcastPlaybackServiceProvider) + .playbackUris(podcast, episode); + + final loaded = await handler.loadEpisode( + episode, + podcast, + source: uris.audio, + artUri: uris.artwork, + startAt: await ref + .read(podcastPlaybackServiceProvider) + .readPosition(episode.id), + ); + + if (loaded) await handler.play(); + } finally { + if (ref.mounted && state.loadingEpisodeId == episode.id) { + state = state.copyWith(loadingEpisodeId: null); + _syncKeepAlive(); + } + } + } + + Future seek(Duration position) => + ref.read(audioHandlerProvider).seek(position); + + Future fastForward() => ref.read(audioHandlerProvider).fastForward(); + + Future rewind() => ref.read(audioHandlerProvider).rewind(); + + Future setSpeed(double speed) => + ref.read(audioHandlerProvider).setSpeed(speed); +} diff --git a/mobile-app/lib/podcasts/controllers/player_controller.freezed.dart b/mobile-app/lib/podcasts/controllers/player_controller.freezed.dart new file mode 100644 index 000000000..c34a10121 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/player_controller.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'player_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PodcastPlayerState { + + String? get episodeId; String? get loadingEpisodeId; bool get isPlaying; bool get isBuffering; Duration? get duration; double get speed; +/// Create a copy of PodcastPlayerState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PodcastPlayerStateCopyWith get copyWith => _$PodcastPlayerStateCopyWithImpl(this as PodcastPlayerState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PodcastPlayerState&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.loadingEpisodeId, loadingEpisodeId) || other.loadingEpisodeId == loadingEpisodeId)&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isBuffering, isBuffering) || other.isBuffering == isBuffering)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.speed, speed) || other.speed == speed)); +} + + +@override +int get hashCode => Object.hash(runtimeType,episodeId,loadingEpisodeId,isPlaying,isBuffering,duration,speed); + +@override +String toString() { + return 'PodcastPlayerState(episodeId: $episodeId, loadingEpisodeId: $loadingEpisodeId, isPlaying: $isPlaying, isBuffering: $isBuffering, duration: $duration, speed: $speed)'; +} + + +} + +/// @nodoc +abstract mixin class $PodcastPlayerStateCopyWith<$Res> { + factory $PodcastPlayerStateCopyWith(PodcastPlayerState value, $Res Function(PodcastPlayerState) _then) = _$PodcastPlayerStateCopyWithImpl; +@useResult +$Res call({ + String? episodeId, String? loadingEpisodeId, bool isPlaying, bool isBuffering, Duration? duration, double speed +}); + + + + +} +/// @nodoc +class _$PodcastPlayerStateCopyWithImpl<$Res> + implements $PodcastPlayerStateCopyWith<$Res> { + _$PodcastPlayerStateCopyWithImpl(this._self, this._then); + + final PodcastPlayerState _self; + final $Res Function(PodcastPlayerState) _then; + +/// Create a copy of PodcastPlayerState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? episodeId = freezed,Object? loadingEpisodeId = freezed,Object? isPlaying = null,Object? isBuffering = null,Object? duration = freezed,Object? speed = null,}) { + return _then(_self.copyWith( +episodeId: freezed == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as String?,loadingEpisodeId: freezed == loadingEpisodeId ? _self.loadingEpisodeId : loadingEpisodeId // ignore: cast_nullable_to_non_nullable +as String?,isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable +as bool,isBuffering: null == isBuffering ? _self.isBuffering : isBuffering // ignore: cast_nullable_to_non_nullable +as bool,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration?,speed: null == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PodcastPlayerState]. +extension PodcastPlayerStatePatterns on PodcastPlayerState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PodcastPlayerState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PodcastPlayerState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PodcastPlayerState value) $default,){ +final _that = this; +switch (_that) { +case _PodcastPlayerState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PodcastPlayerState value)? $default,){ +final _that = this; +switch (_that) { +case _PodcastPlayerState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String? episodeId, String? loadingEpisodeId, bool isPlaying, bool isBuffering, Duration? duration, double speed)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PodcastPlayerState() when $default != null: +return $default(_that.episodeId,_that.loadingEpisodeId,_that.isPlaying,_that.isBuffering,_that.duration,_that.speed);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String? episodeId, String? loadingEpisodeId, bool isPlaying, bool isBuffering, Duration? duration, double speed) $default,) {final _that = this; +switch (_that) { +case _PodcastPlayerState(): +return $default(_that.episodeId,_that.loadingEpisodeId,_that.isPlaying,_that.isBuffering,_that.duration,_that.speed);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String? episodeId, String? loadingEpisodeId, bool isPlaying, bool isBuffering, Duration? duration, double speed)? $default,) {final _that = this; +switch (_that) { +case _PodcastPlayerState() when $default != null: +return $default(_that.episodeId,_that.loadingEpisodeId,_that.isPlaying,_that.isBuffering,_that.duration,_that.speed);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PodcastPlayerState extends PodcastPlayerState { + const _PodcastPlayerState({this.episodeId, this.loadingEpisodeId, this.isPlaying = false, this.isBuffering = false, this.duration, this.speed = 1.0}): super._(); + + +@override final String? episodeId; +@override final String? loadingEpisodeId; +@override@JsonKey() final bool isPlaying; +@override@JsonKey() final bool isBuffering; +@override final Duration? duration; +@override@JsonKey() final double speed; + +/// Create a copy of PodcastPlayerState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PodcastPlayerStateCopyWith<_PodcastPlayerState> get copyWith => __$PodcastPlayerStateCopyWithImpl<_PodcastPlayerState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PodcastPlayerState&&(identical(other.episodeId, episodeId) || other.episodeId == episodeId)&&(identical(other.loadingEpisodeId, loadingEpisodeId) || other.loadingEpisodeId == loadingEpisodeId)&&(identical(other.isPlaying, isPlaying) || other.isPlaying == isPlaying)&&(identical(other.isBuffering, isBuffering) || other.isBuffering == isBuffering)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.speed, speed) || other.speed == speed)); +} + + +@override +int get hashCode => Object.hash(runtimeType,episodeId,loadingEpisodeId,isPlaying,isBuffering,duration,speed); + +@override +String toString() { + return 'PodcastPlayerState(episodeId: $episodeId, loadingEpisodeId: $loadingEpisodeId, isPlaying: $isPlaying, isBuffering: $isBuffering, duration: $duration, speed: $speed)'; +} + + +} + +/// @nodoc +abstract mixin class _$PodcastPlayerStateCopyWith<$Res> implements $PodcastPlayerStateCopyWith<$Res> { + factory _$PodcastPlayerStateCopyWith(_PodcastPlayerState value, $Res Function(_PodcastPlayerState) _then) = __$PodcastPlayerStateCopyWithImpl; +@override @useResult +$Res call({ + String? episodeId, String? loadingEpisodeId, bool isPlaying, bool isBuffering, Duration? duration, double speed +}); + + + + +} +/// @nodoc +class __$PodcastPlayerStateCopyWithImpl<$Res> + implements _$PodcastPlayerStateCopyWith<$Res> { + __$PodcastPlayerStateCopyWithImpl(this._self, this._then); + + final _PodcastPlayerState _self; + final $Res Function(_PodcastPlayerState) _then; + +/// Create a copy of PodcastPlayerState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? episodeId = freezed,Object? loadingEpisodeId = freezed,Object? isPlaying = null,Object? isBuffering = null,Object? duration = freezed,Object? speed = null,}) { + return _then(_PodcastPlayerState( +episodeId: freezed == episodeId ? _self.episodeId : episodeId // ignore: cast_nullable_to_non_nullable +as String?,loadingEpisodeId: freezed == loadingEpisodeId ? _self.loadingEpisodeId : loadingEpisodeId // ignore: cast_nullable_to_non_nullable +as String?,isPlaying: null == isPlaying ? _self.isPlaying : isPlaying // ignore: cast_nullable_to_non_nullable +as bool,isBuffering: null == isBuffering ? _self.isBuffering : isBuffering // ignore: cast_nullable_to_non_nullable +as bool,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration?,speed: null == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double, + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/podcasts/controllers/player_controller.g.dart b/mobile-app/lib/podcasts/controllers/player_controller.g.dart new file mode 100644 index 000000000..f7b02d2d9 --- /dev/null +++ b/mobile-app/lib/podcasts/controllers/player_controller.g.dart @@ -0,0 +1,98 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastPlaybackPosition) +final podcastPlaybackPositionProvider = PodcastPlaybackPositionProvider._(); + +final class PodcastPlaybackPositionProvider + extends + $FunctionalProvider, Duration, Stream> + with $FutureModifier, $StreamProvider { + PodcastPlaybackPositionProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastPlaybackPositionProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastPlaybackPositionHash(); + + @$internal + @override + $StreamProviderElement $createElement($ProviderPointer pointer) => + $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return podcastPlaybackPosition(ref); + } +} + +String _$podcastPlaybackPositionHash() => + r'16dbcaa698addbf6da17ae7a41caf71524e396c1'; + +@ProviderFor(PodcastPlayerNotifier) +final podcastPlayerProvider = PodcastPlayerNotifierProvider._(); + +final class PodcastPlayerNotifierProvider + extends $NotifierProvider { + PodcastPlayerNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastPlayerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastPlayerNotifierHash(); + + @$internal + @override + PodcastPlayerNotifier create() => PodcastPlayerNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastPlayerState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastPlayerNotifierHash() => + r'd416e8d5664fa7e15b7d048126086d700024ba51'; + +abstract class _$PodcastPlayerNotifier extends $Notifier { + PodcastPlayerState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + PodcastPlayerState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/podcasts/models/episode_model.dart b/mobile-app/lib/podcasts/models/episode_model.dart new file mode 100644 index 000000000..69a47c63f --- /dev/null +++ b/mobile-app/lib/podcasts/models/episode_model.dart @@ -0,0 +1,50 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'episode_model.freezed.dart'; +part 'episode_model.g.dart'; + +@freezed +abstract class Episode with _$Episode { + const factory Episode({ + @JsonKey(name: '_id') required String id, + required String podcastId, + required String title, + @Default('') String description, + DateTime? publicationDate, + required String audioUrl, + @DurationConverter() Duration? duration, + }) = _Episode; + + factory Episode.fromJson(Map json) => + _$EpisodeFromJson(json); +} + +// The API sends duration in whichever shape the source feed used: bare seconds +// ('3390'), 'MM:SS' or 'HH:MM:SS'. On disk it is Duration.toString() +// ('0:56:30.000000'). +class DurationConverter implements JsonConverter { + const DurationConverter(); + + @override + Duration? fromJson(String? json) => + json == null || json.isEmpty || json == 'null' + ? null + : _parseDuration(json); + + @override + String? toJson(Duration? object) => object?.toString(); +} + +Duration _parseDuration(String value) { + final parts = value.split(':'); + final secondsParts = parts.last.split('.'); + + return Duration( + hours: parts.length > 2 ? int.tryParse(parts[parts.length - 3]) ?? 0 : 0, + minutes: parts.length > 1 ? int.tryParse(parts[parts.length - 2]) ?? 0 : 0, + seconds: int.tryParse(secondsParts.first) ?? 0, + microseconds: secondsParts.length > 1 + ? int.tryParse(secondsParts[1]) ?? 0 + : 0, + ); +} diff --git a/mobile-app/lib/podcasts/models/episode_model.freezed.dart b/mobile-app/lib/podcasts/models/episode_model.freezed.dart new file mode 100644 index 000000000..c07077c6d --- /dev/null +++ b/mobile-app/lib/podcasts/models/episode_model.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'episode_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Episode { + +@JsonKey(name: '_id') String get id; String get podcastId; String get title; String get description; DateTime? get publicationDate; String get audioUrl;@DurationConverter() Duration? get duration; +/// Create a copy of Episode +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$EpisodeCopyWith get copyWith => _$EpisodeCopyWithImpl(this as Episode, _$identity); + + /// Serializes this Episode to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Episode&&(identical(other.id, id) || other.id == id)&&(identical(other.podcastId, podcastId) || other.podcastId == podcastId)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.publicationDate, publicationDate) || other.publicationDate == publicationDate)&&(identical(other.audioUrl, audioUrl) || other.audioUrl == audioUrl)&&(identical(other.duration, duration) || other.duration == duration)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,podcastId,title,description,publicationDate,audioUrl,duration); + +@override +String toString() { + return 'Episode(id: $id, podcastId: $podcastId, title: $title, description: $description, publicationDate: $publicationDate, audioUrl: $audioUrl, duration: $duration)'; +} + + +} + +/// @nodoc +abstract mixin class $EpisodeCopyWith<$Res> { + factory $EpisodeCopyWith(Episode value, $Res Function(Episode) _then) = _$EpisodeCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: '_id') String id, String podcastId, String title, String description, DateTime? publicationDate, String audioUrl,@DurationConverter() Duration? duration +}); + + + + +} +/// @nodoc +class _$EpisodeCopyWithImpl<$Res> + implements $EpisodeCopyWith<$Res> { + _$EpisodeCopyWithImpl(this._self, this._then); + + final Episode _self; + final $Res Function(Episode) _then; + +/// Create a copy of Episode +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? podcastId = null,Object? title = null,Object? description = null,Object? publicationDate = freezed,Object? audioUrl = null,Object? duration = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,podcastId: null == podcastId ? _self.podcastId : podcastId // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,publicationDate: freezed == publicationDate ? _self.publicationDate : publicationDate // ignore: cast_nullable_to_non_nullable +as DateTime?,audioUrl: null == audioUrl ? _self.audioUrl : audioUrl // ignore: cast_nullable_to_non_nullable +as String,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Episode]. +extension EpisodePatterns on Episode { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Episode value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Episode() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Episode value) $default,){ +final _that = this; +switch (_that) { +case _Episode(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Episode value)? $default,){ +final _that = this; +switch (_that) { +case _Episode() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: '_id') String id, String podcastId, String title, String description, DateTime? publicationDate, String audioUrl, @DurationConverter() Duration? duration)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Episode() when $default != null: +return $default(_that.id,_that.podcastId,_that.title,_that.description,_that.publicationDate,_that.audioUrl,_that.duration);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: '_id') String id, String podcastId, String title, String description, DateTime? publicationDate, String audioUrl, @DurationConverter() Duration? duration) $default,) {final _that = this; +switch (_that) { +case _Episode(): +return $default(_that.id,_that.podcastId,_that.title,_that.description,_that.publicationDate,_that.audioUrl,_that.duration);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: '_id') String id, String podcastId, String title, String description, DateTime? publicationDate, String audioUrl, @DurationConverter() Duration? duration)? $default,) {final _that = this; +switch (_that) { +case _Episode() when $default != null: +return $default(_that.id,_that.podcastId,_that.title,_that.description,_that.publicationDate,_that.audioUrl,_that.duration);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Episode implements Episode { + const _Episode({@JsonKey(name: '_id') required this.id, required this.podcastId, required this.title, this.description = '', this.publicationDate, required this.audioUrl, @DurationConverter() this.duration}); + factory _Episode.fromJson(Map json) => _$EpisodeFromJson(json); + +@override@JsonKey(name: '_id') final String id; +@override final String podcastId; +@override final String title; +@override@JsonKey() final String description; +@override final DateTime? publicationDate; +@override final String audioUrl; +@override@DurationConverter() final Duration? duration; + +/// Create a copy of Episode +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$EpisodeCopyWith<_Episode> get copyWith => __$EpisodeCopyWithImpl<_Episode>(this, _$identity); + +@override +Map toJson() { + return _$EpisodeToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Episode&&(identical(other.id, id) || other.id == id)&&(identical(other.podcastId, podcastId) || other.podcastId == podcastId)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.publicationDate, publicationDate) || other.publicationDate == publicationDate)&&(identical(other.audioUrl, audioUrl) || other.audioUrl == audioUrl)&&(identical(other.duration, duration) || other.duration == duration)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,podcastId,title,description,publicationDate,audioUrl,duration); + +@override +String toString() { + return 'Episode(id: $id, podcastId: $podcastId, title: $title, description: $description, publicationDate: $publicationDate, audioUrl: $audioUrl, duration: $duration)'; +} + + +} + +/// @nodoc +abstract mixin class _$EpisodeCopyWith<$Res> implements $EpisodeCopyWith<$Res> { + factory _$EpisodeCopyWith(_Episode value, $Res Function(_Episode) _then) = __$EpisodeCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: '_id') String id, String podcastId, String title, String description, DateTime? publicationDate, String audioUrl,@DurationConverter() Duration? duration +}); + + + + +} +/// @nodoc +class __$EpisodeCopyWithImpl<$Res> + implements _$EpisodeCopyWith<$Res> { + __$EpisodeCopyWithImpl(this._self, this._then); + + final _Episode _self; + final $Res Function(_Episode) _then; + +/// Create a copy of Episode +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? podcastId = null,Object? title = null,Object? description = null,Object? publicationDate = freezed,Object? audioUrl = null,Object? duration = freezed,}) { + return _then(_Episode( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,podcastId: null == podcastId ? _self.podcastId : podcastId // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,publicationDate: freezed == publicationDate ? _self.publicationDate : publicationDate // ignore: cast_nullable_to_non_nullable +as DateTime?,audioUrl: null == audioUrl ? _self.audioUrl : audioUrl // ignore: cast_nullable_to_non_nullable +as String,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration?, + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/podcasts/models/episode_model.g.dart b/mobile-app/lib/podcasts/models/episode_model.g.dart new file mode 100644 index 000000000..37be71d0b --- /dev/null +++ b/mobile-app/lib/podcasts/models/episode_model.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'episode_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Episode _$EpisodeFromJson(Map json) => _Episode( + id: json['_id'] as String, + podcastId: json['podcastId'] as String, + title: json['title'] as String, + description: json['description'] as String? ?? '', + publicationDate: json['publicationDate'] == null + ? null + : DateTime.parse(json['publicationDate'] as String), + audioUrl: json['audioUrl'] as String, + duration: const DurationConverter().fromJson(json['duration'] as String?), +); + +Map _$EpisodeToJson(_Episode instance) => { + '_id': instance.id, + 'podcastId': instance.podcastId, + 'title': instance.title, + 'description': instance.description, + 'publicationDate': instance.publicationDate?.toIso8601String(), + 'audioUrl': instance.audioUrl, + 'duration': const DurationConverter().toJson(instance.duration), +}; diff --git a/mobile-app/lib/podcasts/models/podcast_model.dart b/mobile-app/lib/podcasts/models/podcast_model.dart new file mode 100644 index 000000000..2c4d9a47d --- /dev/null +++ b/mobile-app/lib/podcasts/models/podcast_model.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'podcast_model.freezed.dart'; +part 'podcast_model.g.dart'; + +@freezed +abstract class Podcast with _$Podcast { + const factory Podcast({ + @JsonKey(name: '_id') required String id, + required String feedUrl, + required String podcastLink, + required String title, + @Default('') String description, + required String imageLink, + @Default('') String copyright, + @Default(0) int numOfEps, + }) = _Podcast; + + factory Podcast.fromJson(Map json) => + _$PodcastFromJson(json); +} diff --git a/mobile-app/lib/podcasts/models/podcast_model.freezed.dart b/mobile-app/lib/podcasts/models/podcast_model.freezed.dart new file mode 100644 index 000000000..f4cdb2375 --- /dev/null +++ b/mobile-app/lib/podcasts/models/podcast_model.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'podcast_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$Podcast { + +@JsonKey(name: '_id') String get id; String get feedUrl; String get podcastLink; String get title; String get description; String get imageLink; String get copyright; int get numOfEps; +/// Create a copy of Podcast +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PodcastCopyWith get copyWith => _$PodcastCopyWithImpl(this as Podcast, _$identity); + + /// Serializes this Podcast to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is Podcast&&(identical(other.id, id) || other.id == id)&&(identical(other.feedUrl, feedUrl) || other.feedUrl == feedUrl)&&(identical(other.podcastLink, podcastLink) || other.podcastLink == podcastLink)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageLink, imageLink) || other.imageLink == imageLink)&&(identical(other.copyright, copyright) || other.copyright == copyright)&&(identical(other.numOfEps, numOfEps) || other.numOfEps == numOfEps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,feedUrl,podcastLink,title,description,imageLink,copyright,numOfEps); + +@override +String toString() { + return 'Podcast(id: $id, feedUrl: $feedUrl, podcastLink: $podcastLink, title: $title, description: $description, imageLink: $imageLink, copyright: $copyright, numOfEps: $numOfEps)'; +} + + +} + +/// @nodoc +abstract mixin class $PodcastCopyWith<$Res> { + factory $PodcastCopyWith(Podcast value, $Res Function(Podcast) _then) = _$PodcastCopyWithImpl; +@useResult +$Res call({ +@JsonKey(name: '_id') String id, String feedUrl, String podcastLink, String title, String description, String imageLink, String copyright, int numOfEps +}); + + + + +} +/// @nodoc +class _$PodcastCopyWithImpl<$Res> + implements $PodcastCopyWith<$Res> { + _$PodcastCopyWithImpl(this._self, this._then); + + final Podcast _self; + final $Res Function(Podcast) _then; + +/// Create a copy of Podcast +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? feedUrl = null,Object? podcastLink = null,Object? title = null,Object? description = null,Object? imageLink = null,Object? copyright = null,Object? numOfEps = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,feedUrl: null == feedUrl ? _self.feedUrl : feedUrl // ignore: cast_nullable_to_non_nullable +as String,podcastLink: null == podcastLink ? _self.podcastLink : podcastLink // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,imageLink: null == imageLink ? _self.imageLink : imageLink // ignore: cast_nullable_to_non_nullable +as String,copyright: null == copyright ? _self.copyright : copyright // ignore: cast_nullable_to_non_nullable +as String,numOfEps: null == numOfEps ? _self.numOfEps : numOfEps // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [Podcast]. +extension PodcastPatterns on Podcast { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _Podcast value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _Podcast() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _Podcast value) $default,){ +final _that = this; +switch (_that) { +case _Podcast(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _Podcast value)? $default,){ +final _that = this; +switch (_that) { +case _Podcast() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function(@JsonKey(name: '_id') String id, String feedUrl, String podcastLink, String title, String description, String imageLink, String copyright, int numOfEps)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _Podcast() when $default != null: +return $default(_that.id,_that.feedUrl,_that.podcastLink,_that.title,_that.description,_that.imageLink,_that.copyright,_that.numOfEps);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function(@JsonKey(name: '_id') String id, String feedUrl, String podcastLink, String title, String description, String imageLink, String copyright, int numOfEps) $default,) {final _that = this; +switch (_that) { +case _Podcast(): +return $default(_that.id,_that.feedUrl,_that.podcastLink,_that.title,_that.description,_that.imageLink,_that.copyright,_that.numOfEps);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@JsonKey(name: '_id') String id, String feedUrl, String podcastLink, String title, String description, String imageLink, String copyright, int numOfEps)? $default,) {final _that = this; +switch (_that) { +case _Podcast() when $default != null: +return $default(_that.id,_that.feedUrl,_that.podcastLink,_that.title,_that.description,_that.imageLink,_that.copyright,_that.numOfEps);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _Podcast implements Podcast { + const _Podcast({@JsonKey(name: '_id') required this.id, required this.feedUrl, required this.podcastLink, required this.title, this.description = '', required this.imageLink, this.copyright = '', this.numOfEps = 0}); + factory _Podcast.fromJson(Map json) => _$PodcastFromJson(json); + +@override@JsonKey(name: '_id') final String id; +@override final String feedUrl; +@override final String podcastLink; +@override final String title; +@override@JsonKey() final String description; +@override final String imageLink; +@override@JsonKey() final String copyright; +@override@JsonKey() final int numOfEps; + +/// Create a copy of Podcast +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PodcastCopyWith<_Podcast> get copyWith => __$PodcastCopyWithImpl<_Podcast>(this, _$identity); + +@override +Map toJson() { + return _$PodcastToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Podcast&&(identical(other.id, id) || other.id == id)&&(identical(other.feedUrl, feedUrl) || other.feedUrl == feedUrl)&&(identical(other.podcastLink, podcastLink) || other.podcastLink == podcastLink)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.imageLink, imageLink) || other.imageLink == imageLink)&&(identical(other.copyright, copyright) || other.copyright == copyright)&&(identical(other.numOfEps, numOfEps) || other.numOfEps == numOfEps)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,feedUrl,podcastLink,title,description,imageLink,copyright,numOfEps); + +@override +String toString() { + return 'Podcast(id: $id, feedUrl: $feedUrl, podcastLink: $podcastLink, title: $title, description: $description, imageLink: $imageLink, copyright: $copyright, numOfEps: $numOfEps)'; +} + + +} + +/// @nodoc +abstract mixin class _$PodcastCopyWith<$Res> implements $PodcastCopyWith<$Res> { + factory _$PodcastCopyWith(_Podcast value, $Res Function(_Podcast) _then) = __$PodcastCopyWithImpl; +@override @useResult +$Res call({ +@JsonKey(name: '_id') String id, String feedUrl, String podcastLink, String title, String description, String imageLink, String copyright, int numOfEps +}); + + + + +} +/// @nodoc +class __$PodcastCopyWithImpl<$Res> + implements _$PodcastCopyWith<$Res> { + __$PodcastCopyWithImpl(this._self, this._then); + + final _Podcast _self; + final $Res Function(_Podcast) _then; + +/// Create a copy of Podcast +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? feedUrl = null,Object? podcastLink = null,Object? title = null,Object? description = null,Object? imageLink = null,Object? copyright = null,Object? numOfEps = null,}) { + return _then(_Podcast( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,feedUrl: null == feedUrl ? _self.feedUrl : feedUrl // ignore: cast_nullable_to_non_nullable +as String,podcastLink: null == podcastLink ? _self.podcastLink : podcastLink // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,imageLink: null == imageLink ? _self.imageLink : imageLink // ignore: cast_nullable_to_non_nullable +as String,copyright: null == copyright ? _self.copyright : copyright // ignore: cast_nullable_to_non_nullable +as String,numOfEps: null == numOfEps ? _self.numOfEps : numOfEps // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/podcasts/models/podcast_model.g.dart b/mobile-app/lib/podcasts/models/podcast_model.g.dart new file mode 100644 index 000000000..8cf1068d8 --- /dev/null +++ b/mobile-app/lib/podcasts/models/podcast_model.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'podcast_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_Podcast _$PodcastFromJson(Map json) => _Podcast( + id: json['_id'] as String, + feedUrl: json['feedUrl'] as String, + podcastLink: json['podcastLink'] as String, + title: json['title'] as String, + description: json['description'] as String? ?? '', + imageLink: json['imageLink'] as String, + copyright: json['copyright'] as String? ?? '', + numOfEps: (json['numOfEps'] as num?)?.toInt() ?? 0, +); + +Map _$PodcastToJson(_Podcast instance) => { + '_id': instance.id, + 'feedUrl': instance.feedUrl, + 'podcastLink': instance.podcastLink, + 'title': instance.title, + 'description': instance.description, + 'imageLink': instance.imageLink, + 'copyright': instance.copyright, + 'numOfEps': instance.numOfEps, +}; diff --git a/mobile-app/lib/podcasts/repositories/api_repository.dart b/mobile-app/lib/podcasts/repositories/api_repository.dart new file mode 100644 index 000000000..1088e1088 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/api_repository.dart @@ -0,0 +1,57 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:freecodecamp/services/dio_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'api_repository.g.dart'; + +typedef RawEpisodesPage = ({ + Map podcast, + List> episodes, +}); + +const _prodUrl = 'https://api.mobile.freecodecamp.org'; +const _devUrl = 'https://api.mobile.freecodecamp.dev'; + +@riverpod +PodcastApiRepository podcastApiRepository(Ref ref) => + PodcastApiRepository(ref.watch(dioProvider)); + +class PodcastApiRepository { + PodcastApiRepository(this._dio); + + final Dio _dio; + + String get _baseUrl => + dotenv.getBool('DEVELOPMENT_MODE', fallback: false) ? _devUrl : _prodUrl; + + Future>> getPodcasts() async { + final response = await _dio.get>('$_baseUrl/podcasts'); + + return [ + for (final entry in response.data ?? const []) + if (entry is Map) Map.from(entry), + ]; + } + + Future getEpisodes(String podcastId, {int page = 0}) async { + final response = await _dio.get>( + '$_baseUrl/podcasts/$podcastId/episodes', + queryParameters: {'page': page}, + ); + + final data = response.data ?? const {}; + final podcast = data['podcast']; + final episodes = data['episodes']; + + return ( + podcast: podcast is Map + ? Map.from(podcast) + : {}, + episodes: [ + for (final entry in episodes is List ? episodes : const []) + if (entry is Map) Map.from(entry), + ], + ); + } +} diff --git a/mobile-app/lib/podcasts/repositories/api_repository.g.dart b/mobile-app/lib/podcasts/repositories/api_repository.g.dart new file mode 100644 index 000000000..396a41d2e --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/api_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastApiRepository) +final podcastApiRepositoryProvider = PodcastApiRepositoryProvider._(); + +final class PodcastApiRepositoryProvider + extends + $FunctionalProvider< + PodcastApiRepository, + PodcastApiRepository, + PodcastApiRepository + > + with $Provider { + PodcastApiRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastApiRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastApiRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastApiRepository create(Ref ref) { + return podcastApiRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastApiRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastApiRepositoryHash() => + r'51754e685b2b3785c13cf95ce173845a660ff6bc'; diff --git a/mobile-app/lib/podcasts/repositories/download_repository.dart b/mobile-app/lib/podcasts/repositories/download_repository.dart new file mode 100644 index 000000000..6ce25af81 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/download_repository.dart @@ -0,0 +1,202 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:freecodecamp/services/dio_service.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'download_repository.g.dart'; + +// NOTE: Folder structure +// ApplicationDocumentsDirectory/ +// episodes/ +// / +// .mp3 +// images/ +// podcast/ +// .jpg + +const _episodesDirName = 'episodes'; +const _artworkDirName = 'podcast'; + +@riverpod +PodcastDownloadRepository podcastDownloadRepository(Ref ref) => + PodcastDownloadRepository(ref.watch(dioProvider)); + +class PodcastDownloadRepository { + PodcastDownloadRepository(this._dio); + + final Dio _dio; + + Directory? _documents; + + Future _documentsDir() async { + final cached = _documents; + if (cached != null) return cached; + + return _documents = await getApplicationDocumentsDirectory(); + } + + Future _episodesRoot() async => + Directory(path.join((await _documentsDir()).path, _episodesDirName)); + + Future _artworkRoot() async => Directory( + path.join((await _documentsDir()).path, 'images', _artworkDirName), + ); + + Future episodeFile(String podcastId, String episodeId) async => File( + path.join((await _episodesRoot()).path, podcastId, '$episodeId.mp3'), + ); + + Future artworkFile(String podcastId) async => + File(path.join((await _artworkRoot()).path, '$podcastId.jpg')); + + Future> existingArtworkFiles( + Iterable podcastIds, + ) async { + final files = {}; + + for (final podcastId in podcastIds) { + final file = await artworkFile(podcastId); + if (await file.exists()) files[podcastId] = file; + } + + return files; + } + + Future downloadEpisode({ + required String podcastId, + required String episodeId, + required String url, + CancelToken? cancelToken, + void Function(double progress)? onProgress, + }) async { + final file = await episodeFile(podcastId, episodeId); + await file.parent.create(recursive: true); + + await _downloadTo( + file, + url, + cancelToken: cancelToken, + onProgress: onProgress, + ); + } + + Future saveArtwork(String podcastId, String imageUrl) async { + if (imageUrl.isEmpty) return; + + final file = await artworkFile(podcastId); + if (await file.exists()) return; + + await file.parent.create(recursive: true); + + try { + await _downloadTo(file, imageUrl); + } catch (e) { + log('Podcast artwork: cannot fetch for $podcastId ($e)'); + } + } + + Future _downloadTo( + File destination, + String url, { + CancelToken? cancelToken, + void Function(double progress)? onProgress, + }) async { + await _dio.download( + url, + destination.path, + cancelToken: cancelToken, + deleteOnError: true, + onReceiveProgress: (received, total) { + if (total > 0) onProgress?.call(received / total); + }, + ); + } + + Future deleteEpisodeFile(String podcastId, String episodeId) async { + final file = await episodeFile(podcastId, episodeId); + if (await file.exists()) await file.delete(); + } + + Future deleteArtwork(String podcastId) async { + final file = await artworkFile(podcastId); + if (await file.exists()) await file.delete(); + } + + Future<({int count, int bytes})> deleteOrphans( + Map> expectedEpisodeIdsByPodcastId, + ) async { + final expected = {}; + + for (final MapEntry(key: podcastId, value: episodeIds) + in expectedEpisodeIdsByPodcastId.entries) { + expected.add((await artworkFile(podcastId)).path); + + for (final episodeId in episodeIds) { + expected.add((await episodeFile(podcastId, episodeId)).path); + } + } + + final orphans = [ + for (final root in [_episodesRoot(), _artworkRoot()]) + for (final file in await _listFiles(root)) + if (!expected.contains(file.path)) file, + ]; + + if (orphans.isEmpty) return (count: 0, bytes: 0); + + final bytes = await _deleteFiles(orphans); + await _removeEmptyPodcastDirectories(); + + return (count: orphans.length, bytes: bytes); + } + + Future> _listFiles(Future root) async { + final directory = await root; + if (!await directory.exists()) return []; + + try { + return await directory + .list(recursive: true) + .where((entity) => entity is File) + .cast() + .toList(); + } catch (e) { + log('Podcast cleanup: cannot list ${directory.path} ($e)'); + rethrow; + } + } + + Future _deleteFiles(Iterable files) async { + var bytes = 0; + + for (final file in files) { + try { + bytes += await file.length(); + await file.delete(); + } catch (e) { + log('Podcast cleanup: cannot delete ${file.path} ($e)'); + } + } + + return bytes; + } + + Future _removeEmptyPodcastDirectories() async { + final root = await _episodesRoot(); + if (!await root.exists()) return; + + await for (final entry in root.list()) { + if (entry is! Directory) continue; + + try { + if (await entry.list().isEmpty) await entry.delete(); + } catch (e) { + log('Podcast cleanup: cannot remove ${entry.path} ($e)'); + } + } + } +} diff --git a/mobile-app/lib/podcasts/repositories/download_repository.g.dart b/mobile-app/lib/podcasts/repositories/download_repository.g.dart new file mode 100644 index 000000000..becc54084 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/download_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'download_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastDownloadRepository) +final podcastDownloadRepositoryProvider = PodcastDownloadRepositoryProvider._(); + +final class PodcastDownloadRepositoryProvider + extends + $FunctionalProvider< + PodcastDownloadRepository, + PodcastDownloadRepository, + PodcastDownloadRepository + > + with $Provider { + PodcastDownloadRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastDownloadRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastDownloadRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastDownloadRepository create(Ref ref) { + return podcastDownloadRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastDownloadRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastDownloadRepositoryHash() => + r'193b2f76ef7fed2fd55ec1ad0f8801b94f189373'; diff --git a/mobile-app/lib/podcasts/repositories/progress_repository.dart b/mobile-app/lib/podcasts/repositories/progress_repository.dart new file mode 100644 index 000000000..052115a0f --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/progress_repository.dart @@ -0,0 +1,37 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'progress_repository.g.dart'; + +@riverpod +PodcastProgressRepository podcastProgressRepository(Ref ref) => + PodcastProgressRepository(); + +class PodcastProgressRepository { + static String _positionKey(String episodeId) => '${episodeId}_progress'; + + static const _recentKey = 'podcast_recent_episodes'; + + Future readPosition(String episodeId) async => + (await SharedPreferences.getInstance()).getInt(_positionKey(episodeId)); + + Future writePosition(String episodeId, int seconds) async => + (await SharedPreferences.getInstance()).setInt( + _positionKey(episodeId), + seconds, + ); + + Future clearPositions(Iterable episodeIds) async { + final prefs = await SharedPreferences.getInstance(); + for (final id in episodeIds) { + await prefs.remove(_positionKey(id)); + } + } + + Future> readRecentEpisodeIds() async => + (await SharedPreferences.getInstance()).getStringList(_recentKey) ?? + const []; + + Future writeRecentEpisodeIds(List ids) async => + (await SharedPreferences.getInstance()).setStringList(_recentKey, ids); +} diff --git a/mobile-app/lib/podcasts/repositories/progress_repository.g.dart b/mobile-app/lib/podcasts/repositories/progress_repository.g.dart new file mode 100644 index 000000000..76149a679 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/progress_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'progress_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastProgressRepository) +final podcastProgressRepositoryProvider = PodcastProgressRepositoryProvider._(); + +final class PodcastProgressRepositoryProvider + extends + $FunctionalProvider< + PodcastProgressRepository, + PodcastProgressRepository, + PodcastProgressRepository + > + with $Provider { + PodcastProgressRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastProgressRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastProgressRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastProgressRepository create(Ref ref) { + return podcastProgressRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastProgressRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastProgressRepositoryHash() => + r'a8f9cbfc928b77ef4a7c73639a57ba9dfa9628c1'; diff --git a/mobile-app/lib/podcasts/repositories/storage_repository.dart b/mobile-app/lib/podcasts/repositories/storage_repository.dart new file mode 100644 index 000000000..eacee13c3 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/storage_repository.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'storage_repository.g.dart'; + +typedef PodcastStoreEntry = Map; + +typedef StoredPodcast = ({ + PodcastStoreEntry podcast, + List episodes, +}); + +// `readable` is false when the file is absent or could not be parsed. The +// orphan sweep must not treat either as "no downloads" +typedef PodcastStoreSnapshot = ({List podcasts, bool readable}); + +typedef PodcastStoreIds = ({ + Map> episodeIdsByPodcastId, + bool readable, +}); + +const _fileName = 'podcast-downloads.json'; + +// Past versions +// 1 - { + 'id': '_id', + 'url': 'feedUrl', + 'link': 'podcastLink', + 'image': 'imageLink', + 'numEps': 'numOfEps', +}; + +const _legacyEpisodeKeys = { + 'id': '_id', + 'contentUrl': 'audioUrl', +}; + +const _episodesKey = 'episodes'; +const _idKey = '_id'; + +// NOTE: One instance per process, so every write shares one queue. +@Riverpod(keepAlive: true) +PodcastStorageRepository podcastStorageRepository(Ref ref) => + PodcastStorageRepository(); + +class PodcastStorageRepository { + File? _file; + + Future _resolveFile() async { + final baseDir = await getApplicationDocumentsDirectory(); + return _file ??= File(path.join(baseDir.path, 'storage', _fileName)); + } + + Future readPodcasts() => _serialized(_read); + + Future readIds() => _serialized(_readIds); + + Future containsEpisode({ + required String podcastId, + required String episodeId, + }) { + return _serialized(() async { + for (final row in (await _read()).podcasts) { + if (row.podcast[_idKey] != podcastId) continue; + + return row.episodes.any((raw) => raw[_idKey] == episodeId); + } + + return false; + }); + } + + Future upsertEpisode({ + required PodcastStoreEntry podcast, + required PodcastStoreEntry episode, + }) { + final podcastId = podcast[_idKey]; + final episodeId = episode[_idKey]; + + return _mutate((podcasts) { + final index = podcasts.indexWhere( + (row) => row.podcast[_idKey] == podcastId, + ); + + final episodes = index == -1 + ? [] + : List.from(podcasts[index].episodes); + + episodes + ..removeWhere((raw) => raw[_idKey] == episodeId) + ..add(episode); + + final row = (podcast: podcast, episodes: episodes); + + if (index == -1) { + podcasts.add(row); + } else { + podcasts[index] = row; + } + }); + } + + // NOTE: Returns whether that was the podcast's last episode + Future removeEpisode({ + required String podcastId, + required String episodeId, + }) { + return _mutate((podcasts) { + final index = podcasts.indexWhere( + (row) => row.podcast[_idKey] == podcastId, + ); + if (index == -1) return false; + + final episodes = List.from(podcasts[index].episodes) + ..removeWhere((raw) => raw[_idKey] == episodeId); + + if (episodes.isEmpty) { + podcasts.removeAt(index); + return true; + } + + podcasts[index] = (podcast: podcasts[index].podcast, episodes: episodes); + return false; + }); + } + + Future _read() async { + final entries = await _decode(); + if (entries == null) { + return (podcasts: const [], readable: false); + } + + return ( + podcasts: [for (final entry in entries) _split(entry)], + readable: true, + ); + } + + Future?> _decode() async { + final file = await _resolveFile(); + if (!await file.exists()) return null; + + try { + final decoded = jsonDecode(await file.readAsString()); + if (decoded is Map && decoded['podcasts'] is List) { + final isLegacy = decoded['version'] != _storeVersion; + + return [ + for (final entry in decoded['podcasts'] as List) + if (entry is Map) + isLegacy + ? _upgradeToV2(PodcastStoreEntry.from(entry)) + : PodcastStoreEntry.from(entry), + ]; + } + log('Podcast downloads: unexpected shape in ${file.path}'); + } catch (e) { + log('Podcast downloads: cannot read ${file.path} ($e)'); + } + + return null; + } + + Future _readIds() async { + const empty = >{}; + + final snapshot = await _read(); + if (!snapshot.readable) { + return (episodeIdsByPodcastId: empty, readable: false); + } + + final ids = >{}; + + for (final row in snapshot.podcasts) { + final podcastId = row.podcast[_idKey]; + if (podcastId is! String || podcastId.isEmpty) { + log('Podcast downloads: row without an id'); + return (episodeIdsByPodcastId: empty, readable: false); + } + + final episodeIds = {}; + + for (final raw in row.episodes) { + final episodeId = raw[_idKey]; + if (episodeId is! String || episodeId.isEmpty) { + log('Podcast downloads: episode without an id'); + return (episodeIdsByPodcastId: empty, readable: false); + } + + episodeIds.add(episodeId); + } + + ids[podcastId] = episodeIds; + } + + return (episodeIdsByPodcastId: ids, readable: true); + } + + StoredPodcast _split(PodcastStoreEntry entry) { + final episodes = entry[_episodesKey]; + + return ( + podcast: {...entry}..remove(_episodesKey), + episodes: [ + for (final raw in episodes is List ? episodes : const []) + if (raw is Map) PodcastStoreEntry.from(raw), + ], + ); + } + + PodcastStoreEntry _join(StoredPodcast row) => { + ...row.podcast, + _episodesKey: row.episodes, + }; + + PodcastStoreEntry _upgradeToV2(PodcastStoreEntry entry) { + final episodes = entry[_episodesKey]; + + return { + ..._renameKeys(entry, _legacyPodcastKeys), + if (episodes is List) + _episodesKey: [ + for (final raw in episodes) + if (raw is Map) + _renameKeys(PodcastStoreEntry.from(raw), _legacyEpisodeKeys), + ], + }; + } + + PodcastStoreEntry _renameKeys( + PodcastStoreEntry entry, + Map renames, + ) { + return { + for (final MapEntry(:key, :value) in entry.entries) + (renames[key] ?? key): value, + }; + } + + Future _mutate(R Function(List podcasts) updater) { + return _serialized(() async { + final podcasts = List.from((await _read()).podcasts); + final result = updater(podcasts); + + await _write(podcasts); + + return result; + }); + } + + Future _write(List podcasts) async { + final file = await _resolveFile(); + await file.parent.create(recursive: true); + + final temp = File('${file.path}.tmp'); + await temp.writeAsString( + jsonEncode({ + 'version': _storeVersion, + 'podcasts': [for (final row in podcasts) _join(row)], + }), + flush: true, + ); + await temp.rename(file.path); + } + + Future _tail = Future.value(); + + Future _serialized(Future Function() action) { + final next = _tail.then((_) => action()); + _tail = next.then((_) {}, onError: (_) {}); + return next; + } +} diff --git a/mobile-app/lib/podcasts/repositories/storage_repository.g.dart b/mobile-app/lib/podcasts/repositories/storage_repository.g.dart new file mode 100644 index 000000000..2cc6ab1d9 --- /dev/null +++ b/mobile-app/lib/podcasts/repositories/storage_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'storage_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastStorageRepository) +final podcastStorageRepositoryProvider = PodcastStorageRepositoryProvider._(); + +final class PodcastStorageRepositoryProvider + extends + $FunctionalProvider< + PodcastStorageRepository, + PodcastStorageRepository, + PodcastStorageRepository + > + with $Provider { + PodcastStorageRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastStorageRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastStorageRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastStorageRepository create(Ref ref) { + return podcastStorageRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastStorageRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastStorageRepositoryHash() => + r'159018925645b4d01ae8dd260c96133884d73571'; diff --git a/mobile-app/lib/podcasts/services/api_service.dart b/mobile-app/lib/podcasts/services/api_service.dart new file mode 100644 index 000000000..a4fce29a7 --- /dev/null +++ b/mobile-app/lib/podcasts/services/api_service.dart @@ -0,0 +1,68 @@ +import 'dart:developer'; + +import 'package:freecodecamp/podcasts/constants.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/repositories/api_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'api_service.g.dart'; + +typedef EpisodesPage = ({ + Podcast podcast, + List episodes, + bool hasMore, +}); + +const _episodesPerPage = 20; + +@riverpod +PodcastApiService podcastApiService(Ref ref) { + final repo = ref.watch(podcastApiRepositoryProvider); + return PodcastApiService(repo); +} + +class PodcastApiService { + final PodcastApiRepository _repo; + + PodcastApiService(this._repo); + + Future> getPodcasts() async { + final podcasts = [ + for (final raw in await _repo.getPodcasts()) ?_toPodcast(raw), + ]; + + return [ + ...podcasts.where((podcast) => fccPodcastUrls.contains(podcast.feedUrl)), + ...podcasts.where((podcast) => !fccPodcastUrls.contains(podcast.feedUrl)), + ]; + } + + Podcast? _toPodcast(Map raw) { + try { + return Podcast.fromJson(raw); + } catch (e) { + log('Podcast ${raw['_id']} is missing required fields; skipping ($e)'); + return null; + } + } + + Future getEpisodes(String podcastId, {int page = 0}) async { + final raw = await _repo.getEpisodes(podcastId, page: page); + + final playable = raw.episodes.where((episode) { + final url = episode['audioUrl']; + if (url is String && url.isNotEmpty) return true; + + // TODO: Capture in Crashlytics so we can fix by adding the missing audioUrl to the API + log('Podcast episode ${episode['_id']} has no audioUrl; skipping'); + return false; + }); + + return ( + podcast: Podcast.fromJson(raw.podcast), + episodes: playable.map(Episode.fromJson).toList(), + hasMore: raw.episodes.length >= _episodesPerPage, + ); + } +} diff --git a/mobile-app/lib/podcasts/services/api_service.g.dart b/mobile-app/lib/podcasts/services/api_service.g.dart new file mode 100644 index 000000000..a85a7947b --- /dev/null +++ b/mobile-app/lib/podcasts/services/api_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'api_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastApiService) +final podcastApiServiceProvider = PodcastApiServiceProvider._(); + +final class PodcastApiServiceProvider + extends + $FunctionalProvider< + PodcastApiService, + PodcastApiService, + PodcastApiService + > + with $Provider { + PodcastApiServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastApiServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastApiServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastApiService create(Ref ref) { + return podcastApiService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastApiService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastApiServiceHash() => r'b94a43282e9a2601f8863cbf964d9f7bbd827535'; diff --git a/mobile-app/lib/podcasts/services/library_service.dart b/mobile-app/lib/podcasts/services/library_service.dart new file mode 100644 index 000000000..308cae5a8 --- /dev/null +++ b/mobile-app/lib/podcasts/services/library_service.dart @@ -0,0 +1,146 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/repositories/download_repository.dart'; +import 'package:freecodecamp/podcasts/repositories/storage_repository.dart'; +import 'package:freecodecamp/services/notification_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'library_service.g.dart'; + +typedef PodcastLibrary = ({ + List podcasts, + Map> episodesByPodcastId, + Map artworkByPodcastId, +}); + +@riverpod +PodcastLibraryService podcastLibraryService(Ref ref) { + return PodcastLibraryService( + ref.watch(podcastStorageRepositoryProvider), + ref.watch(podcastDownloadRepositoryProvider), + ref.watch(notificationServiceProvider), + ); +} + +class PodcastLibraryService { + PodcastLibraryService(this._repo, this._files, this._notifications); + + final PodcastStorageRepository _repo; + final PodcastDownloadRepository _files; + final NotificationService _notifications; + + Future download( + Podcast podcast, + Episode episode, { + CancelToken? cancelToken, + void Function(double progress)? onProgress, + }) async { + await _files.downloadEpisode( + podcastId: episode.podcastId, + episodeId: episode.id, + url: episode.audioUrl, + cancelToken: cancelToken, + onProgress: onProgress, + ); + + await _files.saveArtwork(podcast.id, podcast.imageLink); + + if (cancelToken?.isCancelled ?? false) { + await _files.deleteEpisodeFile(episode.podcastId, episode.id); + return; + } + + await _repo.upsertEpisode( + podcast: podcast.toJson(), + episode: episode.toJson(), + ); + + await _notifications.showNotification( + 'Download complete', + '${podcast.title} - ${episode.title}', + ); + } + + Future getLibrary() async { + final snapshot = await _repo.readPodcasts(); + + final podcasts = []; + final episodesByPodcastId = >{}; + + for (final row in snapshot.podcasts) { + final podcast = _toPodcast(row.podcast); + if (podcast == null) continue; + + final episodes = [for (final raw in row.episodes) ?_toEpisode(raw)]; + + podcasts.add(podcast); + episodesByPodcastId[podcast.id] = episodes; + } + + return ( + podcasts: podcasts, + episodesByPodcastId: episodesByPodcastId, + artworkByPodcastId: await _files.existingArtworkFiles( + podcasts.map((podcast) => podcast.id), + ), + ); + } + + Future removeEpisode(String podcastId, String episodeId) async { + final podcastEmptied = await _repo.removeEpisode( + podcastId: podcastId, + episodeId: episodeId, + ); + + await _files.deleteEpisodeFile(podcastId, episodeId); + if (podcastEmptied) await _files.deleteArtwork(podcastId); + } + + // NOTE: Deletes audio and artwork on disk that the store has no row for. + // Runs unattended at startup, so a failure here must stay contained. + Future sweepOrphanedFiles() async { + try { + final store = await _repo.readIds(); + + if (!store.readable) { + log('Podcast cleanup: store unreadable or incomplete, skipping sweep'); + return; + } + + final removed = await _files.deleteOrphans(store.episodeIdsByPodcastId); + if (removed.count == 0) return; + + log( + 'Podcast cleanup: removed ${removed.count} orphaned files ' + '(${(removed.bytes / 1024 / 1024).toStringAsFixed(1)} MB)', + ); + } catch (e) { + log('Podcast cleanup: sweep failed ($e)'); + } + } + + // Tolerant by design: this is user data that predates v8, and one unreadable + // row should cost that row rather than the whole downloads list. The models + // throw on a missing required field, so the skip happens here. + Podcast? _toPodcast(PodcastStoreEntry entry) { + try { + return Podcast.fromJson(entry); + } catch (e) { + log('Podcast downloads: skipping unreadable podcast row ($e)'); + return null; + } + } + + Episode? _toEpisode(Map raw) { + try { + return Episode.fromJson(raw); + } catch (e) { + log('Podcast downloads: skipping unreadable episode row ($e)'); + return null; + } + } +} diff --git a/mobile-app/lib/podcasts/services/library_service.g.dart b/mobile-app/lib/podcasts/services/library_service.g.dart new file mode 100644 index 000000000..9d1c1e59c --- /dev/null +++ b/mobile-app/lib/podcasts/services/library_service.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'library_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastLibraryService) +final podcastLibraryServiceProvider = PodcastLibraryServiceProvider._(); + +final class PodcastLibraryServiceProvider + extends + $FunctionalProvider< + PodcastLibraryService, + PodcastLibraryService, + PodcastLibraryService + > + with $Provider { + PodcastLibraryServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastLibraryServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastLibraryServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastLibraryService create(Ref ref) { + return podcastLibraryService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastLibraryService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastLibraryServiceHash() => + r'aea6f426a719db3df28934b955b43ca51c7c041d'; diff --git a/mobile-app/lib/podcasts/services/playback_service.dart b/mobile-app/lib/podcasts/services/playback_service.dart new file mode 100644 index 000000000..10b44862a --- /dev/null +++ b/mobile-app/lib/podcasts/services/playback_service.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:audio_service/audio_service.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/repositories/download_repository.dart'; +import 'package:freecodecamp/podcasts/repositories/progress_repository.dart'; +import 'package:freecodecamp/podcasts/repositories/storage_repository.dart'; +import 'package:freecodecamp/services/audio_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'playback_service.g.dart'; + +typedef EpisodePlaybackUris = ({Uri audio, Uri? artwork}); + +const _writeInterval = Duration(seconds: 5); + +const _recentEpisodeLimit = 10; + +// NOTE: Below this, an episode restarts rather than spending a slot on a tap. +const _minimumSavedPosition = Duration(seconds: 30); + +// NOTE: Above this, an episode is considered complete and the position is cleared. +const _completionMargin = Duration(seconds: 30); + +@riverpod +PodcastPlaybackService podcastPlaybackService(Ref ref) { + final service = PodcastPlaybackService( + ref.watch(audioHandlerProvider), + ref.watch(podcastProgressRepositoryProvider), + ref.watch(podcastStorageRepositoryProvider), + ref.watch(podcastDownloadRepositoryProvider), + ); + + ref.onDispose(service.dispose); + service.start(); + + return service; +} + +class PodcastPlaybackService { + PodcastPlaybackService(this._handler, this._repo, this._store, this._files); + + final AudioPlayerHandler _handler; + final PodcastProgressRepository _repo; + final PodcastStorageRepository _store; + final PodcastDownloadRepository _files; + + StreamSubscription? _positionSub; + StreamSubscription? _stateSub; + + String? _episodeId; + Duration _position = Duration.zero; + Duration? _duration; + Duration _lastWritten = Duration.zero; + bool _wasPlaying = false; + + void start() { + _positionSub ??= _handler.positionStream.listen(_onPosition); + _stateSub ??= _handler.playbackState.listen(_onPlaybackState); + } + + Future readPosition(String episodeId) async { + final seconds = await _repo.readPosition(episodeId); + return seconds == null ? null : Duration(seconds: seconds); + } + + Future playbackUris( + Podcast podcast, + Episode episode, + ) async { + final isDownloaded = await _store.containsEpisode( + podcastId: episode.podcastId, + episodeId: episode.id, + ); + + return ( + audio: await _audioSource(episode, isDownloaded: isDownloaded), + artwork: await _artworkSource(podcast, isDownloaded: isDownloaded), + ); + } + + Future _audioSource( + Episode episode, { + required bool isDownloaded, + }) async { + if (isDownloaded) { + final file = await _files.episodeFile(episode.podcastId, episode.id); + + if (await file.exists()) return file.uri; + + log( + 'Podcast playback: ${episode.id} marked downloaded but file is missing', + ); + } + + return Uri.parse(episode.audioUrl); + } + + Future _artworkSource( + Podcast podcast, { + required bool isDownloaded, + }) async { + if (isDownloaded) { + final file = await _files.artworkFile(podcast.id); + + if (await file.exists()) return file.uri; + } + + return podcast.imageLink.isEmpty ? null : Uri.parse(podcast.imageLink); + } + + void _onPosition(Duration position) { + final episodeId = _handler.episodeId; + + if (episodeId == null) return; + + if (episodeId != _episodeId) { + _episodeId = episodeId; + _lastWritten = Duration.zero; + } + + _position = position; + _duration = _handler.duration; + + if ((position - _lastWritten).abs() < _writeInterval) return; + + _lastWritten = position; + unawaited(_savePosition(episodeId, position, _duration)); + } + + void _onPlaybackState(PlaybackState state) { + final wasPlaying = _wasPlaying; + _wasPlaying = state.playing; + + if (!wasPlaying || state.playing) return; + + final episodeId = _episodeId; + if (episodeId == null) return; + + _lastWritten = _position; + unawaited(_savePosition(episodeId, _position, _duration)); + } + + Future _savePosition( + String episodeId, + Duration position, + Duration? total, + ) async { + if (total != null && + total > Duration.zero && + position >= total - _completionMargin) { + return _clearPosition(episodeId); + } + + if (position < _minimumSavedPosition) return; + + await _repo.writePosition(episodeId, position.inSeconds); + await _markRecentlyPlayed(episodeId); + } + + Future _clearPosition(String episodeId) async { + await _repo.clearPositions([episodeId]); + + final recent = await _repo.readRecentEpisodeIds(); + if (!recent.contains(episodeId)) return; + + await _repo.writeRecentEpisodeIds( + recent.where((id) => id != episodeId).toList(), + ); + } + + Future _markRecentlyPlayed(String episodeId) async { + final recent = await _repo.readRecentEpisodeIds(); + + if (recent.isNotEmpty && recent.last == episodeId) return; + + final updated = [...recent.where((id) => id != episodeId), episodeId]; + + if (updated.length > _recentEpisodeLimit) { + final evicted = updated.sublist(0, updated.length - _recentEpisodeLimit); + await _repo.clearPositions(evicted); + updated.removeRange(0, evicted.length); + } + + await _repo.writeRecentEpisodeIds(updated); + } + + void dispose() { + _positionSub?.cancel(); + _stateSub?.cancel(); + _positionSub = null; + _stateSub = null; + } +} diff --git a/mobile-app/lib/podcasts/services/playback_service.g.dart b/mobile-app/lib/podcasts/services/playback_service.g.dart new file mode 100644 index 000000000..3c06f33d4 --- /dev/null +++ b/mobile-app/lib/podcasts/services/playback_service.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'playback_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastPlaybackService) +final podcastPlaybackServiceProvider = PodcastPlaybackServiceProvider._(); + +final class PodcastPlaybackServiceProvider + extends + $FunctionalProvider< + PodcastPlaybackService, + PodcastPlaybackService, + PodcastPlaybackService + > + with $Provider { + PodcastPlaybackServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastPlaybackServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastPlaybackServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + PodcastPlaybackService create(Ref ref) { + return podcastPlaybackService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(PodcastPlaybackService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$podcastPlaybackServiceHash() => + r'fdba54b5176077af6f9dafafb320aa44c3ef7223'; diff --git a/mobile-app/lib/podcasts/ui/downloads/downloads_view.dart b/mobile-app/lib/podcasts/ui/downloads/downloads_view.dart new file mode 100644 index 000000000..c029063dc --- /dev/null +++ b/mobile-app/lib/podcasts/ui/downloads/downloads_view.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/controllers/library_controller.dart'; +import 'package:freecodecamp/podcasts/ui/widgets/podcast_grid_tile.dart'; +import 'package:freecodecamp/routing/podcasts.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class PodcastDownloadsView extends ConsumerWidget { + const PodcastDownloadsView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final library = ref.watch(podcastLibraryProvider); + + return ColoredBox( + color: FccColors.gray80, + child: library.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load downloads.', + onRetry: () => ref.invalidate(podcastLibraryProvider), + ), + data: (library) => library.podcasts.isEmpty + ? const _NoDownloads() + : GridView.count( + crossAxisCount: 2, + childAspectRatio: 1, + children: [ + for (final podcast in library.podcasts) + PodcastGridTile( + key: ValueKey(podcast.id), + podcast: podcast, + artwork: library.artworkByPodcastId[podcast.id], + onTap: () => PodcastEpisodeListRoute( + podcastId: podcast.id, + downloaded: true, + ).push(context), + ), + ], + ), + ), + ); + } +} + +class _NoDownloads extends StatelessWidget { + const _NoDownloads(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.arrow_circle_down_sharp, color: Colors.white, size: 50), + SizedBox(height: 4), + Text( + 'No downloads', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/episode/episode_view.dart b/mobile-app/lib/podcasts/ui/episode/episode_view.dart new file mode 100644 index 000000000..7729202ff --- /dev/null +++ b/mobile-app/lib/podcasts/ui/episode/episode_view.dart @@ -0,0 +1,298 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/constants.dart'; +import 'package:freecodecamp/podcasts/controllers/player_controller.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/ui/widgets/download_button.dart'; +import 'package:freecodecamp/widgets/html_handler/html_handler.dart'; + +class PodcastEpisodeView extends StatefulWidget { + const PodcastEpisodeView({ + super.key, + required this.podcast, + required this.episode, + }); + + final Podcast podcast; + final Episode episode; + + @override + State createState() => _PodcastEpisodeViewState(); +} + +class _PodcastEpisodeViewState extends State { + List? _description; + + bool _isScrubbing = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: ListView( + physics: _isScrubbing ? const NeverScrollableScrollPhysics() : null, + children: [ + _Artwork(imageUrl: widget.podcast.imageLink), + Padding( + padding: const EdgeInsets.all(8), + child: Text( + widget.episode.title, + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20), + ), + ), + Text( + widget.podcast.title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16), + ), + _Scrubber( + episode: widget.episode, + onScrubbingChanged: (value) => setState(() => _isScrubbing = value), + ), + _Controls(podcast: widget.podcast, episode: widget.episode), + Padding( + padding: const EdgeInsets.fromLTRB(8, 16, 8, 8), + child: Column(children: _parsedDescription()), + ), + ], + ), + ); + } + + List _parsedDescription() { + if (widget.episode.description.isEmpty) return const []; + + return _description ??= HTMLParser( + context: context, + ).parse('

${widget.episode.description}

'); + } +} + +class _Artwork extends StatelessWidget { + const _Artwork({required this.imageUrl}); + + final String imageUrl; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.4, + ), + child: AspectRatio( + aspectRatio: 1, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 10, + spreadRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: imageUrl.isEmpty + ? const ColoredBox(color: FccColors.gray80) + : CachedNetworkImage(imageUrl: imageUrl, fit: BoxFit.cover), + ), + ), + ), + ); + } +} + +class _Scrubber extends ConsumerStatefulWidget { + const _Scrubber({required this.episode, required this.onScrubbingChanged}); + + final Episode episode; + final ValueChanged onScrubbingChanged; + + @override + ConsumerState<_Scrubber> createState() => _ScrubberState(); +} + +class _ScrubberState extends ConsumerState<_Scrubber> { + Duration? _scrubbing; + + static String _format(Duration value) { + final seconds = value.inSeconds; + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final rest = seconds % 60; + String two(int n) => n.toString().padLeft(2, '0'); + + return hours > 0 + ? '$hours:${two(minutes)}:${two(rest)}' + : '$minutes:${two(rest)}'; + } + + @override + Widget build(BuildContext context) { + final player = ref.watch( + podcastPlayerProvider.select((state) { + final isCurrent = state.isCurrent(widget.episode.id); + + return ( + isCurrent: isCurrent, + duration: isCurrent + ? state.duration ?? widget.episode.duration + : widget.episode.duration, + ); + }), + ); + + final livePosition = player.isCurrent + ? ref.watch(podcastPlaybackPositionProvider).value ?? Duration.zero + : Duration.zero; + + final position = _scrubbing ?? livePosition; + final total = player.duration; + final isSeekable = total != null && total > Duration.zero; + final elapsed = isSeekable && position > total ? total : position; + final canSeek = isSeekable && player.isCurrent; + + return Column( + children: [ + Listener( + onPointerDown: (_) => widget.onScrubbingChanged(true), + onPointerUp: (_) => widget.onScrubbingChanged(false), + onPointerCancel: (_) => widget.onScrubbingChanged(false), + child: Slider( + value: isSeekable + ? elapsed.inMilliseconds / total.inMilliseconds + : 0, + onChanged: canSeek + ? (value) => setState(() => _scrubbing = total * value) + : null, + onChangeEnd: canSeek + ? (value) { + ref + .read(podcastPlayerProvider.notifier) + .seek(total * value); + setState(() => _scrubbing = null); + } + : null, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_format(elapsed), style: const TextStyle(fontSize: 16)), + Text( + isSeekable ? '-${_format(total - elapsed)}' : '--:--', + style: const TextStyle(fontSize: 16), + ), + ], + ), + ), + ], + ); + } +} + +class _Controls extends ConsumerWidget { + const _Controls({required this.podcast, required this.episode}); + + final Podcast podcast; + final Episode episode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch( + podcastPlayerProvider.select( + (state) => ( + isCurrent: state.isCurrent(episode.id), + isPlaying: state.isPlayingEpisode(episode.id), + isBusy: state.isBusyWith(episode.id), + speed: state.speed, + ), + ), + ); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + DropdownButtonHideUnderline( + child: DropdownButton( + value: + status.isCurrent && podcastSpeedOptions.contains(status.speed) + ? status.speed + : 1.0, + dropdownColor: FccColors.gray90, + icon: const SizedBox.shrink(), + onChanged: status.isCurrent + ? (value) { + if (value != null) { + ref.read(podcastPlayerProvider.notifier).setSpeed(value); + } + } + : null, + items: [ + for (final option in podcastSpeedOptions) + DropdownMenuItem( + value: option, + child: Text( + '${option}x', + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + iconSize: 45, + icon: const Icon( + Icons.replay_10_rounded, + semanticLabel: 'Rewind 10 seconds', + ), + onPressed: status.isCurrent + ? () => ref.read(podcastPlayerProvider.notifier).rewind() + : null, + ), + IconButton( + iconSize: 80, + onPressed: () => + ref.read(podcastPlayerProvider.notifier).toggle(podcast, episode), + icon: status.isBusy + ? const SizedBox.square( + dimension: 80, + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator( + color: FccColors.gray00, + strokeWidth: 2, + ), + ), + ) + : Icon( + status.isPlaying ? Icons.pause : Icons.play_arrow_rounded, + semanticLabel: status.isPlaying + ? 'Pause episode' + : 'Play episode', + ), + ), + IconButton( + iconSize: 45, + icon: const Icon( + Icons.forward_30_rounded, + semanticLabel: 'Fast forward 30 seconds', + ), + onPressed: status.isCurrent + ? () => ref.read(podcastPlayerProvider.notifier).fastForward() + : null, + ), + PodcastDownloadButton(podcast: podcast, episode: episode, size: 45), + ], + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/episode_list/episode_list_view.dart b/mobile-app/lib/podcasts/ui/episode_list/episode_list_view.dart new file mode 100644 index 000000000..bb06002a8 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/episode_list/episode_list_view.dart @@ -0,0 +1,261 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/controllers/library_controller.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/ui/episode_list/episode_list_viewmodel.dart'; +import 'package:freecodecamp/podcasts/ui/widgets/episode_tile.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PodcastEpisodeListView extends ConsumerStatefulWidget { + const PodcastEpisodeListView({super.key, required this.source}); + + final PodcastEpisodeSource source; + + @override + ConsumerState createState() => + _PodcastEpisodeListViewState(); +} + +class _PodcastEpisodeListViewState + extends ConsumerState { + final _scrollController = ScrollController(); + bool _showFullDescription = false; + + PodcastEpisodesNotifierProvider get _provider => + podcastEpisodesProvider(widget.source); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 1000) { + ref.read(_provider.notifier).fetchNextPage(); + } + } + + @override + Widget build(BuildContext context) { + final episodes = ref.watch(_provider); + + return Scaffold( + backgroundColor: FccColors.gray90, + appBar: AppBar(title: Text(episodes.value?.podcast.title ?? '')), + body: episodes.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load episodes.', + onRetry: () => ref.invalidate(_provider), + ), + data: (state) => RefreshIndicator( + onRefresh: () => + ref.refresh(_provider.future).then((_) {}, onError: (_) {}), + backgroundColor: FccColors.gray90, + color: Colors.white, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + _Header(podcast: state.podcast, source: widget.source), + _Description( + description: state.podcast.description, + isExpanded: _showFullDescription, + onToggle: () => setState( + () => _showFullDescription = !_showFullDescription, + ), + ), + ], + ), + ), + if (state.episodes.isEmpty) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: Text('No episodes')), + ) + else + _buildList(state), + ], + ), + ), + ), + ); + } + + Widget _buildList(PodcastEpisodesState state) { + final footer = _buildFooter(state); + + return SliverList.separated( + itemCount: state.episodes.length + (footer == null ? 0 : 1), + separatorBuilder: (_, _) => + const Divider(color: FccColors.gray80, thickness: 1, height: 1), + itemBuilder: (context, index) => index == state.episodes.length + ? footer + : PodcastEpisodeTile( + key: ValueKey(state.episodes[index].id), + podcast: state.podcast, + episode: state.episodes[index], + ), + ); + } + + Widget? _buildFooter(PodcastEpisodesState state) { + if (state.loadMoreError != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: ErrorRetry( + message: 'Unable to load more episodes.', + onRetry: () => + ref.read(_provider.notifier).fetchNextPage(isRetry: true), + ), + ); + } + + if (state.isLoadingMore) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ); + } + + return null; + } +} + +class _Header extends ConsumerWidget { + const _Header({required this.podcast, required this.source}); + + final Podcast podcast; + final PodcastEpisodeSource source; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + children: [ + SizedBox( + width: double.infinity, + child: AspectRatio(aspectRatio: 1, child: _artwork(ref)), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.75), + spreadRadius: 1.5, + blurRadius: 5, + ), + ], + ), + child: Text( + podcast.title, + maxLines: 2, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 24, + height: 1.2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ); + } + + Widget _artwork(WidgetRef ref) { + if (source is DownloadedEpisodes) { + final artwork = ref.watch( + podcastLibraryProvider.select( + (state) => state.value?.artworkByPodcastId[podcast.id], + ), + ); + + if (artwork != null) return Image.file(artwork, fit: BoxFit.cover); + } + + if (podcast.imageLink.isEmpty) { + return const ColoredBox(color: FccColors.gray80); + } + + return CachedNetworkImage(imageUrl: podcast.imageLink, fit: BoxFit.cover); + } +} + +class _Description extends StatelessWidget { + const _Description({ + required this.description, + required this.isExpanded, + required this.onToggle, + }); + + final String description; + final bool isExpanded; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + if (description.isEmpty) return const SizedBox.shrink(); + + return ColoredBox( + color: FccColors.gray90, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Description', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + Html( + data: description, + onLinkTap: (url, _, _) { + if (url != null) launchUrl(Uri.parse(url)); + }, + style: { + '#': Style( + fontSize: FontSize(16), + color: Colors.white.withValues(alpha: 0.87), + margin: Margins.zero, + maxLines: isExpanded ? null : 3, + ), + }, + ), + TextButton( + onPressed: onToggle, + style: TextButton.styleFrom( + shape: const RoundedRectangleBorder(), + ), + child: Text( + isExpanded ? 'Show less' : 'Show more', + style: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.dart b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.dart new file mode 100644 index 000000000..6d7d8d015 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.dart @@ -0,0 +1,117 @@ +import 'package:collection/collection.dart'; +import 'package:freecodecamp/podcasts/controllers/library_controller.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/services/api_service.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'episode_list_viewmodel.freezed.dart'; +part 'episode_list_viewmodel.g.dart'; + +@freezed +sealed class PodcastEpisodeSource with _$PodcastEpisodeSource { + const factory PodcastEpisodeSource.remote(String podcastId) = RemoteEpisodes; + const factory PodcastEpisodeSource.downloaded(String podcastId) = + DownloadedEpisodes; +} + +@freezed +abstract class PodcastEpisodesState with _$PodcastEpisodesState { + const factory PodcastEpisodesState({ + required Podcast podcast, + @Default([]) List episodes, + @Default(0) int page, + @Default(false) bool hasNextPage, + @Default(false) bool isLoadingMore, + // NOTE: Set when appending a page failed; the episodes already loaded stay valid. + Object? loadMoreError, + }) = _PodcastEpisodesState; +} + +@riverpod +class PodcastEpisodesNotifier extends _$PodcastEpisodesNotifier { + Podcast? _lastKnownPodcast; + + // NOTE: A refresh keeps this notifier mounted, so a page fetched before it is told apart by this. + int _generation = 0; + + @override + FutureOr build(PodcastEpisodeSource source) async { + _generation++; + switch (source) { + case RemoteEpisodes(:final podcastId): + final page = await ref + .watch(podcastApiServiceProvider) + .getEpisodes(podcastId); + + _lastKnownPodcast = page.podcast; + + return PodcastEpisodesState( + podcast: page.podcast, + episodes: page.episodes, + hasNextPage: page.hasMore, + ); + + case DownloadedEpisodes(:final podcastId): + final library = await ref.watch(podcastLibraryProvider.future); + + final podcast = + library.podcasts.firstWhereOrNull( + (podcast) => podcast.id == podcastId, + ) ?? + _lastKnownPodcast; + + if (podcast == null) { + throw StateError('Podcast $podcastId has no downloaded episodes'); + } + + _lastKnownPodcast = podcast; + + return PodcastEpisodesState( + podcast: podcast, + episodes: library.episodesByPodcastId[podcastId] ?? const [], + ); + } + } + + Future fetchNextPage({bool isRetry = false}) async { + final current = state.value; + if (current == null || !current.hasNextPage || current.isLoadingMore) { + return; + } + + // A failed append waits for an explicit retry instead of refiring on scroll. + if (current.loadMoreError != null && !isRetry) return; + + if (source case RemoteEpisodes(:final podcastId)) { + final generation = _generation; + state = AsyncData( + current.copyWith(isLoadingMore: true, loadMoreError: null), + ); + + final next = current.page + 1; + + try { + final page = await ref + .read(podcastApiServiceProvider) + .getEpisodes(podcastId, page: next); + if (!ref.mounted || generation != _generation) return; + + state = AsyncData( + current.copyWith( + episodes: [...current.episodes, ...page.episodes], + page: next, + hasNextPage: page.hasMore, + isLoadingMore: false, + ), + ); + } catch (error) { + if (!ref.mounted || generation != _generation) return; + state = AsyncData( + current.copyWith(isLoadingMore: false, loadMoreError: error), + ); + } + } + } +} diff --git a/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.freezed.dart b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.freezed.dart new file mode 100644 index 000000000..eda5fe43e --- /dev/null +++ b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.freezed.dart @@ -0,0 +1,631 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'episode_list_viewmodel.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$PodcastEpisodeSource { + + String get podcastId; +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PodcastEpisodeSourceCopyWith get copyWith => _$PodcastEpisodeSourceCopyWithImpl(this as PodcastEpisodeSource, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PodcastEpisodeSource&&(identical(other.podcastId, podcastId) || other.podcastId == podcastId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,podcastId); + +@override +String toString() { + return 'PodcastEpisodeSource(podcastId: $podcastId)'; +} + + +} + +/// @nodoc +abstract mixin class $PodcastEpisodeSourceCopyWith<$Res> { + factory $PodcastEpisodeSourceCopyWith(PodcastEpisodeSource value, $Res Function(PodcastEpisodeSource) _then) = _$PodcastEpisodeSourceCopyWithImpl; +@useResult +$Res call({ + String podcastId +}); + + + + +} +/// @nodoc +class _$PodcastEpisodeSourceCopyWithImpl<$Res> + implements $PodcastEpisodeSourceCopyWith<$Res> { + _$PodcastEpisodeSourceCopyWithImpl(this._self, this._then); + + final PodcastEpisodeSource _self; + final $Res Function(PodcastEpisodeSource) _then; + +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? podcastId = null,}) { + return _then(_self.copyWith( +podcastId: null == podcastId ? _self.podcastId : podcastId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PodcastEpisodeSource]. +extension PodcastEpisodeSourcePatterns on PodcastEpisodeSource { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( RemoteEpisodes value)? remote,TResult Function( DownloadedEpisodes value)? downloaded,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case RemoteEpisodes() when remote != null: +return remote(_that);case DownloadedEpisodes() when downloaded != null: +return downloaded(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( RemoteEpisodes value) remote,required TResult Function( DownloadedEpisodes value) downloaded,}){ +final _that = this; +switch (_that) { +case RemoteEpisodes(): +return remote(_that);case DownloadedEpisodes(): +return downloaded(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( RemoteEpisodes value)? remote,TResult? Function( DownloadedEpisodes value)? downloaded,}){ +final _that = this; +switch (_that) { +case RemoteEpisodes() when remote != null: +return remote(_that);case DownloadedEpisodes() when downloaded != null: +return downloaded(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String podcastId)? remote,TResult Function( String podcastId)? downloaded,required TResult orElse(),}) {final _that = this; +switch (_that) { +case RemoteEpisodes() when remote != null: +return remote(_that.podcastId);case DownloadedEpisodes() when downloaded != null: +return downloaded(_that.podcastId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String podcastId) remote,required TResult Function( String podcastId) downloaded,}) {final _that = this; +switch (_that) { +case RemoteEpisodes(): +return remote(_that.podcastId);case DownloadedEpisodes(): +return downloaded(_that.podcastId);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String podcastId)? remote,TResult? Function( String podcastId)? downloaded,}) {final _that = this; +switch (_that) { +case RemoteEpisodes() when remote != null: +return remote(_that.podcastId);case DownloadedEpisodes() when downloaded != null: +return downloaded(_that.podcastId);case _: + return null; + +} +} + +} + +/// @nodoc + + +class RemoteEpisodes implements PodcastEpisodeSource { + const RemoteEpisodes(this.podcastId); + + +@override final String podcastId; + +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RemoteEpisodesCopyWith get copyWith => _$RemoteEpisodesCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RemoteEpisodes&&(identical(other.podcastId, podcastId) || other.podcastId == podcastId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,podcastId); + +@override +String toString() { + return 'PodcastEpisodeSource.remote(podcastId: $podcastId)'; +} + + +} + +/// @nodoc +abstract mixin class $RemoteEpisodesCopyWith<$Res> implements $PodcastEpisodeSourceCopyWith<$Res> { + factory $RemoteEpisodesCopyWith(RemoteEpisodes value, $Res Function(RemoteEpisodes) _then) = _$RemoteEpisodesCopyWithImpl; +@override @useResult +$Res call({ + String podcastId +}); + + + + +} +/// @nodoc +class _$RemoteEpisodesCopyWithImpl<$Res> + implements $RemoteEpisodesCopyWith<$Res> { + _$RemoteEpisodesCopyWithImpl(this._self, this._then); + + final RemoteEpisodes _self; + final $Res Function(RemoteEpisodes) _then; + +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? podcastId = null,}) { + return _then(RemoteEpisodes( +null == podcastId ? _self.podcastId : podcastId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class DownloadedEpisodes implements PodcastEpisodeSource { + const DownloadedEpisodes(this.podcastId); + + +@override final String podcastId; + +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$DownloadedEpisodesCopyWith get copyWith => _$DownloadedEpisodesCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is DownloadedEpisodes&&(identical(other.podcastId, podcastId) || other.podcastId == podcastId)); +} + + +@override +int get hashCode => Object.hash(runtimeType,podcastId); + +@override +String toString() { + return 'PodcastEpisodeSource.downloaded(podcastId: $podcastId)'; +} + + +} + +/// @nodoc +abstract mixin class $DownloadedEpisodesCopyWith<$Res> implements $PodcastEpisodeSourceCopyWith<$Res> { + factory $DownloadedEpisodesCopyWith(DownloadedEpisodes value, $Res Function(DownloadedEpisodes) _then) = _$DownloadedEpisodesCopyWithImpl; +@override @useResult +$Res call({ + String podcastId +}); + + + + +} +/// @nodoc +class _$DownloadedEpisodesCopyWithImpl<$Res> + implements $DownloadedEpisodesCopyWith<$Res> { + _$DownloadedEpisodesCopyWithImpl(this._self, this._then); + + final DownloadedEpisodes _self; + final $Res Function(DownloadedEpisodes) _then; + +/// Create a copy of PodcastEpisodeSource +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? podcastId = null,}) { + return _then(DownloadedEpisodes( +null == podcastId ? _self.podcastId : podcastId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc +mixin _$PodcastEpisodesState { + + Podcast get podcast; List get episodes; int get page; bool get hasNextPage; bool get isLoadingMore; Object? get loadMoreError; +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PodcastEpisodesStateCopyWith get copyWith => _$PodcastEpisodesStateCopyWithImpl(this as PodcastEpisodesState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PodcastEpisodesState&&(identical(other.podcast, podcast) || other.podcast == podcast)&&const DeepCollectionEquality().equals(other.episodes, episodes)&&(identical(other.page, page) || other.page == page)&&(identical(other.hasNextPage, hasNextPage) || other.hasNextPage == hasNextPage)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&const DeepCollectionEquality().equals(other.loadMoreError, loadMoreError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,podcast,const DeepCollectionEquality().hash(episodes),page,hasNextPage,isLoadingMore,const DeepCollectionEquality().hash(loadMoreError)); + +@override +String toString() { + return 'PodcastEpisodesState(podcast: $podcast, episodes: $episodes, page: $page, hasNextPage: $hasNextPage, isLoadingMore: $isLoadingMore, loadMoreError: $loadMoreError)'; +} + + +} + +/// @nodoc +abstract mixin class $PodcastEpisodesStateCopyWith<$Res> { + factory $PodcastEpisodesStateCopyWith(PodcastEpisodesState value, $Res Function(PodcastEpisodesState) _then) = _$PodcastEpisodesStateCopyWithImpl; +@useResult +$Res call({ + Podcast podcast, List episodes, int page, bool hasNextPage, bool isLoadingMore, Object? loadMoreError +}); + + +$PodcastCopyWith<$Res> get podcast; + +} +/// @nodoc +class _$PodcastEpisodesStateCopyWithImpl<$Res> + implements $PodcastEpisodesStateCopyWith<$Res> { + _$PodcastEpisodesStateCopyWithImpl(this._self, this._then); + + final PodcastEpisodesState _self; + final $Res Function(PodcastEpisodesState) _then; + +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? podcast = null,Object? episodes = null,Object? page = null,Object? hasNextPage = null,Object? isLoadingMore = null,Object? loadMoreError = freezed,}) { + return _then(_self.copyWith( +podcast: null == podcast ? _self.podcast : podcast // ignore: cast_nullable_to_non_nullable +as Podcast,episodes: null == episodes ? _self.episodes : episodes // ignore: cast_nullable_to_non_nullable +as List,page: null == page ? _self.page : page // ignore: cast_nullable_to_non_nullable +as int,hasNextPage: null == hasNextPage ? _self.hasNextPage : hasNextPage // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreError: freezed == loadMoreError ? _self.loadMoreError : loadMoreError , + )); +} +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$PodcastCopyWith<$Res> get podcast { + + return $PodcastCopyWith<$Res>(_self.podcast, (value) { + return _then(_self.copyWith(podcast: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [PodcastEpisodesState]. +extension PodcastEpisodesStatePatterns on PodcastEpisodesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _PodcastEpisodesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PodcastEpisodesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _PodcastEpisodesState value) $default,){ +final _that = this; +switch (_that) { +case _PodcastEpisodesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _PodcastEpisodesState value)? $default,){ +final _that = this; +switch (_that) { +case _PodcastEpisodesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Podcast podcast, List episodes, int page, bool hasNextPage, bool isLoadingMore, Object? loadMoreError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PodcastEpisodesState() when $default != null: +return $default(_that.podcast,_that.episodes,_that.page,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Podcast podcast, List episodes, int page, bool hasNextPage, bool isLoadingMore, Object? loadMoreError) $default,) {final _that = this; +switch (_that) { +case _PodcastEpisodesState(): +return $default(_that.podcast,_that.episodes,_that.page,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Podcast podcast, List episodes, int page, bool hasNextPage, bool isLoadingMore, Object? loadMoreError)? $default,) {final _that = this; +switch (_that) { +case _PodcastEpisodesState() when $default != null: +return $default(_that.podcast,_that.episodes,_that.page,_that.hasNextPage,_that.isLoadingMore,_that.loadMoreError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _PodcastEpisodesState implements PodcastEpisodesState { + const _PodcastEpisodesState({required this.podcast, final List episodes = const [], this.page = 0, this.hasNextPage = false, this.isLoadingMore = false, this.loadMoreError}): _episodes = episodes; + + +@override final Podcast podcast; + final List _episodes; +@override@JsonKey() List get episodes { + if (_episodes is EqualUnmodifiableListView) return _episodes; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_episodes); +} + +@override@JsonKey() final int page; +@override@JsonKey() final bool hasNextPage; +@override@JsonKey() final bool isLoadingMore; +@override final Object? loadMoreError; + +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PodcastEpisodesStateCopyWith<_PodcastEpisodesState> get copyWith => __$PodcastEpisodesStateCopyWithImpl<_PodcastEpisodesState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PodcastEpisodesState&&(identical(other.podcast, podcast) || other.podcast == podcast)&&const DeepCollectionEquality().equals(other._episodes, _episodes)&&(identical(other.page, page) || other.page == page)&&(identical(other.hasNextPage, hasNextPage) || other.hasNextPage == hasNextPage)&&(identical(other.isLoadingMore, isLoadingMore) || other.isLoadingMore == isLoadingMore)&&const DeepCollectionEquality().equals(other.loadMoreError, loadMoreError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,podcast,const DeepCollectionEquality().hash(_episodes),page,hasNextPage,isLoadingMore,const DeepCollectionEquality().hash(loadMoreError)); + +@override +String toString() { + return 'PodcastEpisodesState(podcast: $podcast, episodes: $episodes, page: $page, hasNextPage: $hasNextPage, isLoadingMore: $isLoadingMore, loadMoreError: $loadMoreError)'; +} + + +} + +/// @nodoc +abstract mixin class _$PodcastEpisodesStateCopyWith<$Res> implements $PodcastEpisodesStateCopyWith<$Res> { + factory _$PodcastEpisodesStateCopyWith(_PodcastEpisodesState value, $Res Function(_PodcastEpisodesState) _then) = __$PodcastEpisodesStateCopyWithImpl; +@override @useResult +$Res call({ + Podcast podcast, List episodes, int page, bool hasNextPage, bool isLoadingMore, Object? loadMoreError +}); + + +@override $PodcastCopyWith<$Res> get podcast; + +} +/// @nodoc +class __$PodcastEpisodesStateCopyWithImpl<$Res> + implements _$PodcastEpisodesStateCopyWith<$Res> { + __$PodcastEpisodesStateCopyWithImpl(this._self, this._then); + + final _PodcastEpisodesState _self; + final $Res Function(_PodcastEpisodesState) _then; + +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? podcast = null,Object? episodes = null,Object? page = null,Object? hasNextPage = null,Object? isLoadingMore = null,Object? loadMoreError = freezed,}) { + return _then(_PodcastEpisodesState( +podcast: null == podcast ? _self.podcast : podcast // ignore: cast_nullable_to_non_nullable +as Podcast,episodes: null == episodes ? _self._episodes : episodes // ignore: cast_nullable_to_non_nullable +as List,page: null == page ? _self.page : page // ignore: cast_nullable_to_non_nullable +as int,hasNextPage: null == hasNextPage ? _self.hasNextPage : hasNextPage // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMore: null == isLoadingMore ? _self.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreError: freezed == loadMoreError ? _self.loadMoreError : loadMoreError , + )); +} + +/// Create a copy of PodcastEpisodesState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$PodcastCopyWith<$Res> get podcast { + + return $PodcastCopyWith<$Res>(_self.podcast, (value) { + return _then(_self.copyWith(podcast: value)); + }); +} +} + +// dart format on diff --git a/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.g.dart b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.g.dart new file mode 100644 index 000000000..5a8c7e138 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/episode_list/episode_list_viewmodel.g.dart @@ -0,0 +1,108 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'episode_list_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(PodcastEpisodesNotifier) +final podcastEpisodesProvider = PodcastEpisodesNotifierFamily._(); + +final class PodcastEpisodesNotifierProvider + extends + $AsyncNotifierProvider { + PodcastEpisodesNotifierProvider._({ + required PodcastEpisodesNotifierFamily super.from, + required PodcastEpisodeSource super.argument, + }) : super( + retry: null, + name: r'podcastEpisodesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastEpisodesNotifierHash(); + + @override + String toString() { + return r'podcastEpisodesProvider' + '' + '($argument)'; + } + + @$internal + @override + PodcastEpisodesNotifier create() => PodcastEpisodesNotifier(); + + @override + bool operator ==(Object other) { + return other is PodcastEpisodesNotifierProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$podcastEpisodesNotifierHash() => + r'74e02ef2905e26c0bdcfca2a69a8e2d7fc132f94'; + +final class PodcastEpisodesNotifierFamily extends $Family + with + $ClassFamilyOverride< + PodcastEpisodesNotifier, + AsyncValue, + PodcastEpisodesState, + FutureOr, + PodcastEpisodeSource + > { + PodcastEpisodesNotifierFamily._() + : super( + retry: null, + name: r'podcastEpisodesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + PodcastEpisodesNotifierProvider call(PodcastEpisodeSource source) => + PodcastEpisodesNotifierProvider._(argument: source, from: this); + + @override + String toString() => r'podcastEpisodesProvider'; +} + +abstract class _$PodcastEpisodesNotifier + extends $AsyncNotifier { + late final _$args = ref.$arg as PodcastEpisodeSource; + PodcastEpisodeSource get source => _$args; + + FutureOr build(PodcastEpisodeSource source); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = + this.ref + as $Ref, PodcastEpisodesState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + PodcastEpisodesState + >, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_view.dart b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_view.dart new file mode 100644 index 000000000..25874b8f6 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_view.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/ui/podcast_list/podcast_list_viewmodel.dart'; +import 'package:freecodecamp/podcasts/ui/widgets/podcast_grid_tile.dart'; +import 'package:freecodecamp/routing/podcasts.dart'; +import 'package:freecodecamp/widgets/error_retry.dart'; + +class PodcastListView extends ConsumerWidget { + const PodcastListView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final podcasts = ref.watch(podcastListProvider); + + return ColoredBox( + color: FccColors.gray80, + child: podcasts.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => ErrorRetry( + message: 'Unable to load podcasts.', + onRetry: () => ref.invalidate(podcastListProvider), + ), + data: (podcasts) => RefreshIndicator( + onRefresh: () => ref + .refresh(podcastListProvider.future) + .then((_) {}, onError: (_) {}), + backgroundColor: FccColors.gray90, + color: Colors.white, + child: GridView.count( + crossAxisCount: 2, + childAspectRatio: 1, + children: [ + for (final podcast in podcasts) + PodcastGridTile( + key: ValueKey(podcast.id), + podcast: podcast, + onTap: () => PodcastEpisodeListRoute( + podcastId: podcast.id, + ).push(context), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.dart b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.dart new file mode 100644 index 000000000..3e9649bd0 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.dart @@ -0,0 +1,9 @@ +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/services/api_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'podcast_list_viewmodel.g.dart'; + +@riverpod +Future> podcastList(Ref ref) => + ref.watch(podcastApiServiceProvider).getPodcasts(); diff --git a/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.g.dart b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.g.dart new file mode 100644 index 000000000..9e76a3b65 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/podcast_list/podcast_list_viewmodel.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'podcast_list_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(podcastList) +final podcastListProvider = PodcastListProvider._(); + +final class PodcastListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + PodcastListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'podcastListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$podcastListHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return podcastList(ref); + } +} + +String _$podcastListHash() => r'050470de646ff921b6da31c2026f07ca7a8405df'; diff --git a/mobile-app/lib/podcasts/ui/podcasts_shell.dart b/mobile-app/lib/podcasts/ui/podcasts_shell.dart new file mode 100644 index 000000000..a83b89994 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/podcasts_shell.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/routing/podcasts.dart'; +import 'package:freecodecamp/widgets/drawer/drawer.dart'; +import 'package:go_router/go_router.dart'; + +typedef _Tab = ({String title, String path, IconData icon}); + +class PodcastsShell extends StatelessWidget { + const PodcastsShell({super.key, required this.child}); + + final Widget child; + + static const List<_Tab> _tabs = [ + (title: 'Podcasts', path: podcastListPath, icon: Icons.grid_view_rounded), + ( + title: 'Downloads', + path: podcastDownloadsPath, + icon: Icons.arrow_circle_down_sharp, + ), + ]; + + static const _listIndex = 0; + + int _currentIndex(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final index = _tabs.indexWhere((tab) => tab.path == location); + return index == -1 ? _listIndex : index; + } + + @override + Widget build(BuildContext context) { + final index = _currentIndex(context); + + return Scaffold( + appBar: AppBar(title: Text(_tabs[index].title)), + drawer: const DrawerWidget(), + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: index, + onTap: (i) => context.go(_tabs[i].path), + items: [ + for (final tab in _tabs) + BottomNavigationBarItem( + icon: Icon(tab.icon), + label: tab.title, + tooltip: tab.title, + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/widgets/download_button.dart b/mobile-app/lib/podcasts/ui/widgets/download_button.dart new file mode 100644 index 000000000..26528bea3 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/widgets/download_button.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/controllers/downloads_controller.dart'; +import 'package:freecodecamp/podcasts/controllers/library_controller.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; + +class PodcastDownloadButton extends ConsumerWidget { + const PodcastDownloadButton({ + super.key, + required this.podcast, + required this.episode, + this.size = 33, + }); + + final Podcast podcast; + final Episode episode; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final downloadState = ref.watch( + podcastDownloadsProvider.select((state) => state[episode.id]), + ); + final isDownloaded = ref.watch( + podcastLibraryProvider.select( + (state) => + state.value?.episodesByPodcastId[episode.podcastId]?.any( + (downloaded) => downloaded.id == episode.id, + ) ?? + false, + ), + ); + + final isRunning = downloadState is DownloadRunning; + + return IconButton( + onPressed: () => _onPressed(ref, downloadState, isDownloaded), + iconSize: size, + style: ButtonStyle( + shape: WidgetStateProperty.all(const CircleBorder()), + side: WidgetStateProperty.all( + isRunning + ? BorderSide.none + : const BorderSide(color: FccColors.gray80, width: 1), + ), + ), + icon: _icon(downloadState, isDownloaded), + ); + } + + void _onPressed( + WidgetRef ref, + DownloadStatus? downloadState, + bool isDownloaded, + ) => switch (downloadState) { + DownloadRunning() => + ref.read(podcastDownloadsProvider.notifier).cancel(episode.id), + DownloadFailed() => + ref.read(podcastDownloadsProvider.notifier).start(podcast, episode), + null when isDownloaded => + ref + .read(podcastLibraryProvider.notifier) + .remove(episode.podcastId, episode.id), + null => ref.read(podcastDownloadsProvider.notifier).start(podcast, episode), + }; + + Widget _icon(DownloadStatus? downloadState, bool isDownloaded) => + switch (downloadState) { + DownloadRunning(:final progress) => SizedBox.square( + dimension: size, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + color: Colors.white, + strokeWidth: 1, + value: progress, + ), + Text( + '${(progress * 100).round()}%', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + DownloadFailed() => Icon( + Icons.refresh, + color: FccColors.red15, + semanticLabel: 'Download failed, tap to retry', + ), + null when isDownloaded => Icon( + Icons.download_done, + color: FccColors.gray10, + semanticLabel: 'Downloaded, tap to delete', + ), + null => Icon( + Icons.download, + color: FccColors.gray10, + semanticLabel: 'Download episode', + ), + }; +} diff --git a/mobile-app/lib/podcasts/ui/widgets/episode_tile.dart b/mobile-app/lib/podcasts/ui/widgets/episode_tile.dart new file mode 100644 index 000000000..4b3974ddf --- /dev/null +++ b/mobile-app/lib/podcasts/ui/widgets/episode_tile.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/controllers/player_controller.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/ui/widgets/download_button.dart'; +import 'package:freecodecamp/routing/podcasts.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:jiffy/jiffy.dart'; + +class PodcastEpisodeTile extends StatelessWidget { + const PodcastEpisodeTile({ + super.key, + required this.podcast, + required this.episode, + }); + + final Podcast podcast; + final Episode episode; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: FccColors.gray90, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => PodcastEpisodeRoute( + podcastId: podcast.id, + episodeId: episode.id, + $extra: (podcast, episode), + ).push(context), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PublishedAt(date: episode.publicationDate), + const SizedBox(height: 6), + Text( + episode.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + color: FccColors.gray00, + ), + ), + _Summary(description: episode.description), + Row( + children: [ + _PodcastPlayButton(podcast: podcast, episode: episode), + const SizedBox(width: 12), + PodcastDownloadButton(podcast: podcast, episode: episode), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +class _PodcastPlayButton extends ConsumerWidget { + const _PodcastPlayButton({required this.podcast, required this.episode}); + + final Podcast podcast; + final Episode episode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch( + podcastPlayerProvider.select( + (state) => ( + isPlaying: state.isPlayingEpisode(episode.id), + isBusy: state.isBusyWith(episode.id), + ), + ), + ); + + return IconButton( + onPressed: () => + ref.read(podcastPlayerProvider.notifier).toggle(podcast, episode), + iconSize: 33, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + status.isPlaying ? FccColors.purple90 : FccColors.gray80, + ), + shape: WidgetStateProperty.all(const CircleBorder()), + ), + icon: status.isBusy + ? const SizedBox.square( + dimension: 33, + child: Padding( + padding: EdgeInsets.all(2), + child: CircularProgressIndicator( + color: FccColors.gray00, + strokeWidth: 1.8, + ), + ), + ) + : Icon( + status.isPlaying ? Icons.pause : Icons.play_arrow, + color: FccColors.gray00, + size: 33, + semanticLabel: status.isPlaying + ? 'Pause episode' + : 'Play episode', + ), + ); + } +} + +class _PublishedAt extends StatelessWidget { + const _PublishedAt({required this.date}); + + final DateTime? date; + + @override + Widget build(BuildContext context) { + if (date == null) return const SizedBox.shrink(); + + return Text( + Jiffy.parseFromDateTime(date!).fromNow().toUpperCase(), + style: const TextStyle( + fontSize: 12, + color: FccColors.gray10, + fontWeight: FontWeight.w600, + letterSpacing: 1.1, + ), + ); + } +} + +class _Summary extends StatelessWidget { + const _Summary({required this.description}); + + final String description; + + static String _firstSentence(String html) { + if (html.isEmpty) return ''; + + final text = html_parser.parse(html).body?.text.trim() ?? ''; + final match = RegExp(r'([^.?!]*[.?!])').firstMatch(text); + final sentence = match?.group(0)?.trim() ?? text; + + return sentence.length > 120 + ? '${sentence.substring(0, 120).trimRight()}...' + : sentence; + } + + @override + Widget build(BuildContext context) { + final sentence = _firstSentence(description); + if (sentence.isEmpty) return const SizedBox(height: 14); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + Text( + sentence, + style: const TextStyle(color: FccColors.gray10, fontSize: 16), + ), + const SizedBox(height: 14), + ], + ); + } +} diff --git a/mobile-app/lib/podcasts/ui/widgets/podcast_grid_tile.dart b/mobile-app/lib/podcasts/ui/widgets/podcast_grid_tile.dart new file mode 100644 index 000000000..e28c54cb1 --- /dev/null +++ b/mobile-app/lib/podcasts/ui/widgets/podcast_grid_tile.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; + +class PodcastGridTile extends StatelessWidget { + const PodcastGridTile({ + super.key, + required this.podcast, + required this.onTap, + this.artwork, + }); + + final Podcast podcast; + final VoidCallback onTap; + + final File? artwork; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(4), + child: Stack( + fit: StackFit.expand, + children: [ + ColoredBox(color: FccColors.gray90, child: _artwork()), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.75), + spreadRadius: 1.5, + blurRadius: 0.1, + ), + ], + ), + child: Text( + podcast.title, + maxLines: 2, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _artwork() { + if (artwork case final file?) return Image.file(file, fit: BoxFit.cover); + + if (podcast.imageLink.isEmpty) { + return const Icon(Icons.podcasts, color: FccColors.gray45, size: 48); + } + + return CachedNetworkImage( + imageUrl: podcast.imageLink, + fit: BoxFit.cover, + errorWidget: (_, _, _) => + const Icon(Icons.podcasts, color: FccColors.gray45, size: 48), + ); + } +} diff --git a/mobile-app/lib/routing/auth.dart b/mobile-app/lib/routing/auth.dart new file mode 100644 index 000000000..d1da94e6f --- /dev/null +++ b/mobile-app/lib/routing/auth.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/auth/ui/login/login_view.dart'; +import 'package:freecodecamp/auth/ui/profile/profile_view.dart'; +import 'package:go_router/go_router.dart'; + +part 'auth.g.dart'; + +const loginPath = '/login'; +const profilePath = '/profile'; + +@TypedGoRoute(path: loginPath) +class LoginRoute extends GoRouteData with $LoginRoute { + const LoginRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const LoginView(); +} + +@TypedGoRoute(path: profilePath) +class ProfileRoute extends GoRouteData with $ProfileRoute { + const ProfileRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const ProfileView(); +} diff --git a/mobile-app/lib/routing/auth.g.dart b/mobile-app/lib/routing/auth.g.dart new file mode 100644 index 000000000..fa9f86519 --- /dev/null +++ b/mobile-app/lib/routing/auth.g.dart @@ -0,0 +1,61 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [$loginRoute, $profileRoute]; + +RouteBase get $loginRoute => GoRouteData.$route( + path: '/login', + hasOverriddenOnExit: false, + factory: $LoginRoute._fromState, +); + +mixin $LoginRoute on GoRouteData { + static LoginRoute _fromState(GoRouterState state) => const LoginRoute(); + + @override + String get location => GoRouteData.$location('/login'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $profileRoute => GoRouteData.$route( + path: '/profile', + hasOverriddenOnExit: false, + factory: $ProfileRoute._fromState, +); + +mixin $ProfileRoute on GoRouteData { + static ProfileRoute _fromState(GoRouterState state) => const ProfileRoute(); + + @override + String get location => GoRouteData.$location('/profile'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/mobile-app/lib/routing/code_radio.dart b/mobile-app/lib/routing/code_radio.dart new file mode 100644 index 000000000..a0f7c7a07 --- /dev/null +++ b/mobile-app/lib/routing/code_radio.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/code_radio/ui/player/player_view.dart'; +import 'package:go_router/go_router.dart'; + +part 'code_radio.g.dart'; + +const codeRadioPath = '/code-radio'; + +@TypedGoRoute(path: codeRadioPath) +class CodeRadioRoute extends GoRouteData with $CodeRadioRoute { + const CodeRadioRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const CodeRadioPlayerView(); +} diff --git a/mobile-app/lib/routing/code_radio.g.dart b/mobile-app/lib/routing/code_radio.g.dart new file mode 100644 index 000000000..2f800ff67 --- /dev/null +++ b/mobile-app/lib/routing/code_radio.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'code_radio.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [$codeRadioRoute]; + +RouteBase get $codeRadioRoute => GoRouteData.$route( + path: '/code-radio', + hasOverriddenOnExit: false, + factory: $CodeRadioRoute._fromState, +); + +mixin $CodeRadioRoute on GoRouteData { + static CodeRadioRoute _fromState(GoRouterState state) => + const CodeRadioRoute(); + + @override + String get location => GoRouteData.$location('/code-radio'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/mobile-app/lib/routing/learn.dart b/mobile-app/lib/routing/learn.dart new file mode 100644 index 000000000..e5ea1965c --- /dev/null +++ b/mobile-app/lib/routing/learn.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/learn/models/challenge_model.dart'; +import 'package:freecodecamp/learn/ui/challenge/challenge_view.dart'; +import 'package:freecodecamp/learn/ui/daily_challenge/daily_challenge_view.dart'; +import 'package:freecodecamp/learn/ui/daily_challenge_archive/daily_challenge_archive_view.dart'; +import 'package:freecodecamp/learn/ui/landing/landing_view.dart'; +import 'package:freecodecamp/learn/ui/module/module_view.dart'; +import 'package:freecodecamp/learn/ui/scene_fullscreen/scene_fullscreen_view.dart'; +import 'package:freecodecamp/learn/ui/superblock/superblock_view.dart'; +import 'package:go_router/go_router.dart'; + +part 'learn.g.dart'; + +const learnLandingPath = '/learn'; +const learnDailyChallengeArchivePath = '/learn/daily-challenges'; +const learnDailyChallengePath = '/learn/daily-challenges/:day'; +const learnSceneFullscreenPath = '/learn/scene-fullscreen'; +const learnSuperBlockPath = '/learn/:superBlock'; +const learnModulePath = '/learn/:superBlock/:module'; +const learnChallengePath = '/learn/:superBlock/:block/:challengeId'; + +@TypedGoRoute(path: learnLandingPath) +class LearnLandingRoute extends GoRouteData with $LearnLandingRoute { + const LearnLandingRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const LearnLandingView(); +} + +@TypedGoRoute( + path: learnDailyChallengeArchivePath, +) +class LearnDailyChallengeArchiveRoute extends GoRouteData + with $LearnDailyChallengeArchiveRoute { + const LearnDailyChallengeArchiveRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const DailyChallengeArchiveView(); +} + +@TypedGoRoute(path: learnDailyChallengePath) +class LearnDailyChallengeRoute extends GoRouteData + with $LearnDailyChallengeRoute { + const LearnDailyChallengeRoute({required this.day}); + + // NOTE: MM-DD + final String day; + + @override + Widget build(BuildContext context, GoRouterState state) => + DailyChallengeView(day: day); +} + +@TypedGoRoute(path: learnSceneFullscreenPath) +class LearnSceneFullscreenRoute extends GoRouteData + with $LearnSceneFullscreenRoute { + const LearnSceneFullscreenRoute({required this.$extra}); + + final (Scene, String) $extra; + + @override + Page buildPage(BuildContext context, GoRouterState state) => + CustomTransitionPage( + key: state.pageKey, + opaque: false, + child: SceneFullscreenView(scene: $extra.$1, title: $extra.$2), + transitionsBuilder: (_, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ); +} + +@TypedGoRoute(path: learnSuperBlockPath) +class LearnSuperBlockRoute extends GoRouteData with $LearnSuperBlockRoute { + const LearnSuperBlockRoute({required this.superBlock}); + + final String superBlock; + + @override + Widget build(BuildContext context, GoRouterState state) => + SuperBlockView(dashedName: superBlock); +} + +@TypedGoRoute(path: learnModulePath) +class LearnModuleRoute extends GoRouteData with $LearnModuleRoute { + const LearnModuleRoute({required this.superBlock, required this.module}); + + final String superBlock; + final String module; + + @override + Widget build(BuildContext context, GoRouterState state) => + ModuleView(superBlock: superBlock, module: module); +} + +@TypedGoRoute(path: learnChallengePath) +class LearnChallengeRoute extends GoRouteData with $LearnChallengeRoute { + const LearnChallengeRoute({ + required this.superBlock, + required this.block, + required this.challengeId, + }); + + final String superBlock; + final String block; + final String challengeId; + + @override + Widget build(BuildContext context, GoRouterState state) => ChallengeView( + superBlock: superBlock, + block: block, + challengeId: challengeId, + ); +} diff --git a/mobile-app/lib/routing/learn.g.dart b/mobile-app/lib/routing/learn.g.dart new file mode 100644 index 000000000..cec2f631a --- /dev/null +++ b/mobile-app/lib/routing/learn.g.dart @@ -0,0 +1,231 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'learn.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $learnLandingRoute, + $learnDailyChallengeArchiveRoute, + $learnDailyChallengeRoute, + $learnSceneFullscreenRoute, + $learnSuperBlockRoute, + $learnModuleRoute, + $learnChallengeRoute, +]; + +RouteBase get $learnLandingRoute => GoRouteData.$route( + path: '/learn', + hasOverriddenOnExit: false, + factory: $LearnLandingRoute._fromState, +); + +mixin $LearnLandingRoute on GoRouteData { + static LearnLandingRoute _fromState(GoRouterState state) => + const LearnLandingRoute(); + + @override + String get location => GoRouteData.$location('/learn'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $learnDailyChallengeArchiveRoute => GoRouteData.$route( + path: '/learn/daily-challenges', + hasOverriddenOnExit: false, + factory: $LearnDailyChallengeArchiveRoute._fromState, +); + +mixin $LearnDailyChallengeArchiveRoute on GoRouteData { + static LearnDailyChallengeArchiveRoute _fromState(GoRouterState state) => + const LearnDailyChallengeArchiveRoute(); + + @override + String get location => GoRouteData.$location('/learn/daily-challenges'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $learnDailyChallengeRoute => GoRouteData.$route( + path: '/learn/daily-challenges/:day', + hasOverriddenOnExit: false, + factory: $LearnDailyChallengeRoute._fromState, +); + +mixin $LearnDailyChallengeRoute on GoRouteData { + static LearnDailyChallengeRoute _fromState(GoRouterState state) => + LearnDailyChallengeRoute(day: state.pathParameters['day']!); + + LearnDailyChallengeRoute get _self => this as LearnDailyChallengeRoute; + + @override + String get location => GoRouteData.$location( + '/learn/daily-challenges/${Uri.encodeComponent(_self.day)}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $learnSceneFullscreenRoute => GoRouteData.$route( + path: '/learn/scene-fullscreen', + hasOverriddenOnExit: false, + factory: $LearnSceneFullscreenRoute._fromState, +); + +mixin $LearnSceneFullscreenRoute on GoRouteData { + static LearnSceneFullscreenRoute _fromState(GoRouterState state) => + LearnSceneFullscreenRoute($extra: state.extra as (Scene, String)); + + LearnSceneFullscreenRoute get _self => this as LearnSceneFullscreenRoute; + + @override + String get location => GoRouteData.$location('/learn/scene-fullscreen'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +RouteBase get $learnSuperBlockRoute => GoRouteData.$route( + path: '/learn/:superBlock', + hasOverriddenOnExit: false, + factory: $LearnSuperBlockRoute._fromState, +); + +mixin $LearnSuperBlockRoute on GoRouteData { + static LearnSuperBlockRoute _fromState(GoRouterState state) => + LearnSuperBlockRoute(superBlock: state.pathParameters['superBlock']!); + + LearnSuperBlockRoute get _self => this as LearnSuperBlockRoute; + + @override + String get location => + GoRouteData.$location('/learn/${Uri.encodeComponent(_self.superBlock)}'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $learnModuleRoute => GoRouteData.$route( + path: '/learn/:superBlock/:module', + hasOverriddenOnExit: false, + factory: $LearnModuleRoute._fromState, +); + +mixin $LearnModuleRoute on GoRouteData { + static LearnModuleRoute _fromState(GoRouterState state) => LearnModuleRoute( + superBlock: state.pathParameters['superBlock']!, + module: state.pathParameters['module']!, + ); + + LearnModuleRoute get _self => this as LearnModuleRoute; + + @override + String get location => GoRouteData.$location( + '/learn/${Uri.encodeComponent(_self.superBlock)}/${Uri.encodeComponent(_self.module)}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $learnChallengeRoute => GoRouteData.$route( + path: '/learn/:superBlock/:block/:challengeId', + hasOverriddenOnExit: false, + factory: $LearnChallengeRoute._fromState, +); + +mixin $LearnChallengeRoute on GoRouteData { + static LearnChallengeRoute _fromState(GoRouterState state) => + LearnChallengeRoute( + superBlock: state.pathParameters['superBlock']!, + block: state.pathParameters['block']!, + challengeId: state.pathParameters['challengeId']!, + ); + + LearnChallengeRoute get _self => this as LearnChallengeRoute; + + @override + String get location => GoRouteData.$location( + '/learn/${Uri.encodeComponent(_self.superBlock)}/${Uri.encodeComponent(_self.block)}/${Uri.encodeComponent(_self.challengeId)}', + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/mobile-app/lib/routing/news.dart b/mobile-app/lib/routing/news.dart new file mode 100644 index 000000000..6b7c4f3a4 --- /dev/null +++ b/mobile-app/lib/routing/news.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/news/models/author_model.dart'; +import 'package:freecodecamp/news/models/bookmarked_post_model.dart'; +import 'package:freecodecamp/news/ui/author_feed/author_feed_view.dart'; +import 'package:freecodecamp/news/ui/bookmark_feed/bookmark_feed_view.dart'; +import 'package:freecodecamp/news/ui/bookmark_post/bookmark_post_view.dart'; +import 'package:freecodecamp/news/ui/feed/feed_view.dart'; +import 'package:freecodecamp/news/ui/image/image_view.dart'; +import 'package:freecodecamp/news/ui/news_shell.dart'; +import 'package:freecodecamp/news/ui/post/post_view.dart'; +import 'package:freecodecamp/news/ui/search/search_view.dart'; +import 'package:freecodecamp/news/ui/tag_feed/tag_feed_view.dart'; +import 'package:go_router/go_router.dart'; + +part 'news.g.dart'; + +const newsBookmarksPath = '/news/bookmarks'; +const newsFeedPath = '/news'; +const newsSearchPath = '/news/search'; +const newsPostPath = '/news/:slug'; +const newsImagePath = '/news/image'; +const newsTagFeedPath = '/news/tag/:tagSlug'; +const newsAuthorPath = '/news/author/:username'; +const newsBookmarkPostPath = '/news/bookmarks/:id'; + +@TypedShellRoute( + routes: [ + TypedGoRoute(path: newsBookmarksPath), + TypedGoRoute(path: newsFeedPath), + TypedGoRoute(path: newsSearchPath), + ], +) +class NewsShellRoute extends ShellRouteData { + const NewsShellRoute(); + + @override + Widget builder(BuildContext context, GoRouterState state, Widget navigator) { + return NewsShell(child: navigator); + } +} + +class NewsBookmarksRoute extends GoRouteData with $NewsBookmarksRoute { + const NewsBookmarksRoute(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const NoTransitionPage(child: NewsBookmarkFeedView()); +} + +class NewsFeedRoute extends GoRouteData with $NewsFeedRoute { + const NewsFeedRoute(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const NoTransitionPage(child: NewsFeedView()); +} + +class NewsSearchRoute extends GoRouteData with $NewsSearchRoute { + const NewsSearchRoute(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const NoTransitionPage(child: NewsSearchView()); +} + +@TypedGoRoute(path: newsImagePath) +class NewsImageRoute extends GoRouteData with $NewsImageRoute { + const NewsImageRoute({required this.$extra}); + + final String $extra; + + @override + Widget build(BuildContext context, GoRouterState state) => + NewsImageView(imgUrl: $extra); +} + +@TypedGoRoute(path: newsTagFeedPath) +class NewsTagFeedRoute extends GoRouteData with $NewsTagFeedRoute { + const NewsTagFeedRoute({required this.tagSlug, this.$extra}); + + final String tagSlug; + + // NOTE: Tag display name + final String? $extra; + + @override + Widget build(BuildContext context, GoRouterState state) => + NewsTagFeedView(tagSlug: tagSlug, tagName: $extra); +} + +@TypedGoRoute(path: newsAuthorPath) +class NewsAuthorRoute extends GoRouteData with $NewsAuthorRoute { + const NewsAuthorRoute({required this.username, this.$extra}); + + final String username; + + // NOTE: Author, when the caller already has it + final Author? $extra; + + @override + Widget build(BuildContext context, GoRouterState state) => + NewsAuthorFeedView(username: username, author: $extra); +} + +@TypedGoRoute(path: newsBookmarkPostPath) +class NewsBookmarkPostRoute extends GoRouteData with $NewsBookmarkPostRoute { + const NewsBookmarkPostRoute({required this.id, required this.$extra}); + + final String id; + + // NOTE: The bookmarked post passed from feed so we don't have to fetch it again + final BookmarkedPost $extra; + + @override + Widget build(BuildContext context, GoRouterState state) => + NewsBookmarkPostView(post: $extra); +} + +@TypedGoRoute(path: newsPostPath) +class NewsPostRoute extends GoRouteData with $NewsPostRoute { + const NewsPostRoute({required this.slug}); + + final String slug; + + @override + Widget build(BuildContext context, GoRouterState state) => + NewsPostView(slug: slug); +} diff --git a/mobile-app/lib/routing/news.g.dart b/mobile-app/lib/routing/news.g.dart new file mode 100644 index 000000000..49fd5ad22 --- /dev/null +++ b/mobile-app/lib/routing/news.g.dart @@ -0,0 +1,269 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'news.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $newsShellRoute, + $newsImageRoute, + $newsTagFeedRoute, + $newsAuthorRoute, + $newsBookmarkPostRoute, + $newsPostRoute, +]; + +RouteBase get $newsShellRoute => ShellRouteData.$route( + factory: $NewsShellRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: '/news/bookmarks', + hasOverriddenOnExit: false, + factory: $NewsBookmarksRoute._fromState, + ), + GoRouteData.$route( + path: '/news', + hasOverriddenOnExit: false, + factory: $NewsFeedRoute._fromState, + ), + GoRouteData.$route( + path: '/news/search', + hasOverriddenOnExit: false, + factory: $NewsSearchRoute._fromState, + ), + ], +); + +extension $NewsShellRouteExtension on NewsShellRoute { + static NewsShellRoute _fromState(GoRouterState state) => + const NewsShellRoute(); +} + +mixin $NewsBookmarksRoute on GoRouteData { + static NewsBookmarksRoute _fromState(GoRouterState state) => + const NewsBookmarksRoute(); + + @override + String get location => GoRouteData.$location('/news/bookmarks'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $NewsFeedRoute on GoRouteData { + static NewsFeedRoute _fromState(GoRouterState state) => const NewsFeedRoute(); + + @override + String get location => GoRouteData.$location('/news'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $NewsSearchRoute on GoRouteData { + static NewsSearchRoute _fromState(GoRouterState state) => + const NewsSearchRoute(); + + @override + String get location => GoRouteData.$location('/news/search'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $newsImageRoute => GoRouteData.$route( + path: '/news/image', + hasOverriddenOnExit: false, + factory: $NewsImageRoute._fromState, +); + +mixin $NewsImageRoute on GoRouteData { + static NewsImageRoute _fromState(GoRouterState state) => + NewsImageRoute($extra: state.extra as String); + + NewsImageRoute get _self => this as NewsImageRoute; + + @override + String get location => GoRouteData.$location('/news/image'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +RouteBase get $newsTagFeedRoute => GoRouteData.$route( + path: '/news/tag/:tagSlug', + hasOverriddenOnExit: false, + factory: $NewsTagFeedRoute._fromState, +); + +mixin $NewsTagFeedRoute on GoRouteData { + static NewsTagFeedRoute _fromState(GoRouterState state) => NewsTagFeedRoute( + tagSlug: state.pathParameters['tagSlug']!, + $extra: state.extra as String?, + ); + + NewsTagFeedRoute get _self => this as NewsTagFeedRoute; + + @override + String get location => + GoRouteData.$location('/news/tag/${Uri.encodeComponent(_self.tagSlug)}'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +RouteBase get $newsAuthorRoute => GoRouteData.$route( + path: '/news/author/:username', + hasOverriddenOnExit: false, + factory: $NewsAuthorRoute._fromState, +); + +mixin $NewsAuthorRoute on GoRouteData { + static NewsAuthorRoute _fromState(GoRouterState state) => NewsAuthorRoute( + username: state.pathParameters['username']!, + $extra: state.extra as Author?, + ); + + NewsAuthorRoute get _self => this as NewsAuthorRoute; + + @override + String get location => GoRouteData.$location( + '/news/author/${Uri.encodeComponent(_self.username)}', + ); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +RouteBase get $newsBookmarkPostRoute => GoRouteData.$route( + path: '/news/bookmarks/:id', + hasOverriddenOnExit: false, + factory: $NewsBookmarkPostRoute._fromState, +); + +mixin $NewsBookmarkPostRoute on GoRouteData { + static NewsBookmarkPostRoute _fromState(GoRouterState state) => + NewsBookmarkPostRoute( + id: state.pathParameters['id']!, + $extra: state.extra as BookmarkedPost, + ); + + NewsBookmarkPostRoute get _self => this as NewsBookmarkPostRoute; + + @override + String get location => + GoRouteData.$location('/news/bookmarks/${Uri.encodeComponent(_self.id)}'); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} + +RouteBase get $newsPostRoute => GoRouteData.$route( + path: '/news/:slug', + hasOverriddenOnExit: false, + factory: $NewsPostRoute._fromState, +); + +mixin $NewsPostRoute on GoRouteData { + static NewsPostRoute _fromState(GoRouterState state) => + NewsPostRoute(slug: state.pathParameters['slug']!); + + NewsPostRoute get _self => this as NewsPostRoute; + + @override + String get location => + GoRouteData.$location('/news/${Uri.encodeComponent(_self.slug)}'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/mobile-app/lib/routing/podcasts.dart b/mobile-app/lib/routing/podcasts.dart new file mode 100644 index 000000000..442b6588d --- /dev/null +++ b/mobile-app/lib/routing/podcasts.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:freecodecamp/podcasts/ui/downloads/downloads_view.dart'; +import 'package:freecodecamp/podcasts/ui/episode/episode_view.dart'; +import 'package:freecodecamp/podcasts/ui/episode_list/episode_list_view.dart'; +import 'package:freecodecamp/podcasts/ui/episode_list/episode_list_viewmodel.dart'; +import 'package:freecodecamp/podcasts/ui/podcast_list/podcast_list_view.dart'; +import 'package:freecodecamp/podcasts/ui/podcasts_shell.dart'; +import 'package:go_router/go_router.dart'; + +part 'podcasts.g.dart'; + +const podcastListPath = '/podcasts'; +const podcastDownloadsPath = '/podcasts/downloads'; +const podcastEpisodeListPath = '/podcasts/:podcastId'; +const podcastEpisodePath = '/podcasts/:podcastId/:episodeId'; + +@TypedShellRoute( + routes: [ + TypedGoRoute(path: podcastListPath), + TypedGoRoute(path: podcastDownloadsPath), + ], +) +class PodcastsShellRoute extends ShellRouteData { + const PodcastsShellRoute(); + + @override + Widget builder(BuildContext context, GoRouterState state, Widget navigator) { + return PodcastsShell(child: navigator); + } +} + +class PodcastListRoute extends GoRouteData with $PodcastListRoute { + const PodcastListRoute(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const NoTransitionPage(child: PodcastListView()); +} + +class PodcastDownloadsRoute extends GoRouteData with $PodcastDownloadsRoute { + const PodcastDownloadsRoute(); + + @override + Page buildPage(BuildContext context, GoRouterState state) => + const NoTransitionPage(child: PodcastDownloadsView()); +} + +@TypedGoRoute(path: podcastEpisodeListPath) +class PodcastEpisodeListRoute extends GoRouteData + with $PodcastEpisodeListRoute { + const PodcastEpisodeListRoute({ + required this.podcastId, + this.downloaded = false, + }); + + final String podcastId; + final bool downloaded; + + @override + Widget build(BuildContext context, GoRouterState state) => + PodcastEpisodeListView( + source: downloaded + ? PodcastEpisodeSource.downloaded(podcastId) + : PodcastEpisodeSource.remote(podcastId), + ); +} + +@TypedGoRoute(path: podcastEpisodePath) +class PodcastEpisodeRoute extends GoRouteData with $PodcastEpisodeRoute { + const PodcastEpisodeRoute({ + required this.podcastId, + required this.episodeId, + required this.$extra, + }); + + final String podcastId; + final String episodeId; + + // NOTE: Required rather than nullable. There is a single-episode endpoint + // that would let this route resolve from its path alone, but nothing + // deep-links to podcasts today and notification deep-linking is a + // post-migration feature, so the fallback is not built yet. Safe only while + // that stays true AND MaterialApp.router has no restorationScopeId. + final (Podcast, Episode) $extra; + + @override + Widget build(BuildContext context, GoRouterState state) => + PodcastEpisodeView(podcast: $extra.$1, episode: $extra.$2); +} diff --git a/mobile-app/lib/routing/podcasts.g.dart b/mobile-app/lib/routing/podcasts.g.dart new file mode 100644 index 000000000..de65cffd9 --- /dev/null +++ b/mobile-app/lib/routing/podcasts.g.dart @@ -0,0 +1,176 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'podcasts.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $podcastsShellRoute, + $podcastEpisodeListRoute, + $podcastEpisodeRoute, +]; + +RouteBase get $podcastsShellRoute => ShellRouteData.$route( + factory: $PodcastsShellRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: '/podcasts', + hasOverriddenOnExit: false, + factory: $PodcastListRoute._fromState, + ), + GoRouteData.$route( + path: '/podcasts/downloads', + hasOverriddenOnExit: false, + factory: $PodcastDownloadsRoute._fromState, + ), + ], +); + +extension $PodcastsShellRouteExtension on PodcastsShellRoute { + static PodcastsShellRoute _fromState(GoRouterState state) => + const PodcastsShellRoute(); +} + +mixin $PodcastListRoute on GoRouteData { + static PodcastListRoute _fromState(GoRouterState state) => + const PodcastListRoute(); + + @override + String get location => GoRouteData.$location('/podcasts'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $PodcastDownloadsRoute on GoRouteData { + static PodcastDownloadsRoute _fromState(GoRouterState state) => + const PodcastDownloadsRoute(); + + @override + String get location => GoRouteData.$location('/podcasts/downloads'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $podcastEpisodeListRoute => GoRouteData.$route( + path: '/podcasts/:podcastId', + hasOverriddenOnExit: false, + factory: $PodcastEpisodeListRoute._fromState, +); + +mixin $PodcastEpisodeListRoute on GoRouteData { + static PodcastEpisodeListRoute _fromState(GoRouterState state) => + PodcastEpisodeListRoute( + podcastId: state.pathParameters['podcastId']!, + downloaded: + _$convertMapValue( + 'downloaded', + state.uri.queryParameters, + _$boolConverter, + ) ?? + false, + ); + + PodcastEpisodeListRoute get _self => this as PodcastEpisodeListRoute; + + @override + String get location => GoRouteData.$location( + '/podcasts/${Uri.encodeComponent(_self.podcastId)}', + queryParams: { + if (_self.downloaded != false) 'downloaded': _self.downloaded.toString(), + }, + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +T? _$convertMapValue( + String key, + Map map, + T? Function(String) converter, +) { + final value = map[key]; + return value == null ? null : converter(value); +} + +bool _$boolConverter(String value) { + switch (value) { + case 'true': + return true; + case 'false': + return false; + default: + throw UnsupportedError('Cannot convert "$value" into a bool.'); + } +} + +RouteBase get $podcastEpisodeRoute => GoRouteData.$route( + path: '/podcasts/:podcastId/:episodeId', + hasOverriddenOnExit: false, + factory: $PodcastEpisodeRoute._fromState, +); + +mixin $PodcastEpisodeRoute on GoRouteData { + static PodcastEpisodeRoute _fromState(GoRouterState state) => + PodcastEpisodeRoute( + podcastId: state.pathParameters['podcastId']!, + episodeId: state.pathParameters['episodeId']!, + $extra: state.extra as (Podcast, Episode), + ); + + PodcastEpisodeRoute get _self => this as PodcastEpisodeRoute; + + @override + String get location => GoRouteData.$location( + '/podcasts/${Uri.encodeComponent(_self.podcastId)}/${Uri.encodeComponent(_self.episodeId)}', + ); + + @override + void go(BuildContext context) => context.go(location, extra: _self.$extra); + + @override + Future push(BuildContext context) => + context.push(location, extra: _self.$extra); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location, extra: _self.$extra); + + @override + void replace(BuildContext context) => + context.replace(location, extra: _self.$extra); +} diff --git a/mobile-app/lib/routing/router.dart b/mobile-app/lib/routing/router.dart new file mode 100644 index 000000000..787683fd0 --- /dev/null +++ b/mobile-app/lib/routing/router.dart @@ -0,0 +1,39 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:freecodecamp/routing/auth.dart' as auth_routes; +import 'package:freecodecamp/routing/code_radio.dart' as code_radio_routes; +import 'package:freecodecamp/routing/learn.dart' as learn_routes; +import 'package:freecodecamp/routing/news.dart' as news_routes; +import 'package:freecodecamp/routing/podcasts.dart' as podcasts_routes; +import 'package:freecodecamp/routing/settings.dart' as settings_routes; +import 'package:go_router/go_router.dart'; + +final _initialLocation = const learn_routes.LearnLandingRoute().location; + +final router = GoRouter( + debugLogDiagnostics: kDebugMode, + initialLocation: _initialLocation, + routes: [ + ...learn_routes.$appRoutes, + ...news_routes.$appRoutes, + ...code_radio_routes.$appRoutes, + ...podcasts_routes.$appRoutes, + ...auth_routes.$appRoutes, + ...settings_routes.$appRoutes, + ], + errorBuilder: (context, state) => Scaffold( + appBar: AppBar(title: const Text('Page Not Found')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText(state.error?.toString() ?? 'page not found'), + TextButton( + onPressed: () => context.go(_initialLocation), + child: const Text('Go Home'), + ), + ], + ), + ), + ), +); diff --git a/mobile-app/lib/routing/settings.dart b/mobile-app/lib/routing/settings.dart new file mode 100644 index 000000000..d61337cd1 --- /dev/null +++ b/mobile-app/lib/routing/settings.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/settings/ui/settings/delete_account/delete_account_view.dart'; +import 'package:freecodecamp/settings/ui/settings/settings_view.dart'; +import 'package:go_router/go_router.dart'; + +part 'settings.g.dart'; + +const settingsPath = '/settings'; +const settingsDeleteAccountPath = '/settings/delete-account'; + +@TypedGoRoute(path: settingsPath) +class SettingsRoute extends GoRouteData with $SettingsRoute { + const SettingsRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const SettingsView(); +} + +@TypedGoRoute(path: settingsDeleteAccountPath) +class SettingsDeleteAccountRoute extends GoRouteData + with $SettingsDeleteAccountRoute { + const SettingsDeleteAccountRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => + const DeleteAccountView(); +} diff --git a/mobile-app/lib/routing/settings.g.dart b/mobile-app/lib/routing/settings.g.dart new file mode 100644 index 000000000..ab17d8be1 --- /dev/null +++ b/mobile-app/lib/routing/settings.g.dart @@ -0,0 +1,62 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'settings.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [$settingsRoute, $settingsDeleteAccountRoute]; + +RouteBase get $settingsRoute => GoRouteData.$route( + path: '/settings', + hasOverriddenOnExit: false, + factory: $SettingsRoute._fromState, +); + +mixin $SettingsRoute on GoRouteData { + static SettingsRoute _fromState(GoRouterState state) => const SettingsRoute(); + + @override + String get location => GoRouteData.$location('/settings'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +RouteBase get $settingsDeleteAccountRoute => GoRouteData.$route( + path: '/settings/delete-account', + hasOverriddenOnExit: false, + factory: $SettingsDeleteAccountRoute._fromState, +); + +mixin $SettingsDeleteAccountRoute on GoRouteData { + static SettingsDeleteAccountRoute _fromState(GoRouterState state) => + const SettingsDeleteAccountRoute(); + + @override + String get location => GoRouteData.$location('/settings/delete-account'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/mobile-app/lib/service/audio/audio_service.dart b/mobile-app/lib/service/audio/audio_service.dart deleted file mode 100644 index 484cf8d2e..000000000 --- a/mobile-app/lib/service/audio/audio_service.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:freecodecamp/models/code-radio/code_radio_model.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -sealed class AudioTypeConfig { - const AudioTypeConfig(); -} - -class EpisodeAudioConfig extends AudioTypeConfig { - final String episodeId; - const EpisodeAudioConfig(this.episodeId); -} - -class CurriculumAudioConfig extends AudioTypeConfig { - final String curriculumAudioFile; - const CurriculumAudioConfig(this.curriculumAudioFile); -} - -class CodeRadioAudioConfig extends AudioTypeConfig { - const CodeRadioAudioConfig(); -} - -class AppAudioService { - static final AppAudioService _appAudioService = AppAudioService._internal(); - - late AudioPlayerHandler audioHandler; - - factory AppAudioService() { - return _appAudioService; - } - - Future init() async { - audioHandler = await AudioService.init( - builder: () => AudioPlayerHandler(), - config: const AudioServiceConfig( - androidNotificationChannelId: 'org.freecodecamp.channel.audio', - androidNotificationChannelName: 'Audio playback', - androidNotificationOngoing: true, - androidStopForegroundOnPause: true, - androidNotificationIcon: 'drawable/notification_icon', - notificationColor: FccColors.gray90, - ), - ); - } - - AppAudioService._internal(); -} - -class AudioPlayerHandler extends BaseAudioHandler { - final AudioPlayer _audioPlayer = AudioPlayer(); - - String _audioType = ''; - String _episodeId = ''; - String _curriculumAudioFile = ''; - - String get audioType => _audioType; - String get episodeId => _episodeId; - String get curriculumAudioFile => _curriculumAudioFile; - - StreamSubscription? cacheSub; - - @override - AudioPlayerHandler() { - _notifyAudioHandlerAboutPlaybackEvents(); - } - - @override - Future play() async { - // if (episode.id != _audioPlayer.audioSource?.sequence[0].tag.id) { - // log('DIFFERENT EPISODE'); - // await _audioPlayer.stop(); - // await loadEpisode(episode, isDownloaded); - // } - _audioPlayer.play(); - } - - @override - Future pause() async { - await _audioPlayer.pause(); - } - - @override - Future stop() async { - await _audioPlayer.stop(); - _audioType = ''; - _episodeId = ''; - _curriculumAudioFile = ''; - return super.stop(); - } - - @override - Future fastForward() async { - await _audioPlayer.seek( - Duration(seconds: _audioPlayer.position.inSeconds + 30), - ); - - return _audioPlayer.position.inSeconds; - } - - @override - Future rewind() async { - await _audioPlayer.seek( - Duration(seconds: _audioPlayer.position.inSeconds - 10), - ); - - return _audioPlayer.position.inSeconds; - } - - @override - Future seek(Duration position) async { - await _audioPlayer.seek(position); - } - - @override - Future setSpeed(double speed) async { - await _audioPlayer.setSpeed(speed); - } - - @override - Future onTaskRemoved() async { - await _audioPlayer.stop(); - return super.onTaskRemoved(); - } - - Duration? duration() { - return _audioPlayer.duration; - } - - double getSpeed() { - return _audioPlayer.speed; - } - - void setAudioCtx(AudioTypeConfig audioConfig) { - switch (audioConfig) { - case EpisodeAudioConfig(:final episodeId): - _audioType = 'episode'; - _episodeId = episodeId; - _curriculumAudioFile = ''; - break; - case CurriculumAudioConfig(:final curriculumAudioFile): - _audioType = 'curriculum'; - _episodeId = ''; - _curriculumAudioFile = curriculumAudioFile; - break; - case CodeRadioAudioConfig(): - _audioType = 'coderadio'; - _episodeId = ''; - _curriculumAudioFile = ''; - break; - } - } - - Future loadEpisode( - Episodes episode, - bool isDownloaded, - Podcasts podcast, - ) async { - Directory appDir = await getApplicationDocumentsDirectory(); - try { - MediaItem audioMediaItem = MediaItem( - id: episode.id, - title: episode.title, - album: podcast.title, - duration: episode.duration, - artUri: - File('${appDir.path}/images/podcast/${episode.podcastId}.jpg').uri, - ); - if (isDownloaded) { - await _audioPlayer.setAudioSource( - AudioSource.uri( - File('${appDir.path}/episodes/${episode.podcastId}/${episode.id}.mp3') - .uri, - tag: audioMediaItem, - ), - preload: true, - ); - } else { - await _audioPlayer.setAudioSource( - AudioSource.uri( - Uri.parse(episode.contentUrl!), - tag: audioMediaItem, - ), - preload: true, - ); - } - await _audioPlayer.load(); - - SharedPreferences prefs = await SharedPreferences.getInstance(); - int? possibleValue = prefs.getInt('${episode.id}_progress'); - if (possibleValue != null) { - _audioPlayer.seek(Duration(seconds: possibleValue)); - } else { - _audioPlayer.seek(Duration(seconds: 0)); - } - - setAudioCtx(EpisodeAudioConfig(episode.id)); - - queue.add([audioMediaItem]); - mediaItem.add(audioMediaItem); - - if (cacheSub != null) { - cacheSub!.cancel(); - } - - cacheSub = _audioPlayer.positionStream.listen((pos) async { - // position stream has an initial value of zero before seeking - // to the position that is stored in the cache. Which means - // "seconds" needs to be bigger than zero. - if (pos.inSeconds > 1) { - SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setInt('${episode.id}_progress', pos.inSeconds); - } - }); - } catch (e) { - log('loadEpisode: Cannot play audio: $e'); - } - } - - Future codeRadioMusic(CodeRadio radio) async { - try { - MediaItem currentSong = MediaItem( - id: radio.nowPlaying.id, - title: radio.nowPlaying.title, - album: radio.nowPlaying.album, - artUri: Uri.parse(radio.nowPlaying.artUrl), - ); - MediaItem nextSong = MediaItem( - id: radio.nextPlaying.id, - album: radio.nextPlaying.album, - title: radio.nextPlaying.title, - artUri: Uri.parse(radio.nextPlaying.artUrl), - ); - await _audioPlayer.setAudioSources([ - AudioSource.uri(Uri.parse(radio.listenUrl), tag: currentSong), - AudioSource.uri(Uri.parse(radio.listenUrl), tag: nextSong) - ]); - await _audioPlayer.load(); - setAudioCtx(const CodeRadioAudioConfig()); - queue.add([currentSong, nextSong]); - mediaItem.add(currentSong); - } catch (e) { - log('codeRadioMusic: Cannot play audio: $e'); - } - } - - bool isPlaying(String src, {String? episodeId}) { - if (src == 'podcast') { - return _audioPlayer.playing && - _audioPlayer.audioSource?.sequence[0].tag.id == episodeId && - _audioType == 'episode'; - } else if (src == 'coderadio') { - return _audioPlayer.playing && _audioType == 'coderadio'; - } else { - return false; - } - } - - Duration parseTimeStamp(String? timeStamp) { - if (timeStamp == null || timeStamp == '0') { - return const Duration(milliseconds: 0); - } - - return Duration( - milliseconds: (double.parse(timeStamp) * 1000).round(), - ); - } - - // TODO: Move to a common constants like file for curriculum stuff - String returnUrl(String fileName) { - return 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/sounds/$fileName'; - } - - bool canSeek(bool forward, int currentDuration, AudioClip audio) { - currentDuration = - currentDuration + parseTimeStamp(audio.startTimeStamp).inSeconds; - - if (forward) { - return currentDuration + 2 < - parseTimeStamp(audio.finishTimeStamp).inSeconds; - } else { - return currentDuration - 2 > - parseTimeStamp(audio.startTimeStamp).inSeconds; - } - } - - Future loadCurriculumAudio(AudioClip audio) async { - try { - await _audioPlayer.setAudioSource( - ClippingAudioSource( - start: parseTimeStamp(audio.startTimeStamp), - end: audio.finishTimeStamp == null - ? null - : parseTimeStamp(audio.finishTimeStamp), - child: AudioSource.uri( - Uri.parse(returnUrl(audio.fileName)), - ), - ), - ); - await _audioPlayer.load(); - setAudioCtx( - CurriculumAudioConfig( - '${audio.fileName}-${audio.startTimeStamp}-${audio.finishTimeStamp}', - ), - ); - } catch (e) { - log('loadCurriculumAudio: Cannot play audio: $e'); - } - } - - void _notifyAudioHandlerAboutPlaybackEvents() { - _audioPlayer.playbackEventStream.listen( - (PlaybackEvent event) { - final playing = _audioPlayer.playing; - playbackState.add( - playbackState.value.copyWith( - controls: [ - if (playing) MediaControl.pause else MediaControl.play, - MediaControl.stop, - ], - systemActions: const { - MediaAction.seek, - MediaAction.seekBackward, - MediaAction.seekForward, - }, - androidCompactActionIndices: const [0, 1], // CHECK - processingState: const { - ProcessingState.idle: AudioProcessingState.idle, - ProcessingState.loading: AudioProcessingState.loading, - ProcessingState.buffering: AudioProcessingState.buffering, - ProcessingState.ready: AudioProcessingState.ready, - ProcessingState.completed: AudioProcessingState.completed, - }[_audioPlayer.processingState]!, - repeatMode: AudioServiceRepeatMode.none, - shuffleMode: AudioServiceShuffleMode.none, - playing: playing, - updatePosition: _audioPlayer.position, - bufferedPosition: _audioPlayer.bufferedPosition, - speed: _audioPlayer.speed, - queueIndex: event.currentIndex, - ), - ); - }, - onError: (Object e, StackTrace st) { - log('Playback event error: $e\n$st'); - }, - ); - } -} diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart deleted file mode 100644 index 01d2ce430..000000000 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ /dev/null @@ -1,395 +0,0 @@ -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:auth0_flutter/auth0_flutter.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:stacked_services/stacked_services.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class AuthenticationService { - static final AuthenticationService _authenticationService = - AuthenticationService._internal(); - - SnackbarService snackbar = locator(); - final NavigationService _navigationService = locator(); - - final FlutterSecureStorage store = const FlutterSecureStorage(); - final Dio _dio = DioService.dio; - late final Auth0 auth0; - - String _csrf = ''; - String _csrfToken = ''; - String _jwtAccessToken = ''; - - String get csrf => _csrf; - String get csrfToken => _csrfToken; - String get jwtAccessToken => _jwtAccessToken; - - static String baseURL = ''; - static String baseApiURL = ''; - Future? userModel; - - bool isDevMode = false; - - static StreamController isLoggedInStream = - StreamController.broadcast(); - - static const String supportEmail = 'mobile@freecodecamp.org'; - - StreamController progress = StreamController.broadcast(); - - final Stream _isLoggedIn = isLoggedInStream.stream; - Stream get isLoggedIn => _isLoggedIn; - static bool staticIsloggedIn = false; - - factory AuthenticationService() { - return _authenticationService; - } - - Future hasRequiredTokens() async { - List requiredTokens = ['jwt_access_token', 'csrf_token', 'csrf']; - - for (String requiredToken in requiredTokens) { - if (await store.containsKey(key: requiredToken) == false || - await store.read(key: requiredToken) == null || - await store.read(key: requiredToken) == '') { - log('message: Missing token: $requiredToken'); - return false; - } - } - - return true; - } - - Future writeTokensToStorage() async { - store.write(key: 'csrf', value: _csrf); - store.write(key: 'csrf_token', value: _csrfToken); - store.write(key: 'jwt_access_token', value: _jwtAccessToken); - } - - Future setRequiredTokens() async { - _csrf = await store.read(key: 'csrf') as String; - _csrfToken = await store.read(key: 'csrf_token') as String; - _jwtAccessToken = await store.read(key: 'jwt_access_token') as String; - } - - void extractCookies(Response res) { - for (var cookie in res.headers['set-cookie']!) { - var parsedCookie = Cookie.fromSetCookieValue(cookie); - if (parsedCookie.name == '_csrf') { - _csrf = parsedCookie.value; - } - if (parsedCookie.name == 'csrf_token') { - _csrfToken = parsedCookie.value; - } - if (parsedCookie.name == 'jwt_access_token') { - _jwtAccessToken = parsedCookie.value; - } - } - } - - Future setCurrentClientMode() async { - await dotenv.load(); - - isDevMode = - dotenv.get('DEVELOPMENTMODE', fallback: '').toLowerCase() == 'true'; - baseURL = isDevMode - ? 'https://www.freecodecamp.dev' - : 'https://www.freecodecamp.org'; - baseApiURL = isDevMode - ? 'https://api.freecodecamp.dev' - : 'https://api.freecodecamp.org'; - } - - Future init() async { - await dotenv.load(); - auth0 = Auth0(dotenv.get('AUTH0_DOMAIN'), dotenv.get('AUTH0_CLIENT_ID')); - - await setCurrentClientMode(); - - if (await hasRequiredTokens()) { - log('message: Tokens found in storage'); - await setRequiredTokens(); - await fetchUser(); - } - } - - Future parseUserModel(Map data) async { - return FccUserModel.fromJson(data); - } - - Future login( - BuildContext context, - String connectionType, { - String? email, - String? otp, - }) async { - late final Credentials creds; - late final Response emailLoginRes; - Response? res; - - showDialog( - context: context, - barrierDismissible: false, - routeSettings: RouteSettings( - name: '/login/$connectionType', - ), - builder: (context) { - return PopScope( - canPop: false, - child: SimpleDialog( - title: Text(context.t.login_load_message), - contentPadding: const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 24.0), - backgroundColor: const Color(0xFF2A2A40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - children: const [ - Center( - child: CircularProgressIndicator(), - ), - ], - ), - ); - }, - ); - - try { - if (connectionType == 'email') { - emailLoginRes = await _dio.post( - 'https://${dotenv.get('AUTH0_DOMAIN')}/oauth/token', - data: { - 'client_id': dotenv.get('AUTH0_CLIENT_ID'), - 'grant_type': 'http://auth0.com/oauth/grant-type/passwordless/otp', - 'realm': 'email', - 'username': email, - 'otp': otp, - 'scope': 'openid profile email', - }, - ); - } else { - // NOTE: scheme is only needed for Android in debug mode, as the callback URL is different in that case - creds = await auth0 - .webAuthentication(scheme: kReleaseMode ? null : 'org.freecodecamp') - .login(useHTTPS: true, parameters: {'connection': connectionType}); - } - } on WebAuthenticationException catch (e) { - log('message: WebAuthenticationException: ${e.message}'); - - // NOTE: The most likely case is that the user canceled the login - snackbar.showSnackbar( - title: context.t.login_cancelled, - message: '', - ); - - logout(); - Navigator.pop(context); - - return false; - } on DioException catch (e) { - log(e.toString()); - logout(); - Navigator.pop(context); - return false; - } - - try { - String accessToken = connectionType == 'email' - ? emailLoginRes.data['access_token'] - : creds.accessToken; - res = await _dio.get( - '$baseApiURL/mobile-login', - options: Options( - headers: { - 'Authorization': 'Bearer $accessToken', - }, - ), - ); - extractCookies(res); - await writeTokensToStorage(); - await fetchUser(); - } on DioException catch (err, st) { - String subject = Uri.encodeComponent(context.t.login_email_error_subject); - Navigator.pop(context); - if (err.response != null) { - String body = Uri.encodeComponent(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.response!.data.toString(), - st.toString(), - )); - await showDialog( - context: context, - barrierDismissible: false, - routeSettings: const RouteSettings( - name: '/login/error', - ), - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF2A2A40), - title: Text(context.t.error_two), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - content: SingleChildScrollView( - child: SelectionArea( - child: Text(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.response!.data.toString(), - st.toString(), - )), - ), - ), - actions: [ - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () async { - logout(); - await launchUrl(Uri.parse( - 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); - Navigator.pop(context); - }, - child: Text(context.t.email_error), - ), - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () { - logout(); - Navigator.pop(context); - }, - child: Text(context.t.close), - ), - ], - ), - ); - } else { - String body = Uri.encodeComponent(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.toString(), - st.toString(), - )); - await showDialog( - context: context, - barrierDismissible: false, - routeSettings: const RouteSettings( - name: '/login/error', - ), - builder: (context) => AlertDialog( - backgroundColor: const Color(0xFF2A2A40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - title: Text(context.t.error_two), - content: SingleChildScrollView( - child: SelectionArea( - child: Text(context.t.login_email_error_message( - AuthenticationService.supportEmail, - err.toString(), - st.toString(), - )), - ), - ), - actions: [ - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () async { - logout(); - await launchUrl(Uri.parse( - 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); - Navigator.pop(context); - }, - child: Text(context.t.email_error), - ), - TextButton( - style: TextButton.styleFrom( - backgroundColor: const Color(0xFF0a0a23), - ), - onPressed: () { - logout(); - Navigator.pop(context); - }, - child: Text(context.t.close), - ), - ], - ), - ); - } - return false; - } - - await auth0.credentialsManager.clearCredentials(); - // ignore: unnecessary_null_comparison - if (res != null) { - Navigator.pop(context); - Navigator.pop(context); - } - return true; - } - - Future logout() async { - staticIsloggedIn = false; - isLoggedInStream.sink.add(false); - _csrf = ''; - _csrfToken = ''; - _jwtAccessToken = ''; - await store.delete(key: 'csrf'); - await store.delete(key: 'csrf_token'); - await store.delete(key: 'jwt_access_token'); - userModel = null; - } - - Future fetchUser() async { - late final Response res; - try { - res = await _dio.get( - '$baseApiURL/user/session-user', - options: Options( - headers: { - 'CSRF-Token': _csrfToken, - 'Cookie': 'jwt_access_token=$_jwtAccessToken; _csrf=$_csrf', - }, - ), - ); - - if (res.statusCode == 200 && res.data['result'] != '') { - userModel = parseUserModel(res.data['user'][res.data['result']]); - staticIsloggedIn = true; - isLoggedInStream.sink.add(true); - progress.add(true); - } else { - staticIsloggedIn = false; - isLoggedInStream.sink.add(false); - await logout(); - } - } on DioException { - staticIsloggedIn = false; - isLoggedInStream.sink.add(false); - await logout(); - } - } - - void routeToLogin([bool fromButton = false]) { - _navigationService.navigateTo( - Routes.nativeLoginView, - arguments: NativeLoginViewArguments( - fromButton: fromButton, - ), - ); - } - - AuthenticationService._internal(); -} diff --git a/mobile-app/lib/service/developer_service.dart b/mobile-app/lib/service/developer_service.dart deleted file mode 100644 index ff3226730..000000000 --- a/mobile-app/lib/service/developer_service.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -class DeveloperService { - Future developmentMode() async { - await dotenv.load(); - return dotenv.get('DEVELOPMENTMODE', fallback: '').toLowerCase() == 'true'; - } -} diff --git a/mobile-app/lib/service/dio_service.dart b/mobile-app/lib/service/dio_service.dart deleted file mode 100644 index a2a2d7c7b..000000000 --- a/mobile-app/lib/service/dio_service.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:pretty_dio_logger/pretty_dio_logger.dart'; - -class DioService { - static final DioService _dioService = DioService._internal(); - - static final Dio dio = Dio(); - - factory DioService() { - return _dioService; - } - - Future init() async { - await dotenv.load(); - bool isDevMode = - dotenv.get('DEVELOPMENTMODE', fallback: '').toLowerCase() == 'true'; - - if (isDevMode) { - dio.interceptors.add(PrettyDioLogger(responseBody: false)); - } - } - - DioService._internal(); -} diff --git a/mobile-app/lib/service/firebase/analytics_observer.dart b/mobile-app/lib/service/firebase/analytics_observer.dart deleted file mode 100644 index 015ba83b4..000000000 --- a/mobile-app/lib/service/firebase/analytics_observer.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'dart:developer'; - -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:flutter/widgets.dart'; -import 'package:freecodecamp/app/app.router.dart'; - -class AnalyticsObserver extends RouteObserver { - AnalyticsObserver({required this.analytics}); - - final FirebaseAnalytics analytics; - - void _sendScreenView(Route route) async { - String screenName = route.settings.name ?? 'could-not-find-view'; - - if (route.settings.arguments != null) { - String runtimeType = route.settings.arguments.runtimeType.toString(); - switch (runtimeType) { - case 'SuperBlockViewArguments': - final routeArgs = route.settings.arguments as SuperBlockViewArguments; - screenName += '/${routeArgs.superBlockDashedName}'; - break; - case 'NewsTutorialViewArguments': - final routeArgs = - route.settings.arguments as NewsTutorialViewArguments; - screenName += '/${routeArgs.slug}'; - break; - case 'NewsBookmarkTutorialViewArguments': - final routeArgs = - route.settings.arguments as NewsBookmarkTutorialViewArguments; - screenName += '/${routeArgs.tutorial.tutorialTitle}'; - break; - case 'ChallengeTemplateViewArguments': - final routeArgs = - route.settings.arguments as ChallengeTemplateViewArguments; - screenName += '/${routeArgs.challengeId}'; - break; - default: - screenName += '/could-not-find-view'; - } - } - log('Setting screen to $screenName'); - await analytics.logScreenView( - screenName: screenName, - screenClass: screenName, - ); - } - - @override - void didPush(Route route, Route? previousRoute) { - super.didPush(route, previousRoute); - _sendScreenView(route); - } - - @override - void didReplace({Route? newRoute, Route? oldRoute}) { - super.didReplace(newRoute: newRoute, oldRoute: oldRoute); - if (newRoute != null) { - _sendScreenView(newRoute); - } - } - - @override - void didPop(Route route, Route? previousRoute) { - super.didPop(route, previousRoute); - if (previousRoute != null) { - _sendScreenView(previousRoute); - } - } -} diff --git a/mobile-app/lib/service/firebase/analytics_service.dart b/mobile-app/lib/service/firebase/analytics_service.dart deleted file mode 100644 index c8ec8226a..000000000 --- a/mobile-app/lib/service/firebase/analytics_service.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:freecodecamp/service/firebase/analytics_observer.dart'; - -class AnalyticsService { - static final AnalyticsService _instance = AnalyticsService._internal(); - - static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance; - - factory AnalyticsService() { - return _instance; - } - - AnalyticsObserver getAnalyticsObserver() { - return AnalyticsObserver(analytics: _analytics); - } - - AnalyticsService._internal(); -} diff --git a/mobile-app/lib/service/firebase/remote_config_service.dart b/mobile-app/lib/service/firebase/remote_config_service.dart deleted file mode 100644 index d9b5ea7d3..000000000 --- a/mobile-app/lib/service/firebase/remote_config_service.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:firebase_crashlytics/firebase_crashlytics.dart'; -import 'package:firebase_remote_config/firebase_remote_config.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:freecodecamp/utils/upgrade_controller.dart'; -import 'package:upgrader/upgrader.dart'; - -class RemoteConfigService { - static final RemoteConfigService _instance = RemoteConfigService._internal(); - - static final remoteConfig = FirebaseRemoteConfig.instance; - static const _activationOverridesKey = 'activation_overrides'; - final String Function(String key) _getConfigString; - - factory RemoteConfigService() { - return _instance; - } - - @visibleForTesting - factory RemoteConfigService.withConfigReader( - String Function(String key) getConfigString, - ) { - return RemoteConfigService._internal(getConfigString: getConfigString); - } - - Future init() async { - try { - await remoteConfig.setConfigSettings( - RemoteConfigSettings( - fetchTimeout: const Duration(minutes: 1), - minimumFetchInterval: const Duration(hours: 1), - ), - ); - await remoteConfig.setDefaults({ - 'min_app_version': '7.5.6', - _activationOverridesKey: '{}', - }); - - await remoteConfig.fetchAndActivate(); - - remoteConfig.onConfigUpdated.listen((event) async { - await remoteConfig.activate(); - - // Update the min app version - UpgraderState currUpgradeState = upgraderController.state; - upgraderController.updateState(currUpgradeState.copyWith( - minAppVersion: Upgrader.parseVersion( - remoteConfig.getString('min_app_version'), - 'minAppVersion', - false, - ), - )); - }); - } on PlatformException catch (exception, stack) { - log('Platform exception - $exception'); - await FirebaseCrashlytics.instance.recordError( - exception, - stack, - reason: 'Remote Config Platform Exception', - ); - } catch (exception) { - log('Unable to fetch remote config. Cached or default values will be used $exception'); - } - } - - RemoteConfigService._internal({ - String Function(String key)? getConfigString, - }) : _getConfigString = getConfigString ?? remoteConfig.getString; - - bool isActive({ - required String superBlockDashedName, - String? blockDashedName, - required bool fallbackValue, - }) { - final override = getActivationOverride( - superBlockDashedName: superBlockDashedName, - blockDashedName: blockDashedName, - ); - return override ?? fallbackValue; - } - - bool? getActivationOverride({ - required String superBlockDashedName, - String? blockDashedName, - }) { - final overrideKey = blockDashedName == null - ? superBlockDashedName - : '$superBlockDashedName/$blockDashedName'; - return _getOverrideValue(key: overrideKey); - } - - bool? _getOverrideValue({ - required String key, - }) { - final overrides = _getOverridesMap(); - if (overrides == null) { - return null; - } - - final overrideValue = overrides[key]; - if (overrideValue is bool) { - return overrideValue; - } - - return null; - } - - Map? _getOverridesMap() { - try { - final String rawConfig = _getConfigString(_activationOverridesKey); - if (rawConfig.trim().isEmpty) { - return null; - } - - final dynamic decoded = jsonDecode(rawConfig); - if (decoded is! Map) { - return null; - } - return decoded; - } catch (exception) { - log( - 'Invalid remote config for $_activationOverridesKey. ' - 'Ignoring overrides: $exception', - ); - return null; - } - } -} diff --git a/mobile-app/lib/service/learn/daily_challenge_notification_service.dart b/mobile-app/lib/service/learn/daily_challenge_notification_service.dart deleted file mode 100644 index 9194ba4ec..000000000 --- a/mobile-app/lib/service/learn/daily_challenge_notification_service.dart +++ /dev/null @@ -1,384 +0,0 @@ -import 'dart:developer' as developer; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/services.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; - -class DailyChallengeNotificationService { - final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin; - final DailyChallengeService _dailyChallengeService; - final AuthenticationService _authenticationService; - final LocaleService _localeService; - Random random = Random(); - - static const String _channelId = 'daily_challenge_channel'; - static const int _maxDays = 7; - - DailyChallengeNotificationService({ - FlutterLocalNotificationsPlugin? flutterLocalNotificationsPlugin, - DailyChallengeService? dailyChallengeService, - AuthenticationService? authenticationService, - LocaleService? localeService, - }) : _flutterLocalNotificationsPlugin = flutterLocalNotificationsPlugin ?? - FlutterLocalNotificationsPlugin(), - _dailyChallengeService = - dailyChallengeService ?? locator(), - _authenticationService = - authenticationService ?? locator(), - _localeService = localeService ?? locator(); - - Future init() async { - tz.initializeTimeZones(); - - const AndroidInitializationSettings androidInitializationSettings = - AndroidInitializationSettings('notification_icon'); - const DarwinInitializationSettings iosInitializationSettings = - DarwinInitializationSettings(); - - const InitializationSettings initializationSettings = - InitializationSettings( - android: androidInitializationSettings, - iOS: iosInitializationSettings, - ); - - await _flutterLocalNotificationsPlugin.initialize( - settings: initializationSettings, - onDidReceiveNotificationResponse: _onNotificationResponse, - ); - } - - Future requestPermissionIfNeeded() async { - if (Platform.isAndroid) { - final androidImplementation = _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - - if (androidImplementation == null) { - return false; - } - - try { - return await androidImplementation.requestNotificationsPermission() ?? - false; - } on PlatformException catch (e, stackTrace) { - developer.log( - 'Failed to request Android notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } catch (e, stackTrace) { - developer.log( - 'Unexpected failure while requesting Android notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } - } - - if (Platform.isIOS) { - final iosImplementation = _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin>(); - - try { - return await iosImplementation?.requestPermissions( - alert: true, - badge: true, - sound: true, - ) ?? - false; - } on PlatformException catch (e, stackTrace) { - developer.log( - 'Failed to request iOS notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } catch (e, stackTrace) { - developer.log( - 'Unexpected failure while requesting iOS notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } - } - - return true; - } - - Future setupNotifications() async { - final permissionGranted = await requestPermissionIfNeeded(); - - // Auto-enable daily notifications if system permission is granted - // and user hasn't explicitly disabled them - if (permissionGranted) { - final prefs = await SharedPreferences.getInstance(); - final hasSetPreference = - prefs.containsKey('daily_challenge_notifications_enabled'); - - if (!hasSetPreference) { - // First time - enable notifications by default - await enableDailyNotifications(); - } else if (await areNotificationsEnabled()) { - // Re-schedule notifications if they were previously enabled - await scheduleDailyChallengeNotification(); - } - } - } - - Future requestPermissionAndConfigure() => setupNotifications(); - - void _onNotificationResponse(NotificationResponse response) { - if (response.payload == 'daily_challenge_notification') { - // Schedule the next notification - scheduleDailyChallengeNotification(); - } - } - - Future enableDailyNotifications() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('daily_challenge_notifications_enabled', true); - - await scheduleDailyChallengeNotification(); - } - - Future disableDailyNotifications() async { - await _flutterLocalNotificationsPlugin.cancelAll(); - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('daily_challenge_notifications_enabled', false); - } - - Future areNotificationsEnabled() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getBool('daily_challenge_notifications_enabled') ?? false; - } - - Future cancelAllNotifications() async { - await _flutterLocalNotificationsPlugin.cancelAll(); - } - - Future areSystemNotificationsEnabled() async { - if (Platform.isAndroid) { - final androidImplementation = _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - if (androidImplementation == null) { - return false; - } - - try { - return await androidImplementation.areNotificationsEnabled() ?? false; - } on PlatformException catch (e, stackTrace) { - developer.log( - 'Failed to check Android notification permission state.', - error: e, - stackTrace: stackTrace, - ); - return false; - } catch (e, stackTrace) { - developer.log( - 'Unexpected failure while checking Android notification permission state.', - error: e, - stackTrace: stackTrace, - ); - return false; - } - } - - // iOS does not let apps check notification status after permission is granted, so we always return true. - // If campers disable notifications in iOS Settings, scheduled notifications will not be shown. - return true; - } - - // This method orchestrates the scheduling of daily challenge notifications for the user. - // It ensures notifications are only scheduled if both user and system settings allow, and the notification window is still valid. - // The method determines the appropriate start date based on user progress and cached data, updates the cache, - // and schedules notifications for the next 7 days. If any prerequisite is not met, it exits early without scheduling. - Future scheduleDailyChallengeNotification() async { - if (!(await areNotificationsEnabled()) || - !(await areSystemNotificationsEnabled())) { - return; - } - - // Cancel any previous scheduled notifications - await _flutterLocalNotificationsPlugin.cancelAll(); - - final prefs = await SharedPreferences.getInstance(); - final now = DateTime.now(); - - // Check if notification window has expired - if (await hasNotificationWindowExpired(prefs, now)) { - return; - } - - // Determine when to start scheduling notifications - final startSchedulingFrom = await determineSchedulingStartDate(prefs, now); - await prefs.setString('notification_schedule_start_date', - startSchedulingFrom.toIso8601String()); - - // Schedule notifications for the next 7 days - await scheduleNotificationsForPeriod(startSchedulingFrom, now); - } - - Future hasNotificationWindowExpired( - SharedPreferences prefs, DateTime now) async { - final scheduleStartDateStr = - prefs.getString('notification_schedule_start_date'); - - if (scheduleStartDateStr != null) { - final scheduleStartDate = DateTime.parse(scheduleStartDateStr); - final daysSinceStart = now.difference(scheduleStartDate).inDays; - - // If more than 7 days have passed and user hasn't opened the app, - // we assume the user has stopped engaging and stop sending them notifications. - return daysSinceStart >= _maxDays; - } - - return false; - } - - // This method determines the correct start date for scheduling notifications. - // If the user has completed today's challenge, returns tomorrow's date. - // Otherwise, returns the cached start date if available, or today if not. - Future determineSchedulingStartDate( - SharedPreferences prefs, DateTime now) async { - final scheduleStartDateStr = - prefs.getString('notification_schedule_start_date'); - - final todayChallengeCompleted = await checkIfTodayChallengeCompleted(); - DateTime startSchedulingFrom; - - if (todayChallengeCompleted) { - // If today's challenge is completed, start from tomorrow - final tomorrow = now.add(const Duration(days: 1)); - - startSchedulingFrom = - DateTime(tomorrow.year, tomorrow.month, tomorrow.day); - } else { - // If not completed, use cache if available, else today - if (scheduleStartDateStr != null) { - startSchedulingFrom = DateTime.parse(scheduleStartDateStr); - } else { - startSchedulingFrom = DateTime(now.year, now.month, now.day); - } - } - - return startSchedulingFrom; - } - - Future checkIfTodayChallengeCompleted() async { - try { - final todayChallenge = await _dailyChallengeService.fetchTodayChallenge(); - final userModelFuture = _authenticationService.userModel; - - if (userModelFuture != null) { - try { - FccUserModel user = await userModelFuture; - for (CompletedDailyChallenge challenge - in user.completedDailyCodingChallenges) { - if (challenge.id == todayChallenge.id) { - return true; - } - } - } catch (e) { - return false; - } - } - return false; - } catch (e) { - // If we can't fetch today's challenge, assume it's not completed - return false; - } - } - - // This method schedules daily notifications for up to 7 days starting from the given date. - // For each day in the period, this method calculates the appropriate notification time and schedules a notification if the time is in the future. - // Notifications are set for 9 AM local time, and only future notifications are scheduled. - Future scheduleNotificationsForPeriod( - DateTime startSchedulingFrom, DateTime now) async { - final centralLocation = tz.getLocation('America/Chicago'); - final endDate = startSchedulingFrom.add(const Duration(days: 7)); - final t = lookupAppLocalizations(_localeService.locale); - int notificationId = 0; - - for (DateTime date = startSchedulingFrom; - date.isBefore(endDate); - date = date.add(const Duration(days: 1))) { - final scheduleTime = findNextValidNotificationTime(date, centralLocation); - - // Only schedule if the notification time is in the future - if (scheduleTime.isAfter(now)) { - try { - await _flutterLocalNotificationsPlugin.zonedSchedule( - id: notificationId++, - title: t.new_daily_challenge_available, - body: t.daily_challenge_notification_body, - scheduledDate: tz.TZDateTime.from(scheduleTime, tz.local), - notificationDetails: NotificationDetails( - android: AndroidNotificationDetails( - _channelId, - t.daily_challenge_notifications, - channelDescription: t.daily_challenge_notifications_description, - priority: Priority.high, - importance: Importance.max, - color: FccColors.gray90, - ), - iOS: const DarwinNotificationDetails( - threadIdentifier: 'daily-challenge-notification', - categoryIdentifier: 'DAILY_CHALLENGE', - ), - ), - androidScheduleMode: AndroidScheduleMode.inexact, - payload: 'daily_challenge_notification', - ); - } catch (e) { - // Log the error but continue scheduling other notifications - print('Failed to schedule notification for $scheduleTime: $e'); - } - } - } - } - - DateTime findNextValidNotificationTime( - DateTime scheduleDate, tz.Location centralLocation, - {DateTime? now}) { - final current = now ?? DateTime.now(); - - // Default to 9 AM on the scheduleDate. This is used if scheduleDate is not today - DateTime candidate = - DateTime(scheduleDate.year, scheduleDate.month, scheduleDate.day, 9); - - // If scheduleDate is today, adjust based on current time - if (scheduleDate.day == current.day && - scheduleDate.month == current.month && - scheduleDate.year == current.year) { - if (current.hour < 9) { - // Before 9 AM today, schedule for 9 AM today - candidate = DateTime(current.year, current.month, current.day, 9); - } else if (current.hour >= 21) { - // After 9 PM today, schedule for 9 AM tomorrow - candidate = DateTime(current.year, current.month, current.day + 1, 9); - } else { - // Between 9 AM and 9 PM, schedule for 9 AM tomorrow - candidate = DateTime(current.year, current.month, current.day + 1, 9); - } - } - - return candidate; - } -} diff --git a/mobile-app/lib/service/learn/daily_challenge_service.dart b/mobile-app/lib/service/learn/daily_challenge_service.dart deleted file mode 100644 index f29d69940..000000000 --- a/mobile-app/lib/service/learn/daily_challenge_service.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'dart:developer'; - -import 'package:dio/dio.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class DailyChallengeService { - static final DailyChallengeService _instance = - DailyChallengeService._internal(); - final Dio _dio = DioService.dio; - final _authenticationService = locator(); - - // Caching fields for getDailyChallenge method - DailyChallenge? _cachedDailyChallenge; - String? _cachedChallengeDate; - - factory DailyChallengeService() { - return _instance; - } - - DailyChallengeService._internal(); - - Future> fetchAllDailyChallenges() async { - final response = await _dio - .get('${AuthenticationService.baseApiURL}/daily-coding-challenge/all'); - - if (response.statusCode == 200 && response.data is List) { - return (response.data as List) - .map((item) => DailyChallengeOverview.fromJson(item)) - .toList(); - } else { - log('Failed to fetch daily challenges - Status: ${response.statusCode}'); - throw Exception('Failed to fetch daily challenges'); - } - } - - Future fetchChallengeByDate(String date) async { - // Convert YYYY-MM-DD to MM-DD for the year-agnostic /day/:day endpoint. - // Also accepts an already-converted MM-DD string. - final monthDay = - date.length == 10 ? date.substring(5) : date; - final response = await _dio.get( - '${AuthenticationService.baseApiURL}/daily-coding-challenge/day/$monthDay'); - - if (response.statusCode == 200) { - return DailyChallenge.fromJson(response.data); - } else { - log('Failed to fetch challenge for date: $date - Status: ${response.statusCode}'); - throw Exception('Failed to fetch challenge.'); - } - } - - Future fetchTodayChallenge() async { - final response = await _dio.get( - '${AuthenticationService.baseApiURL}/daily-coding-challenge/today'); - - if (response.statusCode == 200) { - return DailyChallenge.fromJson(response.data); - } else { - log('Failed to fetch today\'s challenge - Status: ${response.statusCode}'); - throw Exception('Failed to fetch challenge.'); - } - } - - Future postChallengeCompleted({ - required String challengeId, - required DailyChallengeLanguage language, - }) async { - final response = await _dio.post( - '${AuthenticationService.baseApiURL}/daily-coding-challenge-completed', - data: {'id': challengeId, 'language': language.toString()}, - options: Options( - headers: { - 'CSRF-Token': _authenticationService.csrfToken, - 'Cookie': - 'jwt_access_token=${_authenticationService.jwtAccessToken}; _csrf=${_authenticationService.csrf};', - }, - ), - ); - - if (response.statusCode != 200) { - log('Failed to post challenge completion for $challengeId - Status: ${response.statusCode}'); - throw Exception('Failed to post challenge.'); - } - } - - /* - Fetches daily challenge data and creates a Challenge object for the specified language. - Uses caching to avoid redundant API calls when switching between languages for the same date. - If no language is provided, uses the stored language preference. - */ - Future getDailyChallenge( - String date, - Block block, { - DailyChallengeLanguage? language, - }) async { - if (language == null) { - SharedPreferences prefs = await SharedPreferences.getInstance(); - String? selectedLangStr = - prefs.getString('selectedDailyChallengeLanguage'); - language = parseLanguageFromString(selectedLangStr); - } - - if (_cachedDailyChallenge == null || _cachedChallengeDate != date) { - // Temporarily cache the challenge data to avoid redundant API calls. - // But not saving the cached data to SharedPreferences - // so we can still get the up-to-date challenge. - _cachedDailyChallenge = await fetchChallengeByDate(date); - _cachedChallengeDate = date; - } - - DailyChallenge dailyChallenge = _cachedDailyChallenge!; - - // Select language-specific data - DailyChallengeLanguageData languageData; - HelpCategory helpCategory; - int challengeType; - - switch (language) { - case DailyChallengeLanguage.python: - languageData = dailyChallenge.python; - helpCategory = HelpCategory.python; - challengeType = 29; - break; - default: - languageData = dailyChallenge.javascript; - helpCategory = HelpCategory.javascript; - challengeType = 28; - break; - } - - // Map the daily challenge to Challenge - return Challenge( - id: dailyChallenge.id, - block: block.dashedName, - title: dailyChallenge.title, - description: dailyChallenge.description, - instructions: '', - dashedName: '', - superBlock: block.superBlock.dashedName, - videoId: null, - challengeType: challengeType, - tests: languageData.tests, - files: languageData.challengeFiles, - helpCategory: helpCategory, - explanation: '', - transcript: '', - hooks: Hooks.fromJson({'beforeAll': ''}), - fillInTheBlank: null, - audio: null, - scene: null, - questions: null, - assignments: null, - quizzes: null, - ); - } - - /// Helper method to parse DailyChallengeLanguage from string with fallback - static DailyChallengeLanguage parseLanguageFromString(String? langStr) { - if (langStr == null || langStr.isEmpty) { - return DailyChallengeLanguage.javascript; - } - - return DailyChallengeLanguage.values.firstWhere( - (e) => e.name == langStr, - orElse: () => DailyChallengeLanguage.javascript, - ); - } -} diff --git a/mobile-app/lib/service/learn/learn_file_service.dart b/mobile-app/lib/service/learn/learn_file_service.dart deleted file mode 100644 index 291b78057..000000000 --- a/mobile-app/lib/service/learn/learn_file_service.dart +++ /dev/null @@ -1,340 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:freecodecamp/enums/ext_type.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/views/learn/test_runner.dart'; -import 'package:html/dom.dart'; -import 'package:html/parser.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class LearnFileService { - String getFullFileName(ChallengeFile file) { - return '${file.name}.${file.ext.value}'; - } - - // This function returns a specific file content from the cache. - // If testing is enabled on the function it will return - // the first file with the given file name. - - Future getExactFileFromCache( - Challenge challenge, - ChallengeFile file, { - bool testing = false, - }) async { - String? cache; - - if (testing) { - cache = challenge.files - .firstWhere( - (element) => getFullFileName(element) == getFullFileName(file)) - .contents; - } else { - SharedPreferences prefs = await SharedPreferences.getInstance(); - cache = prefs.getString('${challenge.id}.${getFullFileName(file)}'); - } - - return cache ?? file.contents; - } - - void saveFileInCache( - Challenge challenge, - String currentSelectedFile, - String value, - ) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - await prefs.setString('${challenge.id}.$currentSelectedFile', value); - } - - // This function returns the first file content with the given extension from the - // cache. If testing is enabled it will return the file directly from the challenge. - // If a CSS extension is put as a parameter it will return the first HTML file instead. - - Future getFirstFileFromCache( - Challenge challenge, - Ext ext, { - bool testing = false, - }) async { - String fileContent = ''; - - if (testing) { - List firstHtmlChallenge = challenge.files - .where((file) => (file.ext == Ext.css || file.ext == Ext.html) - ? file.ext == Ext.html - : file.ext == ext) - .toList(); - fileContent = firstHtmlChallenge[0].contents; - } else { - List firstChallenge = challenge.files - .where((file) => (file.ext == Ext.css || file.ext == Ext.html) - ? file.ext == Ext.html - : file.ext == ext) - .toList(); - SharedPreferences prefs = await SharedPreferences.getInstance(); - - fileContent = prefs.getString( - '${challenge.id}.${getFullFileName(firstChallenge[0])}', - ) ?? - firstChallenge[0].contents; - } - - return fileContent; - } - - // this function will get the current file which is being edited. - // otherwise we can not detect which file is currently being worked on. This is only for the new RWD. - - Future getCurrentEditedFileFromCache( - Challenge challenge, { - bool testing = false, - }) async { - List? fileWithEditableRegion = challenge.files - .where((file) => file.editableRegionBoundaries.isNotEmpty) - .toList(); - - String? cache; - - if (testing) { - return fileWithEditableRegion.isNotEmpty - ? fileWithEditableRegion[0] - : challenge.files[0]; - } - - SharedPreferences prefs = await SharedPreferences.getInstance(); - - if (fileWithEditableRegion.isNotEmpty) { - cache = prefs.getString( - '${challenge.id}.${getFullFileName(fileWithEditableRegion[0])}', - ) ?? - ''; - - if (cache.isNotEmpty) { - ChallengeFile file = fileWithEditableRegion[0]; - - return ChallengeFile( - ext: file.ext, - name: file.name, - editableRegionBoundaries: file.editableRegionBoundaries, - contents: cache, - history: file.history, - fileKey: file.fileKey, - ); - } else { - return fileWithEditableRegion[0]; - } - } else { - return challenge.files[0]; - } - } - - // This function checks if the given document contains any link elements. - // If so check if the css file name corresponds with the names put in the array. - // If the file is linked return true. - - Future cssFileIsLinked( - String document, - String cssFileName, - ) async { - Document doc = parse(document); - - List links = doc.getElementsByTagName('LINK'); - - List linkedFileNames = []; - - if (links.isNotEmpty) { - for (Node node in links) { - if (node.attributes['href'] == null) continue; - - if (node.attributes['href']!.contains('/')) { - linkedFileNames.add(node.attributes['href']!.split('/').last); - } else if (node.attributes['href']!.isNotEmpty) { - linkedFileNames.add(node.attributes['href'] as String); - } - } - } - - return linkedFileNames.contains(cssFileName); - } - - // This function checks if the given document contains any script elements. - // If so check if the js file name corresponds with the names put in the array. - // If the file is linked return true. - - Future jsFileIsLinked( - String document, - String jsFileName, - ) async { - Document doc = parse(document); - - List scripts = doc.getElementsByTagName('SCRIPT'); - - List linkedFileNames = []; - - if (scripts.isNotEmpty) { - for (Node node in scripts) { - if (node.attributes['src'] == null) continue; - - if (node.attributes['src']!.contains('/')) { - linkedFileNames.add(node.attributes['src']!.split('/').last); - } else if (node.attributes['src']!.isNotEmpty) { - linkedFileNames.add(node.attributes['src'] as String); - } - } - } - - return linkedFileNames.contains(jsFileName); - } - - // This function puts the given css content in the same file as the HTML content. - // It will parse the current CSS content into style tags only if it is linked. - // If there is nothing to parse it will return the plain content document. - - Future parseCssDocumentsAsStyleTags( - Challenge challenge, - String content, { - bool testing = false, - }) async { - List cssFiles = challenge.files - .where( - (element) => element.ext == Ext.css, - ) - .toList(); - List cssFilesWithCache = []; - List tags = []; - - if (cssFiles.isNotEmpty) { - for (ChallengeFile file in cssFiles) { - String? cache = await getExactFileFromCache( - challenge, - file, - testing: testing, - ); - - if (!await cssFileIsLinked(content, getFullFileName(file))) { - continue; - } - - String handledFile = cache; - - cssFilesWithCache.add(handledFile); - } - - for (String contents in cssFilesWithCache) { - String tag = ''; - tags.add(tag); - } - - for (String tag in tags) { - content += tag; - } - - return content; - } - - return content; - } - - // This function puts the given js content in the same file as the HTML content. - // It will parse the current JS content into script tags only if it is linked. - // If there is nothing to parse it will return the plain content document. - - Future parseJsDocumentsAsScriptTags( - Challenge challenge, - String challengeContent, - InAppWebViewController? babelController, { - bool testing = false, - }) async { - List jsFiles = challenge.files - .where( - (element) => element.ext == Ext.js, - ) - .toList(); - - if (jsFiles.isNotEmpty) { - for (ChallengeFile file in jsFiles) { - String filename = getFullFileName(file); - String? fileContents = await getExactFileFromCache( - challenge, - file, - testing: testing, - ); - - if (!await jsFileIsLinked(challengeContent, filename)) { - continue; - } - - if (babelController == null) { - throw Exception('Babel controller is required to transpile JS code.'); - } - - final babelRes = await babelController.callAsyncJavaScript( - functionBody: ScriptBuilder.transpileScript, - arguments: {'code': fileContents}, - ); - - final skipBabelError = ScriptBuilder - .skipBabelErrorChallengeIds - .contains(challenge.id); - - if (babelRes?.error != null) { - if (!skipBabelError) { - throw Exception('Babel transpilation failed: ${babelRes?.error}'); - } - } - - final result = babelRes?.value as Map?; - if (result?['success'] == false) { - if (!skipBabelError) { - throw Exception('Babel transpilation failed: ${result?['error']}'); - } - } else { - fileContents = result?['code']; - } - - Document document = parse(challengeContent); - - Element scriptElement = - document.querySelector('script[src\$="$filename"]')!; - - scriptElement.text = fileContents; - - challengeContent = document.outerHtml; - } - } - - return challengeContent; - } - - String changeActiveFileLinks(String file) { - Document document = parse(file); - - List linkElements = document.querySelectorAll('LINK'); - List scripElements = document.querySelectorAll('SCRIPT'); - - if (scripElements.isEmpty && linkElements.isEmpty) return file; - - for (int i = 0; i < linkElements.length; i++) { - String? hrefValue = linkElements[i].attributes['href']; - - if (hrefValue == null) continue; - - if (hrefValue == 'styles.css' || hrefValue == './styles.css') { - linkElements[i].attributes.remove('href'); - - linkElements[i].attributes['data-href'] = 'styles.css'; - } - } - - for (int i = 0; i < scripElements.length; i++) { - String? srcValue = scripElements[i].attributes['src']; - - if (srcValue == null) continue; - - if (srcValue == 'script.js' || srcValue == './script.js') { - scripElements[i].attributes.remove('src'); - - scripElements[i].attributes['data-src'] = 'script.js'; - } - } - return document.outerHtml; - } -} diff --git a/mobile-app/lib/service/learn/learn_offline_service.dart b/mobile-app/lib/service/learn/learn_offline_service.dart deleted file mode 100644 index 9377f921f..000000000 --- a/mobile-app/lib/service/learn/learn_offline_service.dart +++ /dev/null @@ -1,416 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class ChallengeDownload { - const ChallengeDownload({ - required this.id, - required this.date, - required this.block, - }); - - final String id; - final String date; - final String block; - - factory ChallengeDownload.fromObject(Map challengeObject) { - return ChallengeDownload( - id: challengeObject['id'], - date: challengeObject['date'], - block: challengeObject['block'], - ); - } - - static toObject(ChallengeDownload challengeDownload) { - return { - 'id': challengeDownload.id, - 'date': challengeDownload.date, - 'block': challengeDownload.block, - }; - } -} - -class LearnOfflineService { - StreamController downloadStream = StreamController.broadcast(); - StreamSubscription? batchSub; - StreamSubscription? downloadSub; - - Timer? timer; - final _dio = DioService.dio; - - /* - This function will return an instance of every challenge download that has - previously occured when downloading. - - // TODO: check if it is neccessary to request all challenges and not just the block - */ - - Future> checkStoredChallenges() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - List? storedChallenges = prefs.getStringList('storedChallenges'); - - bool hasStoredChallenges = storedChallenges != null; - - if (!hasStoredChallenges) { - // prefs.setStringList('storedChallenges', []); - } else { - List converted = []; - - for (int i = 0; i < storedChallenges.length; i++) { - Map toObject = jsonDecode(storedChallenges[i]); - - converted.add(ChallengeDownload.fromObject(toObject)); - } - - return converted; - } - - return []; - } - - /* - This function will request the given url and return an instance of a challenge.s - */ - - Future getChallenge(String url, [String challengeId = '']) async { - Challenge challenge; - - Response res = await _dio.get(url); - if (res.statusCode == 200) { - challenge = Challenge.fromJson(res.data); - return challenge; - } else { - throw Exception('Please check your internet connection.'); - } - - // SharedPreferences prefs = await SharedPreferences.getInstance(); - // if (await hasInternet()) { - // Response res = await _dio.get(url); - - // if (prefs.getString(url) == null) { - // if (res.statusCode == 200) { - // challenge = Challenge.fromJson(res.data); - - // prefs.setString(url, jsonEncode(res.data)); - - // return challenge; - // } - // } - - // challenge = Challenge.fromJson( - // jsonDecode(prefs.getString(url) as String), - // ); - // } else { - // String? challengeStr = prefs.getString(challengeId); - // if (challengeStr == null) { - // throw Exception('No internet connection and no stored challenge'); - // } else { - // challenge = Challenge.fromJson(jsonDecode(challengeStr)); - // } - // } - // return challenge; - } - - /* - This function will download every challenges in a given amount of time in a - certain block. It will request these urls and update the respective stream - which is listen to on the front-end. - */ - - Future getChallengeBatch(Block block, List urls) async { - int index = 0; - Duration duration = const Duration(milliseconds: 2000); - - await cacheBlockInfo(block); - - timer = Timer.periodic(duration, (timer) async { - await storeDownloadedChallenge(await getChallenge(urls[index])); - - index += 1; - - if (!downloadStream.isClosed) { - downloadStream.sink.add( - index == 0 ? 1 / urls.length * 100 : index / urls.length * 100, - ); - } - - if (urls.length == index) { - timer.cancel(); - } - }); - - batchSub = downloadStream.stream.listen( - (event) { - log('percentage completed: $event'); - }, - ); - } - - /* - This function will store a downloaded challenge, it will return a future. - If an error occurs an instance of a Future.error is throw which should stop - the downloading. - - // TODO: check if downloading stops if an error occurs - */ - - Future> storeDownloadedChallenge(Challenge challenge) async { - try { - SharedPreferences prefs = await SharedPreferences.getInstance(); - Map challengeObj = Challenge.toJson(challenge); - - List downloads = await checkStoredChallenges(); - List> downloadObjects = []; - - List downloadIds = []; - - for (int i = 0; i < downloads.length; i++) { - if (downloads[i] == null) continue; - - downloadObjects.add(ChallengeDownload.toObject(downloads[i]!)); - - downloadIds.add(downloads[i]!.id); - } - - if (!downloadIds.contains(challenge.id)) { - downloadObjects.add({ - 'id': challenge.id, - 'date': DateTime.now().toString(), - 'block': challenge.block - }); - - await prefs.setStringList( - 'storedChallenges', - downloadObjects.map((e) => jsonEncode(e)).toList(), - ); - await prefs.setString(challenge.id, jsonEncode(challengeObj)); - } - - return Future.value('download completed'); - } catch (e) { - return Future.error( - Exception('unable to store challenge: \n ${e.toString()}'), - ); - } - } - - /* - This function wil cancel the download of challenges, it will delete challenges - that are already downloaded. It will also remove the correlated block. - */ - - Future cancelChallengeDownload(String block) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - List downloads = await checkStoredChallenges(); - List filteredDownloads = []; - - List> challengeObjects = []; - - await removeCachedBlock(block); - - if (downloads.isNotEmpty) { - for (int i = 0; i < downloads.length; i++) { - if (downloads[i]!.block != block) { - filteredDownloads.add(downloads[i]); - } - } - - for (int i = 0; i < filteredDownloads.length; i++) { - challengeObjects.add( - ChallengeDownload.toObject( - filteredDownloads[i]!, - ), - ); - } - - await prefs.setStringList( - 'storedChallenges', - challengeObjects.map((e) => jsonEncode(e)).toList(), - ); - } - } - - /* - This function checks if the user has internet, it makes a request to a given - URL, which will return an error if the request did not go through. - */ - - Future hasInternet() async { - try { - final result = await InternetAddress.lookup('google.com'); - if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { - return true; - } - } on SocketException catch (_) { - print('not connected'); - - return false; - } - - return false; - } - - /* - This function will cache the given block. - */ - - Future cacheBlockInfo(Block block) async { - try { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - Map blockToJson = {}; - List? storedBlocks = prefs.getStringList('storedBlocks'); - - if (storedBlocks == null) { - await prefs.setStringList('storedBlocks', [ - jsonEncode(blockToJson), - ]); - - log('it is empty'); - } else { - List newInfo = storedBlocks; - - newInfo.add(jsonEncode(blockToJson)); - - await prefs.setStringList( - 'storedBlocks', - newInfo, - ); - } - } catch (e) { - return Future.error('Could not download block info'); - } - } - - /* - This function will remove the cached block according to the given dashed name - of the block in question. If there is an error removing the block an error - will be thrown. - */ - - Future removeCachedBlock(String dashedBlockName) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - List? storedBlocks = prefs.getStringList('storedBlocks'); - - try { - if (storedBlocks != null) { - for (int i = 0; i < storedBlocks.length; i++) { - Map data = json.decode(storedBlocks[i]); - - Block block = Block.fromJson( - data, - data['description'], - data['dashedName'], - data['superBlock']['dashedName'], - data['superBlock']['name'], - ); - - if (dashedBlockName == block.dashedName) { - storedBlocks.removeAt(i); - - if (storedBlocks.isEmpty) { - await prefs.setStringList('storedBlocks', []); - } else { - await prefs.setStringList('storedBlocks', [...storedBlocks]); - } - - break; - } - } - } - } catch (e) { - throw Exception(e); - } - } - - /* - This function retrieves the cached blocks according to their correlated - superblock by giving the superblock's dashed name. If there are no cached - blocks on the gives superblock, it wil return an empty list. - */ - - Future?> getCachedBlocks( - String superBlockDashedName, - ) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - List? blocks = prefs.getStringList('storedBlocks'); - - List convertedBlocks = []; - - if (blocks != null) { - if (blocks.isEmpty) return []; - - for (int i = 0; i < blocks.length; i++) { - Map data = jsonDecode(blocks[i]); - - Block block = Block.fromJson( - data, - data['description'], - data['dashedName'], - data['superBlock']['dashedName'], - data['superBlock']['name'], - ); - - if (block.superBlock.dashedName == superBlockDashedName) { - convertedBlocks.add(block); - } - } - - return convertedBlocks; - } - - return []; - } - - /* - This function retrieves a list of superblock buttons which are used to build - the landing page. It firstly checks if there are any cached blocks, if so - get the superblock names correlated to the block in question. This will start - a for loop which will return the superblock buttons in a list. - */ - - Future> getCachedSuperblocks() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - List? blocks = prefs.getStringList('storedBlocks'); - - List> superBlockNames = []; - - List buttons = []; - - if (blocks != null) { - for (int i = 0; i < blocks.length; i++) { - Map data = jsonDecode(blocks[i]); - - if (!superBlockNames.contains(data['superBlock']['dashedName'])) { - superBlockNames.add([ - data['superBlock']['dashedName'], - data['superBlock']['name'], - ]); - } - } - } - - for (int i = 0; i < superBlockNames.length; i++) { - buttons.add( - SuperBlockButtonData( - path: superBlockNames[i][0], - name: superBlockNames[i][1], - public: true, - ), - ); - } - - return buttons; - } -} diff --git a/mobile-app/lib/service/learn/learn_service.dart b/mobile-app/lib/service/learn/learn_service.dart deleted file mode 100644 index 4082fd6fb..000000000 --- a/mobile-app/lib/service/learn/learn_service.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:developer'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/enums/dialog_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/utils/helpers.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked_services/stacked_services.dart'; -import 'package:url_launcher/url_launcher.dart'; - -const forumLocation = 'https://forum.freecodecamp.org'; - -class LearnService { - static final LearnService _learnService = LearnService._internal(); - final _authenticationService = locator(); - - final Dio _dio = DioService.dio; - - static final baseUrl = '${AuthenticationService.baseURL}/curriculum-data/v2'; - - final LearnOfflineService learnOfflineService = - locator(); - final NavigationService _navigationService = locator(); - final DialogService _dialogService = locator(); - final DailyChallengeService _dailyChallengeService = - locator(); - final DailyChallengeNotificationService _notificationService = - locator(); - - factory LearnService() { - return _learnService; - } - - void setLastVisitedChallenge(String url, Block block) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setStringList('lastVisitedChallenge', [ - url, - block.superBlock.dashedName, - block.superBlock.name, - block.dashedName, - ]); - } - - Future postChallengeCompleted( - Challenge challenge, { - List? challengeFiles, - String? solutionLink, - }) async { - String challengeId = challenge.id; - int challengeType = challenge.challengeType; - - Response submitTypesRes = await _dio.get('$baseUrl/submit-types.json'); - Map submitTypes = submitTypesRes.data; - - switch (submitTypes[challengeType.toString()]) { - case 'tests': - late Map payload; - if (challengeType == 14 || - challenge.block == - 'javascript-algorithms-and-data-structures-projects') { - payload = { - 'id': challengeId, - 'challengeType': challengeType, - 'files': challengeFiles, - }; - } else { - payload = { - 'id': challengeId, - 'challengeType': challengeType, - }; - } - Response res = await _dio.post( - '${AuthenticationService.baseApiURL}/modern-challenge-completed', - data: payload, - options: Options( - headers: { - 'CSRF-Token': _authenticationService.csrfToken, - 'Cookie': - 'jwt_access_token=${_authenticationService.jwtAccessToken}; _csrf=${_authenticationService.csrf};', - }, - ), - ); - log(res.toString()); - break; - case 'backend': - break; - case 'project.backEnd': - Map payload = { - 'id': challengeId, - 'challengeType': challengeType, - 'solution': solutionLink - }; - Response res = await _dio.post( - '${AuthenticationService.baseApiURL}/project-completed', - data: payload, - options: Options( - headers: { - 'CSRF-Token': _authenticationService.csrfToken, - 'Cookie': - 'jwt_access_token=${_authenticationService.jwtAccessToken}; _csrf=${_authenticationService.csrf};', - }, - ), - ); - log(res.toString()); - break; - case 'project.frontEnd': - break; - } - await _authenticationService.fetchUser(); - } - - void passChallenge( - Challenge challenge, - String? solutionLink, - ) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - List challengeFiles = challenge.files.map((file) { - return { - 'contents': - prefs.getString('${challenge.id}.${file.name}.${file.ext.value}') ?? - file.contents, - 'ext': file.ext.name, - 'history': file.history, - 'key': file.fileKey, - 'name': file.name, - }; - }).toList(); - - await postChallengeCompleted( - challenge, - challengeFiles: challengeFiles, - solutionLink: solutionLink, - ); - } - - void passDailyChallenge(Challenge challenge) async { - await _dailyChallengeService.postChallengeCompleted( - challengeId: challenge.id, - language: challenge.challengeType == 28 - ? DailyChallengeLanguage.javascript - : DailyChallengeLanguage.python); - - await _authenticationService.fetchUser(); - - // Refresh notifications if today's challenge was completed - try { - final todayChallenge = await _dailyChallengeService.fetchTodayChallenge(); - - if (challenge.id == todayChallenge.id) { - await _notificationService.scheduleDailyChallengeNotification(); - } - } catch (e) { - log('Failed to check today\'s challenge for notification update: $e'); - } - } - - void goToNextChallenge( - int maxChallenges, - Challenge challenge, - Block block, { - String solutionLink = '', - }) async { - if (AuthenticationService.staticIsloggedIn) { - if (challenge.challengeType == 28 || challenge.challengeType == 29) { - passDailyChallenge(challenge); - _navigationService.back(); - return; - } else { - passChallenge(challenge, solutionLink); - } - } - - var challengeIndex = block.challengeTiles.indexWhere( - (element) => element.id == challenge.id, - ); - if (challengeIndex == maxChallenges - 1) { - _navigationService.back(); - } else { - _navigationService.replaceWith( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - challengeId: block.challengeTiles[challengeIndex + 1].id, - block: block, - ), - ); - } - } - - Future genForumLink( - Challenge challenge, - Block block, - String description, - BuildContext context, { - String editorText = '', - }) async { - Challenge? currChallenge = challenge; - - final HelpCategory helpCategory = challenge.helpCategory; - final String blockTitle = block.name; - - final userDeviceInfo = await getDeviceInfo(context); - - final titleText = '$blockTitle - ${currChallenge.title}'; - final t = context.t; - final String endingText = - '**${t.forum_mobile_info}**\n```txt\n$userDeviceInfo\n```\n\n**${t.forum_challenge}** $titleText\n\n**${t.forum_challenge_link}**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; - - final String userCode = await filesToMarkdown(currChallenge, editorText); - - final String textMessage = - '**${t.forum_tell_us_heading}**\n${t.forum_describe_issue} \n\n$description \n\n**${t.forum_code_so_far}**$userCode\n\n$endingText'; - final String altTextMessage = - '**${t.forum_tell_us_heading}**\n\n\n\n**${t.forum_code_so_far}**\n\n${t.forum_warning}\n\n${t.forum_code_too_long}\n\n${t.forum_additional_step}\n\n${t.forum_copy_editor_code}\n\n```\n${t.forum_replace_code}\n\n```\n$endingText'; - - String studentCode = Uri.encodeComponent(textMessage); - String altStudentCode = Uri.encodeComponent(altTextMessage); - - final String baseURL = - '$forumLocation/new-topic?category=${helpCategory.value}&title=$titleText&body='; - final String defaultURL = '$baseURL$studentCode'; - final String altURL = '$baseURL$altStudentCode'; - - return defaultURL.length < 8000 ? defaultURL : altURL; - } - - Future forumHelpDialog( - Challenge challenge, - Block block, - BuildContext context, - ) async { - DialogResponse? res = await _dialogService.showCustomDialog( - barrierDismissible: true, - variant: DialogType.askForHelp, - title: context.t.ask_for_help, - description: context.t.forum_help_description, - mainButtonTitle: context.t.forum_create_post, - data: {'challengeName': challenge.title, 'blockName': block.name}); - if (res != null && res.confirmed) { - DialogResponse? forumRes = await _dialogService.showCustomDialog( - variant: DialogType.askForHelpInput, - title: context.t.forum_create_post, - description: context.t.forum_confirm_description, - mainButtonTitle: context.t.solution_link_submit, - secondaryButtonTitle: context.t.cancel, - data: {'challengeName': challenge.title, 'blockName': block.name}, - ); - - String description = forumRes?.data ?? ''; - - if (forumRes != null && forumRes.confirmed) { - try { - final forumLink = await genForumLink( - challenge, - block, - description, - context, - ); - - await launchUrl(Uri.parse(forumLink)); - } catch (e) { - log('Error launching forum link: $e'); - } - } - } - } - - LearnService._internal(); -} diff --git a/mobile-app/lib/service/learn/scene_assets_service.dart b/mobile-app/lib/service/learn/scene_assets_service.dart deleted file mode 100644 index f4e2fef51..000000000 --- a/mobile-app/lib/service/learn/scene_assets_service.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'dart:developer' as developer; - -import 'package:dio/dio.dart'; -import 'package:freecodecamp/models/learn/scene_assets_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; - -class SceneAssetsService { - static final SceneAssetsService _instance = SceneAssetsService._internal(); - factory SceneAssetsService() => _instance; - SceneAssetsService._internal(); - - final Dio _dio = DioService.dio; - static final _baseUrl = '${AuthenticationService.baseURL}/curriculum-data/v2'; - - SceneAssets? _cachedAssets; - bool _isLoading = false; - - SceneAssets? get cachedAssets => _cachedAssets; - bool get isLoaded => _cachedAssets != null; - - Future fetchSceneAssets() async { - if (_cachedAssets != null) return _cachedAssets; - if (_isLoading) return null; - - _isLoading = true; - - try { - developer.log('Fetching scene assets...', name: 'SceneAssetsService'); - final response = await _dio.get('$_baseUrl/scene-assets.json'); - _cachedAssets = SceneAssets.fromJson(response.data); - developer.log( - 'Scene assets loaded: ${_cachedAssets!.characterAssets.length} characters', - name: 'SceneAssetsService', - ); - return _cachedAssets; - } catch (e) { - developer.log('Failed to fetch scene assets: $e', name: 'SceneAssetsService'); - return null; - } finally { - _isLoading = false; - } - } - - CharacterAssets? getCharacterAssets(String characterName) { - return _cachedAssets?.characterAssets[characterName]; - } - - String? getBackgroundUrl(String backgroundName) { - if (_cachedAssets == null) return null; - final cleanName = backgroundName.endsWith('.png') ? backgroundName : '$backgroundName.png'; - return '${_cachedAssets!.backgrounds}/$cleanName'; - } -} diff --git a/mobile-app/lib/service/locale_service.dart b/mobile-app/lib/service/locale_service.dart deleted file mode 100644 index 5cbe99aee..000000000 --- a/mobile-app/lib/service/locale_service.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -class LocaleService { - Locale locale = const Locale('en'); - - // supported locales - List locales = [ - // Enlish - const Locale('en'), - // Spanish - const Locale('es'), - // Portuguese - const Locale('pt'), - ]; - - List localeNames = [ - 'English', - 'Spanish', - 'Portuguese', - ]; - - String currentLocaleName = 'English'; - - Stream get localeStream => _localeController.stream; - final _localeController = StreamController.broadcast(); - - void changeLocale(String locale, {isLocaleCode = false}) async { - int localeIndex = 0; - final SharedPreferences prefs = await SharedPreferences.getInstance(); - - if (!isLocaleCode) { - localeIndex = localeNames.indexWhere((element) { - return locale == element; - }); - - currentLocaleName = localeNames[localeIndex]; - } else { - int index = locales.indexWhere((element) { - return locale == element.languageCode; - }); - - currentLocaleName = localeNames[index]; - } - - this.locale = Locale.fromSubtags( - languageCode: isLocaleCode ? locale : locales[localeIndex].languageCode, - ); - - await prefs.setString( - 'locale', - isLocaleCode ? locale : locales[localeIndex].languageCode, - ); - - _localeController.sink.add(this.locale); - } - - void init() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - String? locale = prefs.getString('locale'); - - if (locale != null) { - changeLocale(locale, isLocaleCode: true); - } - } -} diff --git a/mobile-app/lib/service/navigation/quick_actions_service.dart b/mobile-app/lib/service/navigation/quick_actions_service.dart deleted file mode 100644 index aa6401333..000000000 --- a/mobile-app/lib/service/navigation/quick_actions_service.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:io'; - -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:quick_actions/quick_actions.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class QuickActionsService { - static final QuickActionsService _quickActionsService = - QuickActionsService._internal(); - - QuickActions quickActions = const QuickActions(); - - final NavigationService _navigationService = locator(); - - factory QuickActionsService() { - return _quickActionsService; - } - - Future init() async { - final t = lookupAppLocalizations(locator().locale); - - await quickActions.setShortcutItems([ - ShortcutItem( - type: 'action_daily_challenges', - localizedTitle: t.quick_action_daily_challenges, - ), - ShortcutItem( - type: 'action_learn', - localizedTitle: t.learn, - ), - ShortcutItem( - type: 'action_tutorials', - localizedTitle: t.tutorials, - ), - if (!Platform.isIOS) - ShortcutItem( - type: 'action_code_radio', - localizedTitle: t.code_radio, - ), - ShortcutItem( - type: 'action_podcasts', - localizedTitle: t.podcasts, - ), - ]); - - await quickActions.initialize((shortcutType) { - switch (shortcutType) { - case 'action_tutorials': - _navigationService.replaceWith(Routes.newsViewHandlerView); - break; - case 'action_learn': - _navigationService.replaceWith(Routes.learnLandingView); - break; - case 'action_code_radio': - _navigationService.replaceWith(Routes.codeRadioView); - break; - case 'action_podcasts': - _navigationService.replaceWith(Routes.podcastListView); - break; - case 'action_daily_challenges': - _navigationService.replaceWith(Routes.dailyChallengeView); - break; - default: - } - }); - } - - QuickActionsService._internal(); -} diff --git a/mobile-app/lib/service/news/api_service.dart b/mobile-app/lib/service/news/api_service.dart deleted file mode 100644 index e52b1c0e8..000000000 --- a/mobile-app/lib/service/news/api_service.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:graphql/client.dart'; - -typedef ApiData = ({String endCursor, bool hasNextPage, List posts}); -typedef GetAllPostsT = Future; - -const postsPerPage = 20; - -const postFieldsFragment = r''' - fragment PostFields on Post { - id - slug - title - url - author { - id - username - name - bio { - text - } - profilePicture - socialMediaLinks { - website - twitter - facebook - } - location - } - tags { - id - name - slug - } - coverImage { - url - } - brief - readTimeInMinutes - content { - html - } - seo { - description - } - publishedAt - updatedAt - } - '''; - -const getAllPostsQuery = postFieldsFragment + - r''' - query GetAllPosts($publicationId: ObjectId!, $first: Int!, $after: String) { - publication(id: $publicationId) { - id - posts(first: $first, after: $after) { - edges { - node { - ...PostFields - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - } - '''; - -const getAuthorQuery = r''' - query GetAuthor($authorSlug: String!) { - user(username: $authorSlug) { - id - username - name - bio { - text - } - profilePicture - socialMediaLinks { - website - twitter - facebook - } - location - } - } - '''; - -const getPostsByAuthorQuery = postFieldsFragment + - r''' - query GetPostsByAuthorQuery($first: Int!, $after: String, $filter: SearchPostsOfPublicationFilter!) { - searchPostsOfPublication(first: $first, after: $after, filter: $filter) { - edges { - node { - ...PostFields - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - '''; - -const getPostsByTagQuery = postFieldsFragment + - r''' - query GetPostsByTagQuery($publicationId: ObjectId!, $first: Int!, $after: String, $filter: PublicationPostConnectionFilter!) { - publication(id: $publicationId) { - id - posts(first: $first, after: $after, filter: $filter) { - edges { - node { - ...PostFields - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - } - '''; - -const getPostQuery = postFieldsFragment + - r''' - query GetPost($id: ID!) { - post(id: $id) { - ...PostFields - } - } - '''; - -class NewsApiService { - static final NewsApiService _instance = NewsApiService._internal(); - factory NewsApiService() => _instance; - - HttpLink apiLink = HttpLink('https://gql-beta.hashnode.com'); - late final GraphQLClient client; - late final String publicationId; - - Future init() async { - // TODO: Initialize dotenv in main once for the entire app - await dotenv.load(); - - client = GraphQLClient( - link: apiLink, - cache: GraphQLCache(), - ); - publicationId = dotenv.get('HASHNODE_PUBLICATION_ID'); - } - - GetAllPostsT getAllPosts({String afterCursor = ''}) async { - final result = await client.query( - QueryOptions( - document: gql(getAllPostsQuery), - variables: { - 'publicationId': publicationId, - 'first': postsPerPage, - 'after': afterCursor, - }, - ), - ); - - if (result.hasException) { - throw Exception(result.exception.toString()); - } - - final List posts = result.data!['publication']['posts']['edges']; - final String endCursor = - result.data!['publication']['posts']['pageInfo']['endCursor']; - final bool hasNextPage = - result.data!['publication']['posts']['pageInfo']['hasNextPage']; - - return ( - posts: posts, - endCursor: endCursor, - hasNextPage: hasNextPage, - ); - } - - Future> getPost(String postId) async { - final result = await client.query( - QueryOptions( - document: gql(getPostQuery), - variables: { - 'id': postId, - }, - ), - ); - - if (result.hasException) { - throw Exception(result.exception.toString()); - } - - final Map post = result.data!['post']; - - return post; - } - - Future> getAuthor(String authorSlug) async { - final result = await client.query( - QueryOptions( - document: gql(getAuthorQuery), - variables: { - 'authorSlug': authorSlug, - }, - ), - ); - - if (result.hasException) { - throw Exception(result.exception.toString()); - } - - final Map author = result.data!['user']; - - return author; - } - - GetAllPostsT getPostsByAuthor( - String authorId, { - String afterCursor = '', - }) async { - final result = await client.query( - QueryOptions( - document: gql(getPostsByAuthorQuery), - variables: { - 'first': postsPerPage, - 'after': afterCursor, - 'filter': { - 'publicationId': publicationId, - 'authorIds': [authorId], - }, - }, - ), - ); - - if (result.hasException) { - throw Exception(result.exception.toString()); - } - - final List posts = - result.data!['searchPostsOfPublication']['edges']; - final String endCursor = - result.data!['searchPostsOfPublication']['pageInfo']['endCursor']; - final bool hasNextPage = - result.data!['searchPostsOfPublication']['pageInfo']['hasNextPage']; - - return ( - posts: posts, - endCursor: endCursor, - hasNextPage: hasNextPage, - ); - } - - GetAllPostsT getPostsByTag( - String tagSlug, { - String afterCursor = '', - }) async { - final result = await client.query( - QueryOptions( - document: gql(getPostsByTagQuery), - variables: { - 'publicationId': publicationId, - 'first': postsPerPage, - 'after': afterCursor, - 'filter': { - 'tagSlugs': [tagSlug], - }, - }, - ), - ); - - if (result.hasException) { - throw Exception(result.exception.toString()); - } - - final List posts = result.data!['publication']['posts']['edges']; - final String endCursor = - result.data!['publication']['posts']['pageInfo']['endCursor']; - final bool hasNextPage = - result.data!['publication']['posts']['pageInfo']['hasNextPage']; - - return ( - posts: posts, - endCursor: endCursor, - hasNextPage: hasNextPage, - ); - } - - NewsApiService._internal(); -} diff --git a/mobile-app/lib/service/news/bookmark_service.dart b/mobile-app/lib/service/news/bookmark_service.dart deleted file mode 100644 index 03e472141..000000000 --- a/mobile-app/lib/service/news/bookmark_service.dart +++ /dev/null @@ -1,230 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/legacy/bookmark_sqlite_migrator.dart'; -import 'package:freecodecamp/service/storage/json_file_store.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; - -class BookmarksDatabaseService { - BookmarksDatabaseService({Directory? storageDirectoryOverride}) - : _storageDirectoryOverride = storageDirectoryOverride; - - final Directory? _storageDirectoryOverride; - final _legacyMigrator = const BookmarkSqliteMigrator(); - - JsonFileStore< - ({ - int version, - bool migratedFromSqlite, - List bookmarks, - })>? _store; - Future? _initFuture; - - static const int _storeVersion = 1; - - Future initialise() { - _initFuture ??= _initialiseInternal(); - return _initFuture!; - } - - Future _initialiseInternal() async { - final baseDir = - _storageDirectoryOverride ?? await getApplicationDocumentsDirectory(); - - final file = File( - path.join(baseDir.path, 'storage', 'bookmarked-articles.json'), - ); - final store = JsonFileStore< - ({ - int version, - bool migratedFromSqlite, - List bookmarks, - })>( - file: file, - defaultValue: ( - version: _storeVersion, - migratedFromSqlite: false, - bookmarks: [], - ), - fromJson: _fromStoreJson, - toJson: _toStoreJson, - ); - await store.ensureExists(); - _store = store; - - await _migrateFromSqliteIfNeeded(); - } - - Future _migrateFromSqliteIfNeeded() async { - final store = _store; - if (store == null) return; - - await store.updateAndWrite((current) async { - if (current.migratedFromSqlite) return current; - - // If the JSON store already has data, don't overwrite it. - if (current.bookmarks.isNotEmpty) { - return ( - version: current.version, - migratedFromSqlite: true, - bookmarks: current.bookmarks, - ); - } - - final legacy = await _legacyMigrator.readBookmarks(); - if (legacy.isEmpty) { - return ( - version: current.version, - migratedFromSqlite: true, - bookmarks: current.bookmarks, - ); - } - - final normalized = legacy.map((row) { - return BookmarkedTutorial.fromMap({ - 'articleId': row['articleId'], - 'articleTitle': row['articleTitle'], - 'authorName': row['authorName'], - 'articleText': row['articleText'], - }); - }).toList(); - - log('Migrated ${normalized.length} bookmarks from SQLite to JSON'); - return ( - version: _storeVersion, - migratedFromSqlite: true, - bookmarks: normalized, - ); - }); - } - - static ({ - int version, - bool migratedFromSqlite, - List bookmarks, - }) _fromStoreJson(JsonMap json) { - final version = json['version']; - final migratedFromSqlite = json['migratedFromSqlite'] == true; - - final raw = json['bookmarks']; - final bookmarks = []; - if (raw is List) { - for (final entry in raw) { - if (entry is Map) { - final m = Map.from(entry); - bookmarks.add( - BookmarkedTutorial( - id: m['articleId'], - tutorialTitle: m['articleTitle'], - authorName: m['authorName'], - tutorialText: m['articleText'], - ), - ); - } - } - } - - return ( - version: version, - migratedFromSqlite: migratedFromSqlite, - bookmarks: bookmarks, - ); - } - - static JsonMap _toStoreJson( - ({ - int version, - bool migratedFromSqlite, - List bookmarks, - }) value, - ) { - return { - 'version': value.version, - 'migratedFromSqlite': value.migratedFromSqlite, - 'bookmarks': value.bookmarks.map((b) { - return { - 'articleId': b.id, - 'articleTitle': b.tutorialTitle, - 'authorName': b.authorName, - 'articleText': b.tutorialText, - }; - }).toList(), - }; - } - - BookmarkedTutorial _toBookmarkedTutorial(Object tutorial) { - if (tutorial is Tutorial) { - return BookmarkedTutorial( - id: tutorial.id, - tutorialTitle: tutorial.title, - authorName: tutorial.authorName, - tutorialText: tutorial.text ?? '', - ); - } else if (tutorial is BookmarkedTutorial) { - return tutorial; - } else { - throw Exception( - 'unable to convert tutorial to map type: ${tutorial.runtimeType}', - ); - } - } - - String _idFor(Object tutorial) { - if (tutorial is Tutorial) return tutorial.id; - if (tutorial is BookmarkedTutorial) return tutorial.id; - throw Exception('unsupported tutorial type: ${tutorial.runtimeType}'); - } - - Future> getBookmarks() async { - await initialise(); - final store = _store!; - - final data = await store.read(); - return List.from(data.bookmarks.reversed); - } - - Future isBookmarked(Object tutorial) async { - await initialise(); - final store = _store!; - final data = await store.read(); - final id = _idFor(tutorial); - return data.bookmarks.any((b) => b.id == id); - } - - Future addBookmark(Object tutorial) async { - await initialise(); - final store = _store!; - await store.updateAndWrite((current) async { - final list = List.from(current.bookmarks); - final id = _idFor(tutorial); - list.removeWhere((b) => b.id == id); - list.add(_toBookmarkedTutorial(tutorial)); - - log('Added bookmark: $id'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - bookmarks: list, - ); - }); - } - - Future removeBookmark(Object tutorial) async { - await initialise(); - final store = _store!; - await store.updateAndWrite((current) async { - final list = List.from(current.bookmarks); - final id = _idFor(tutorial); - list.removeWhere((b) => b.id == id); - log('Removed bookmark: $id'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - bookmarks: list, - ); - }); - } -} diff --git a/mobile-app/lib/service/news/legacy/bookmark_sqlite_migrator.dart b/mobile-app/lib/service/news/legacy/bookmark_sqlite_migrator.dart deleted file mode 100644 index d7087c7cd..000000000 --- a/mobile-app/lib/service/news/legacy/bookmark_sqlite_migrator.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'dart:developer'; - -import 'package:path/path.dart' as path; -import 'package:sqflite/sqflite.dart'; - -const String _bookmarksTableName = 'bookmarks'; - -class BookmarkSqliteMigrator { - const BookmarkSqliteMigrator(); - - Future>> readBookmarks() async { - String dbFilePath; - try { - final dbPath = await getDatabasesPath(); - dbFilePath = path.join(dbPath, 'bookmarked-article.db'); - - final exists = await databaseExists(dbFilePath); - if (!exists) return []; - } catch (e) { - // In some environments (e.g., unit tests without sqflite factory init), - // any sqflite call can throw. Treat that as "no legacy DB". - log('BookmarkSqliteMigrator unavailable: $e'); - return []; - } - - Database? db; - try { - db = await openDatabase(dbFilePath, version: 1); - final results = await db.query(_bookmarksTableName); - return results; - } catch (e) { - log('BookmarkSqliteMigrator failed: $e'); - return []; - } finally { - try { - await db?.close(); - } catch (_) {} - } - } -} diff --git a/mobile-app/lib/service/podcast/download_service.dart b/mobile-app/lib/service/podcast/download_service.dart deleted file mode 100644 index ee9fba424..000000000 --- a/mobile-app/lib/service/podcast/download_service.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'dart:async'; -import 'dart:developer' as dev; -import 'dart:io'; - -import 'package:dio/dio.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/service/podcast/notification_service.dart'; -import 'package:freecodecamp/service/podcast/podcasts_service.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:ua_client_hints/ua_client_hints.dart'; - -class DownloadService { - final Dio dio = DioService.dio; - static final DownloadService _downloadService = DownloadService._internal(); - final _databaseService = locator(); - final _notificationService = locator(); - final _localeService = locator(); - - static final StreamController _progStream = - StreamController.broadcast(); - StreamController get progStream => _progStream; - - final Stream _porgress = _progStream.stream; - Stream get progress => _porgress; - - static final StreamController _downloading = - StreamController.broadcast(); - - final Stream _downloadingStream = _downloading.stream; - Stream get downloadingStream => _downloadingStream; - - set setDownloadId(String state) { - _downloadId = state; - } - - String _downloadId = ''; - String get downloadId => _downloadId; - - bool _isDownloading = false; - bool get isDownloading => _isDownloading; - - factory DownloadService() { - return _downloadService; - } - - void download(Episodes episode, Podcasts podcast) async { - if (episode.contentUrl == null) { - dev.log('DownloadService: contentUrl is null for episode ${episode.id}'); - return; - } - - Directory app = await getApplicationDocumentsDirectory(); - dev.log(_downloadId); - _downloading.sink.add(true); - - String path = '${app.path}/episodes/${podcast.id}/${episode.id}.mp3'; - - await dio.download(episode.contentUrl!, path, - onReceiveProgress: (int recevied, int total) { - _progStream.sink.add(((recevied / total) * 100).toStringAsFixed(0)); - }, options: Options(headers: {'User-Agent': await userAgent()})); - _downloading.sink.add(false); - _progStream.sink.add(''); - final t = lookupAppLocalizations(_localeService.locale); - await _notificationService.showNotification( - t.download_complete, - '${podcast.title} - ${episode.title}', - ); - await _databaseService.addPodcast(podcast); - await _databaseService.addEpisode(episode); - setDownloadId = ''; - } - - DownloadService._internal() { - _downloading.stream.listen((event) { - _isDownloading = event; - }); - } -} diff --git a/mobile-app/lib/service/podcast/legacy/podcasts_sqlite_migrator.dart b/mobile-app/lib/service/podcast/legacy/podcasts_sqlite_migrator.dart deleted file mode 100644 index 8274f0881..000000000 --- a/mobile-app/lib/service/podcast/legacy/podcasts_sqlite_migrator.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:developer'; - -import 'package:path/path.dart' as path; -import 'package:sqflite/sqflite.dart'; - -const String _podcastsTableName = 'podcasts'; -const String _episodesTableName = 'episodes'; - -class PodcastsSqliteMigrator { - const PodcastsSqliteMigrator(); - - Future< - ({ - List> podcasts, - List> episodes - })> readPodcastsAndEpisodes() async { - String dbFilePath; - try { - final dbPath = await getDatabasesPath(); - log('dbPath: $dbPath'); - dbFilePath = path.join(dbPath, 'podcasts.db'); - - final exists = await databaseExists(dbFilePath); - if (!exists) { - return ( - podcasts: >[], - episodes: >[], - ); - } - } catch (e) { - // In some environments (e.g., unit tests without sqflite factory init), - // any sqflite call can throw. Treat that as "no legacy DB". - log('PodcastsSqliteMigrator unavailable: $e'); - return ( - podcasts: >[], - episodes: >[], - ); - } - - Database? db; - try { - db = await openDatabase(dbFilePath, version: 1); - - final podcasts = await db.query(_podcastsTableName); - final episodes = await db.query(_episodesTableName); - - return (podcasts: podcasts, episodes: episodes); - } catch (e) { - log('PodcastsSqliteMigrator failed: $e'); - return ( - podcasts: >[], - episodes: >[] - ); - } finally { - try { - await db?.close(); - } catch (_) {} - } - } -} diff --git a/mobile-app/lib/service/podcast/notification_service.dart b/mobile-app/lib/service/podcast/notification_service.dart deleted file mode 100644 index a86cc775f..000000000 --- a/mobile-app/lib/service/podcast/notification_service.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'dart:developer' as developer; -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/services.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; - -// This is a singleton class and initialized only once -class NotificationService { - static final NotificationService _notificationService = - NotificationService._internal(); - final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - final LocaleService _localeService = locator(); - Random random = Random(); - - factory NotificationService() { - return _notificationService; - } - - Future init() async { - const AndroidInitializationSettings androidInitializationSettings = - AndroidInitializationSettings('notification_icon'); - const DarwinInitializationSettings iosInitializationSettings = - DarwinInitializationSettings(); - - const InitializationSettings initializationSettings = - InitializationSettings( - android: androidInitializationSettings, - iOS: iosInitializationSettings, - ); - - await _flutterLocalNotificationsPlugin.initialize( - settings: initializationSettings, - ); - } - - Future requestPermission() async { - if (Platform.isAndroid) { - final androidImplementation = _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - - if (androidImplementation == null) { - return false; - } - - try { - return await androidImplementation.requestNotificationsPermission() ?? - false; - } on PlatformException catch (e, stackTrace) { - developer.log( - 'Failed to request Android notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } catch (e, stackTrace) { - developer.log( - 'Unexpected failure while requesting Android notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } - } - - if (Platform.isIOS) { - final iosImplementation = _flutterLocalNotificationsPlugin - .resolvePlatformSpecificImplementation< - IOSFlutterLocalNotificationsPlugin>(); - - try { - return await iosImplementation?.requestPermissions( - alert: true, - badge: true, - sound: true, - ) ?? - false; - } on PlatformException catch (e, stackTrace) { - developer.log( - 'Failed to request iOS notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } catch (e, stackTrace) { - developer.log( - 'Unexpected failure while requesting iOS notification permission.', - error: e, - stackTrace: stackTrace, - ); - return false; - } - } - return true; - } - - Future requestPermissionIfNeeded() => requestPermission(); - - Future showNotification(String title, String body) async { - final t = lookupAppLocalizations(_localeService.locale); - - // Check up more on the below values here - final AndroidNotificationDetails androidNotificationDetails = - AndroidNotificationDetails( - 'fcc-notif-channel', - 'podcast-episodes', - channelDescription: t.notification_channel_description, - priority: Priority.high, - importance: Importance.max, - color: FccColors.gray90, - ); - - const DarwinNotificationDetails iosNotificationDetails = - DarwinNotificationDetails(threadIdentifier: 'fcc-ios-notif-channel'); - - final NotificationDetails platformChannelSpecifics = NotificationDetails( - android: androidNotificationDetails, - iOS: iosNotificationDetails, - ); - - await _flutterLocalNotificationsPlugin.show( - id: random.nextInt(pow(2, 31).toInt() - 1), - title: title, - body: body, - notificationDetails: platformChannelSpecifics, - ); - } - - NotificationService._internal(); -} diff --git a/mobile-app/lib/service/podcast/podcasts_service.dart b/mobile-app/lib/service/podcast/podcasts_service.dart deleted file mode 100644 index b993f9099..000000000 --- a/mobile-app/lib/service/podcast/podcasts_service.dart +++ /dev/null @@ -1,330 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/podcast/legacy/podcasts_sqlite_migrator.dart'; -import 'package:freecodecamp/service/storage/json_file_store.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; - -class PodcastsDatabaseService { - PodcastsDatabaseService({Directory? storageDirectoryOverride}) - : _storageDirectoryOverride = storageDirectoryOverride; - - final Directory? _storageDirectoryOverride; - final _legacyMigrator = const PodcastsSqliteMigrator(); - - JsonFileStore< - ({ - int version, - bool migratedFromSqlite, - List<({Podcasts podcast, List episodes})> podcasts, - })>? _store; - Future? _initFuture; - - static const int _storeVersion = 1; - - Future initialise() { - _initFuture ??= _initialiseInternal(); - return _initFuture!; - } - - Future _initialiseInternal() async { - final baseDir = - _storageDirectoryOverride ?? await getApplicationDocumentsDirectory(); - - final file = File( - path.join(baseDir.path, 'storage', 'podcast-downloads.json'), - ); - final store = JsonFileStore< - ({ - int version, - bool migratedFromSqlite, - List<({Podcasts podcast, List episodes})> podcasts, - })>( - file: file, - defaultValue: ( - version: _storeVersion, - migratedFromSqlite: false, - podcasts: <({Podcasts podcast, List episodes})>[], - ), - fromJson: _fromStoreJson, - toJson: _toStoreJson, - ); - await store.ensureExists(); - _store = store; - - await _migrateFromSqliteIfNeeded(); - } - - Future _migrateFromSqliteIfNeeded() async { - final store = _store; - if (store == null) return; - - await store.updateAndWrite((current) async { - if (current.migratedFromSqlite) return current; - - // If JSON already has data, don't overwrite it. - if (current.podcasts.isNotEmpty) { - return ( - version: current.version, - migratedFromSqlite: true, - podcasts: current.podcasts, - ); - } - - final legacy = await _legacyMigrator.readPodcastsAndEpisodes(); - if (legacy.podcasts.isEmpty && legacy.episodes.isEmpty) { - return ( - version: current.version, - migratedFromSqlite: true, - podcasts: current.podcasts, - ); - } - - // Normalize legacy rows to match Podcasts/Episodes.fromDBJson. - final legacyPodcasts = legacy.podcasts.map((p) { - return Podcasts.fromDBJson({ - 'id': p['id'], - 'url': p['url'], - 'link': p['link'], - 'title': p['title'], - 'description': p['description'], - 'image': p['image'], - 'copyright': p['copyright'], - 'numEps': p['numEps'], - }); - }).toList(); - - final legacyEpisodes = legacy.episodes.map((e) { - return Episodes.fromDBJson({ - 'id': e['id'], - 'podcastId': e['podcastId'], - 'title': e['title'], - 'description': e['description'], - 'publicationDate': e['publicationDate'], - 'contentUrl': e['contentUrl'], - 'duration': e['duration'], - }); - }).toList(); - - final episodesByPodcastId = >{}; - for (final e in legacyEpisodes) { - if (e.podcastId.isEmpty) continue; - (episodesByPodcastId[e.podcastId] ??= []).add(e); - } - - final records = legacyPodcasts.map((p) { - return ( - podcast: p, - episodes: episodesByPodcastId[p.id] ?? [], - ); - }).toList(); - - log( - 'Migrated ${legacyPodcasts.length} podcasts and ${legacyEpisodes.length} episodes from SQLite to JSON (embedded)', - ); - return ( - version: _storeVersion, - migratedFromSqlite: true, - podcasts: records, - ); - }); - } - - static ({ - int version, - bool migratedFromSqlite, - List<({Podcasts podcast, List episodes})> podcasts, - }) _fromStoreJson(JsonMap json) { - final version = json['version']; - final migratedFromSqlite = json['migratedFromSqlite'] == true; - - final List<({Podcasts podcast, List episodes})> podcasts = []; - - final rawPodcasts = json['podcasts']; - if (rawPodcasts is List) { - for (final entry in rawPodcasts) { - if (entry is! Map) continue; - final podcastJson = Map.from(entry); - final rawEpisodes = podcastJson.remove('episodes'); - - final podcast = Podcasts.fromDBJson(podcastJson); - final List episodes = []; - if (rawEpisodes is List) { - for (final e in rawEpisodes) { - if (e is Map) { - episodes.add(Episodes.fromDBJson(Map.from(e))); - } - } - } - - podcasts.add((podcast: podcast, episodes: episodes)); - } - } - - return ( - version: version, - migratedFromSqlite: migratedFromSqlite, - podcasts: podcasts, - ); - } - - static JsonMap _toStoreJson( - ({ - int version, - bool migratedFromSqlite, - List<({Podcasts podcast, List episodes})> podcasts, - }) value, - ) { - return { - 'version': value.version, - 'migratedFromSqlite': value.migratedFromSqlite, - 'podcasts': value.podcasts.map((p) { - return { - ...p.podcast.toJson(), - 'episodes': p.episodes.map((e) => e.toJson()).toList(), - }; - }).toList(), - }; - } - - int _indexOfPodcast( - List<({Podcasts podcast, List episodes})> list, - String podcastId, - ) { - return list.indexWhere((p) => p.podcast.id == podcastId); - } - - // PODCAST QUERIES - Future> getPodcasts() async { - await initialise(); - final data = await _store!.read(); - return data.podcasts.map((p) => p.podcast).toList(); - } - - Future addPodcast(Podcasts podcast) async { - await initialise(); - await _store!.updateAndWrite((current) async { - final list = List<({Podcasts podcast, List episodes})>.from( - current.podcasts, - ); - - final existingIndex = _indexOfPodcast(list, podcast.id); - final List existingEpisodes = - existingIndex == -1 ? [] : list[existingIndex].episodes; - - if (existingIndex != -1) { - list.removeAt(existingIndex); - } - - list.add((podcast: podcast, episodes: existingEpisodes)); - log('Added Podcast: ${podcast.title}'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - podcasts: list, - ); - }); - } - - Future removePodcast(Podcasts podcast) async { - await initialise(); - await _store!.updateAndWrite((current) async { - final list = List<({Podcasts podcast, List episodes})>.from( - current.podcasts, - ); - - final index = _indexOfPodcast(list, podcast.id); - if (index == -1) return current; - - final episodeCount = list[index].episodes.length; - - if (episodeCount > 0) { - log('Did not remove podcast: ${podcast.title} because it has $episodeCount episodes'); - return current; - } - - list.removeAt(index); - log('Removed Podcast: ${podcast.title}'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - podcasts: list, - ); - }); - } - - // EPISODE QUERIES - Future> getEpisodes(Podcasts podcast) async { - await initialise(); - final data = await _store!.read(); - final index = _indexOfPodcast(data.podcasts, podcast.id); - if (index == -1) return []; - return List.from(data.podcasts[index].episodes); - } - - Future addEpisode(Episodes episode) async { - await initialise(); - await _store!.updateAndWrite((current) async { - final list = List<({Podcasts podcast, List episodes})>.from( - current.podcasts, - ); - - final index = _indexOfPodcast(list, episode.podcastId); - if (index == -1) { - log('Did not add episode: missing podcast ${episode.podcastId}'); - return current; - } - - final episodes = List.from(list[index].episodes); - episodes.removeWhere((e) => e.id == episode.id); - episodes.add(episode); - - list[index] = ( - podcast: list[index].podcast, - episodes: episodes, - ); - log('Added Episode: ${episode.title}'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - podcasts: list, - ); - }); - } - - Future removeEpisode(Episodes episode) async { - await initialise(); - await _store!.updateAndWrite((current) async { - final list = List<({Podcasts podcast, List episodes})>.from( - current.podcasts, - ); - - final index = _indexOfPodcast(list, episode.podcastId); - if (index == -1) return current; - - final episodes = List.from(list[index].episodes); - episodes.removeWhere((e) => e.id == episode.id); - list[index] = ( - podcast: list[index].podcast, - episodes: episodes, - ); - log('Removed Episode: ${episode.title}'); - return ( - version: current.version, - migratedFromSqlite: current.migratedFromSqlite, - podcasts: list, - ); - }); - } - - Future episodeExists(Episodes episode) async { - await initialise(); - final data = await _store!.read(); - final index = _indexOfPodcast(data.podcasts, episode.podcastId); - if (index == -1) return false; - return data.podcasts[index].episodes.any((e) => e.id == episode.id); - } -} diff --git a/mobile-app/lib/service/storage/json_file_store.dart b/mobile-app/lib/service/storage/json_file_store.dart deleted file mode 100644 index d9811e290..000000000 --- a/mobile-app/lib/service/storage/json_file_store.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -typedef JsonMap = Map; - -class JsonFileStore { - JsonFileStore({ - required this.file, - required T defaultValue, - required T Function(JsonMap json) fromJson, - required JsonMap Function(T value) toJson, - }) : _defaultValue = defaultValue, - _fromJson = fromJson, - _toJson = toJson; - - final File file; - final T _defaultValue; - final T Function(JsonMap json) _fromJson; - final JsonMap Function(T value) _toJson; - - Future ensureExists() async { - if (await file.exists()) return; - await file.parent.create(recursive: true); - await _atomicWrite(_toJson(_defaultValue)); - } - - Future read() async { - await ensureExists(); - final contents = await file.readAsString(); - if (contents.trim().isEmpty) { - return _defaultValue; - } - - try { - final decoded = jsonDecode(contents); - if (decoded is Map) { - return _fromJson(JsonMap.from(decoded)); - } - } catch (_) { - // Ignore and return default. - } - - return _defaultValue; - } - - Future write(T value) async { - await ensureExists(); - await _atomicWrite(_toJson(value)); - } - - Future updateAndWrite(Future Function(T current) updater) { - return _synchronized(() async { - final current = await read(); - final updated = await updater(current); - await write(updated); - }); - } - - Future _atomicWrite(JsonMap value) async { - await file.parent.create(recursive: true); - - final temp = File('${file.path}.tmp'); - final encoded = const JsonEncoder.withIndent(' ').convert(value); - await temp.writeAsString(encoded, flush: true); - - if (await file.exists()) { - await file.delete(); - } - await temp.rename(file.path); - } - - Future _tail = Future.value(); - - Future _synchronized(Future Function() action) { - final next = _tail.then((_) => action()); - _tail = next.then((_) {}).catchError((_) {}); - return next; - } -} diff --git a/mobile-app/lib/services/analytics_service.dart b/mobile-app/lib/services/analytics_service.dart new file mode 100644 index 000000000..dfd98ddb4 --- /dev/null +++ b/mobile-app/lib/services/analytics_service.dart @@ -0,0 +1,7 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'analytics_service.g.dart'; + +@Riverpod(keepAlive: true) +FirebaseAnalytics analytics(Ref ref) => FirebaseAnalytics.instance; diff --git a/mobile-app/lib/services/analytics_service.g.dart b/mobile-app/lib/services/analytics_service.g.dart new file mode 100644 index 000000000..c7ff7a5cb --- /dev/null +++ b/mobile-app/lib/services/analytics_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'analytics_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(analytics) +final analyticsProvider = AnalyticsProvider._(); + +final class AnalyticsProvider + extends + $FunctionalProvider< + FirebaseAnalytics, + FirebaseAnalytics, + FirebaseAnalytics + > + with $Provider { + AnalyticsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'analyticsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$analyticsHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + FirebaseAnalytics create(Ref ref) { + return analytics(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(FirebaseAnalytics value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$analyticsHash() => r'8fb06ce3653ba0d954e4379b42d73d731065c520'; diff --git a/mobile-app/lib/services/audio_service.dart b/mobile-app/lib/services/audio_service.dart new file mode 100644 index 000000000..1a8a02e10 --- /dev/null +++ b/mobile-app/lib/services/audio_service.dart @@ -0,0 +1,397 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:audio_service/audio_service.dart'; +import 'package:freecodecamp/code_radio/models/code_radio_model.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/podcasts/models/episode_model.dart'; +import 'package:freecodecamp/podcasts/models/podcast_model.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'audio_service.g.dart'; + +typedef _MediaControls = ({ + List controls, + Set systemActions, + List compactIndices, +}); + +sealed class AudioTypeConfig { + const AudioTypeConfig(); +} + +class CodeRadioAudioConfig extends AudioTypeConfig { + const CodeRadioAudioConfig(); +} + +class PodcastAudioConfig extends AudioTypeConfig { + const PodcastAudioConfig(this.episodeId); + + final String episodeId; +} + +class CurriculumAudioConfig extends AudioTypeConfig { + const CurriculumAudioConfig(this.clipId); + + final String clipId; +} + +// NOTE: The handler is created before runApp and injected, so reading +// this without that override is a wiring mistake rather than a lazy init. +@Riverpod(keepAlive: true) +AudioPlayerHandler audioHandler(Ref ref) => throw UnimplementedError( + 'audioHandlerProvider must be overridden in main() with initAudioService()', +); + +Future initAudioService() => AudioService.init( + builder: () => AudioPlayerHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'org.freecodecamp.channel.audio', + androidNotificationChannelName: 'Audio playback', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + androidNotificationIcon: 'drawable/notification_icon', + notificationColor: FccColors.gray90, + ), +); + +class AudioPlayerHandler extends BaseAudioHandler { + AudioPlayerHandler() { + _notifyAudioHandlerAboutPlaybackEvents(); + _trackCodeRadioLiveEdge(); + } + + static const _skipForward = Duration(seconds: 30); + static const _skipBackward = Duration(seconds: 10); + static const _defaultSpeed = 1.0; + + final AudioPlayer _audioPlayer = AudioPlayer(); + + AudioTypeConfig? _audioConfig; + + Uri? _codeRadioUrl; + + // NOTE: Pausing a live stream does not pause the source: the buffer keeps filling + // while the playhead sits still, so resuming plays out the backlog instead + // of what the station is broadcasting now. Reconnecting on resume is the + // only way back to the live edge. just_audio pauses and resumes the player + // itself on interruptions and unplugging, so the flag follows playingStream. + bool _resumeNeedsLiveEdge = false; + + // NOTE: Every load takes a new id, so a load interrupted by a newer one + // returns without stopping, restoring or publishing over the newer source. + int _loadId = 0; + + // NOTE: While a source is swapping, position events can still come from the + // old one, so no episode is reported until the new one is in place. + bool _loading = false; + + String? get codeRadioSongId => + _audioConfig is CodeRadioAudioConfig ? mediaItem.value?.id : null; + + bool get isPlayingCodeRadio => + _audioPlayer.playing && _audioConfig is CodeRadioAudioConfig; + + String? get episodeId => switch (_audioConfig) { + PodcastAudioConfig(:final episodeId) when !_loading => episodeId, + _ => null, + }; + + String? get curriculumClipId => switch (_audioConfig) { + CurriculumAudioConfig(:final clipId) => clipId, + _ => null, + }; + + Stream get positionStream => _audioPlayer.positionStream; + + Duration? get duration => _audioPlayer.duration; + + @override + Future play() async { + if (_audioConfig == null) return; + + if (_resumeNeedsLiveEdge) { + _resumeNeedsLiveEdge = false; + + if (_audioConfig is CodeRadioAudioConfig) { + await _rejoinCodeRadioLiveEdge(); + } + } + + _audioPlayer.play(); + } + + @override + Future pause() => _audioPlayer.pause(); + + @override + Future stop() async { + _loadId++; + _loading = false; + _audioConfig = null; + _codeRadioUrl = null; + _resumeNeedsLiveEdge = false; + + await _audioPlayer.stop(); + + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.idle, + playing: false, + controls: const [], + systemActions: const {}, + androidCompactActionIndices: const [], + updatePosition: Duration.zero, + bufferedPosition: Duration.zero, + speed: _defaultSpeed, + ), + ); + + mediaItem.add(null); + queue.add(const []); + + return super.stop(); + } + + @override + Future seek(Duration position) => _audioPlayer.seek(position); + + @override + Future fastForward() => _seekBy(_skipForward); + + @override + Future rewind() => _seekBy(-_skipBackward); + + @override + Future setSpeed(double speed) => _audioPlayer.setSpeed(speed); + + @override + Future onTaskRemoved() async { + await stop(); + return super.onTaskRemoved(); + } + + Future _seekBy(Duration offset) async { + final target = _audioPlayer.position + offset; + final end = _audioPlayer.duration; + + if (target < Duration.zero) return _audioPlayer.seek(Duration.zero); + if (end != null && target > end) return _audioPlayer.seek(end); + + return _audioPlayer.seek(target); + } + + Future loadCodeRadio(CodeRadio radio) async { + final load = ++_loadId; + final previous = _audioConfig; + _loading = true; + + try { + final song = _toMediaItem(radio.nowPlaying.song); + final url = Uri.parse(radio.station.listenUrl); + + _codeRadioUrl = url; + _resumeNeedsLiveEdge = false; + _audioConfig = const CodeRadioAudioConfig(); + + await _audioPlayer.setSpeed(_defaultSpeed); + await _audioPlayer.setAudioSource(AudioSource.uri(url)); + if (load != _loadId) return; + + _publish(song); + } catch (e) { + if (load != _loadId) return; + + _audioConfig = previous; + log('loadCodeRadio: Cannot play audio: $e'); + } finally { + if (load == _loadId) _loading = false; + } + } + + void updateCodeRadioSong(Song song) => _publish(_toMediaItem(song)); + + Future _rejoinCodeRadioLiveEdge() async { + final url = _codeRadioUrl; + if (url == null) return; + + try { + await _audioPlayer.setAudioSource(AudioSource.uri(url)); + } catch (e) { + log('rejoinCodeRadioLiveEdge: Cannot reconnect: $e'); + } + } + + Future loadEpisode( + Episode episode, + Podcast podcast, { + required Uri source, + Uri? artUri, + Duration? startAt, + }) async { + final item = MediaItem( + id: episode.id, + title: episode.title, + album: podcast.title, + duration: episode.duration, + artUri: artUri, + ); + + final load = ++_loadId; + _loading = true; + _codeRadioUrl = null; + _resumeNeedsLiveEdge = false; + _audioConfig = PodcastAudioConfig(episode.id); + + try { + await _audioPlayer.setSpeed(_defaultSpeed); + await _audioPlayer.setAudioSource(AudioSource.uri(source)); + + if (startAt != null && startAt > Duration.zero) { + await _audioPlayer.seek(startAt); + } + if (load != _loadId) return false; + + _publish(item); + return true; + } catch (e) { + if (load != _loadId) return false; + + log('loadEpisode: Cannot play audio: $e'); + await stop(); + return false; + } finally { + if (load == _loadId) _loading = false; + } + } + + Future loadCurriculumAudio( + Uri source, { + required String clipId, + required String title, + Duration start = Duration.zero, + Duration? end, + }) async { + final item = MediaItem(id: clipId, title: title, album: 'freeCodeCamp'); + + final load = ++_loadId; + _loading = true; + _codeRadioUrl = null; + _resumeNeedsLiveEdge = false; + _audioConfig = CurriculumAudioConfig(clipId); + + try { + await _audioPlayer.setSpeed(_defaultSpeed); + await _audioPlayer.setAudioSource( + ClippingAudioSource( + start: start, + end: end, + child: AudioSource.uri(source), + ), + ); + if (load != _loadId) return false; + + _publish(item); + return true; + } catch (e) { + if (load != _loadId) return false; + + log('loadCurriculumAudio: Cannot play audio: $e'); + await stop(); + return false; + } finally { + if (load == _loadId) _loading = false; + } + } + + MediaItem _toMediaItem(Song song) => MediaItem( + id: song.id, + title: song.title, + artist: song.artist, + album: song.album, + artUri: Uri.parse(song.art), + ); + + void _publish(MediaItem song) { + queue.add([song]); + mediaItem.add(song); + } + + _MediaControls _controlsFor(bool playing) { + final playPause = playing ? MediaControl.pause : MediaControl.play; + + return switch (_audioConfig) { + PodcastAudioConfig() => ( + controls: [ + MediaControl.rewind, + playPause, + MediaControl.fastForward, + MediaControl.stop, + ], + systemActions: const {MediaAction.seek}, + compactIndices: const [0, 1, 2], + ), + CodeRadioAudioConfig() || CurriculumAudioConfig() || null => ( + controls: [playPause, MediaControl.stop], + systemActions: const {}, + compactIndices: const [0, 1], + ), + }; + } + + void _trackCodeRadioLiveEdge() { + _audioPlayer.playingStream.listen((playing) { + if (_audioConfig is! CodeRadioAudioConfig) return; + + if (!playing) { + _resumeNeedsLiveEdge = true; + } else if (_resumeNeedsLiveEdge) { + _resumeNeedsLiveEdge = false; + unawaited(_rejoinCodeRadioLiveEdge()); + } + }); + } + + void _notifyAudioHandlerAboutPlaybackEvents() { + _audioPlayer.playbackEventStream.listen( + (PlaybackEvent event) { + if (_audioConfig == null) return; + + if (_audioConfig is PodcastAudioConfig && + _audioPlayer.processingState == ProcessingState.completed) { + unawaited(Future.microtask(stop)); + return; + } + + final playing = _audioPlayer.playing; + final mediaControls = _controlsFor(playing); + + playbackState.add( + playbackState.value.copyWith( + controls: mediaControls.controls, + systemActions: mediaControls.systemActions, + androidCompactActionIndices: mediaControls.compactIndices, + processingState: const { + ProcessingState.idle: AudioProcessingState.idle, + ProcessingState.loading: AudioProcessingState.loading, + ProcessingState.buffering: AudioProcessingState.buffering, + ProcessingState.ready: AudioProcessingState.ready, + ProcessingState.completed: AudioProcessingState.completed, + }[_audioPlayer.processingState]!, + repeatMode: AudioServiceRepeatMode.none, + shuffleMode: AudioServiceShuffleMode.none, + playing: playing, + updatePosition: _audioPlayer.position, + bufferedPosition: _audioPlayer.bufferedPosition, + speed: _audioPlayer.speed, + queueIndex: event.currentIndex, + ), + ); + }, + onError: (Object e, StackTrace st) { + log('Playback event error: $e\n$st'); + }, + ); + } +} diff --git a/mobile-app/lib/services/audio_service.g.dart b/mobile-app/lib/services/audio_service.g.dart new file mode 100644 index 000000000..1a49beccc --- /dev/null +++ b/mobile-app/lib/services/audio_service.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'audio_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(audioHandler) +final audioHandlerProvider = AudioHandlerProvider._(); + +final class AudioHandlerProvider + extends + $FunctionalProvider< + AudioPlayerHandler, + AudioPlayerHandler, + AudioPlayerHandler + > + with $Provider { + AudioHandlerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'audioHandlerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$audioHandlerHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + AudioPlayerHandler create(Ref ref) { + return audioHandler(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AudioPlayerHandler value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$audioHandlerHash() => r'014f1951b3e255a5440d3a291bb0cf719c0a8e20'; diff --git a/mobile-app/lib/services/auth_service.dart b/mobile-app/lib/services/auth_service.dart new file mode 100644 index 000000000..b618d8b30 --- /dev/null +++ b/mobile-app/lib/services/auth_service.dart @@ -0,0 +1,58 @@ +import 'package:freecodecamp/auth/models/user_model.dart'; +import 'package:freecodecamp/auth/services/session_service.dart'; +import 'package:freecodecamp/utils/riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'auth_service.g.dart'; + +extension AuthStatus on AsyncValue { + bool get isLoggedIn => value != null; +} + +// NOTE: The signed-in user, or null when signed out. The only place auth state changes. +@Riverpod(keepAlive: true, retry: noRetry) +class AuthNotifier extends _$AuthNotifier { + @override + Future build() { + final session = ref.watch(authSessionServiceProvider); + + // NOTE: Any authenticated call rejected with a 401 signs the user out everywhere. + final sub = session.sessionExpired.listen((_) { + state = const AsyncData(null); + }); + ref.onDispose(sub.cancel); + + return session.restoreSession(); + } + + AuthSessionService get _session => ref.read(authSessionServiceProvider); + + Future loginWithProvider(String connection) async { + state = AsyncData(await _session.loginWithProvider(connection)); + } + + Future loginWithEmailOtp(String email, String otp) async { + state = AsyncData(await _session.loginWithEmailOtp(email, otp)); + } + + Future refresh() async { + if (state.hasError) { + ref.invalidateSelf(); + return; + } + if (!state.isLoggedIn) return; + + final user = await _session.restoreSession(); + if (ref.mounted && state.isLoggedIn) state = AsyncData(user); + } + + Future signOut() async { + await _session.logout(); + state = const AsyncData(null); + } + + Future deleteAccount() async { + await _session.deleteAccount(); + state = const AsyncData(null); + } +} diff --git a/mobile-app/lib/services/auth_service.g.dart b/mobile-app/lib/services/auth_service.g.dart new file mode 100644 index 000000000..739731246 --- /dev/null +++ b/mobile-app/lib/services/auth_service.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'auth_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(AuthNotifier) +final authProvider = AuthNotifierProvider._(); + +final class AuthNotifierProvider + extends $AsyncNotifierProvider { + AuthNotifierProvider._() + : super( + from: null, + argument: null, + retry: noRetry, + name: r'authProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$authNotifierHash(); + + @$internal + @override + AuthNotifier create() => AuthNotifier(); +} + +String _$authNotifierHash() => r'2592c7431cd018c7ea85a93247819fff219276c7'; + +abstract class _$AuthNotifier extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref, FccUserModel?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, FccUserModel?>, + AsyncValue, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/services/dio_service.dart b/mobile-app/lib/services/dio_service.dart new file mode 100644 index 000000000..9f2dae7ca --- /dev/null +++ b/mobile-app/lib/services/dio_service.dart @@ -0,0 +1,19 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:pretty_dio_logger/pretty_dio_logger.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'dio_service.g.dart'; + +@Riverpod(keepAlive: true) +Dio dio(Ref ref) { + final dio = Dio(); + + if (dotenv.getBool('DEVELOPMENT_MODE', fallback: false)) { + dio.interceptors.add(PrettyDioLogger(responseBody: false)); + } + + ref.onDispose(dio.close); + + return dio; +} diff --git a/mobile-app/lib/services/dio_service.g.dart b/mobile-app/lib/services/dio_service.g.dart new file mode 100644 index 000000000..722bf4995 --- /dev/null +++ b/mobile-app/lib/services/dio_service.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'dio_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(dio) +final dioProvider = DioProvider._(); + +final class DioProvider extends $FunctionalProvider + with $Provider { + DioProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'dioProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$dioHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + Dio create(Ref ref) { + return dio(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Dio value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$dioHash() => r'0ec5d203ab21d291bb4a2a4ad994085c797a960c'; diff --git a/mobile-app/lib/services/notification_service.dart b/mobile-app/lib/services/notification_service.dart new file mode 100644 index 000000000..8275265de --- /dev/null +++ b/mobile-app/lib/services/notification_service.dart @@ -0,0 +1,121 @@ +import 'dart:developer'; +import 'dart:io'; +import 'dart:math' show Random, pow; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:timezone/timezone.dart' as tz; + +part 'notification_service.g.dart'; + +@Riverpod(keepAlive: true) +NotificationService notificationService(Ref ref) => NotificationService(); + +class NotificationService { + static const _channelId = 'fcc-notif-channel'; + static const _channelName = 'podcast-episodes'; + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + final Random _random = Random(); + + Future? _initialisation; + + Future _ensureInitialised() => _initialisation ??= _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('notification_icon'), + iOS: DarwinInitializationSettings(), + ), + ); + + Future requestPermission() async { + try { + await _ensureInitialised(); + + if (Platform.isAndroid) { + return await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.requestNotificationsPermission() ?? + false; + } + + if (Platform.isIOS) { + return await _plugin + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >() + ?.requestPermissions(alert: true, badge: true, sound: true) ?? + false; + } + } catch (e) { + log('Notifications: permission request failed ($e)'); + return false; + } + + return true; + } + + Future enabled() async { + await _ensureInitialised(); + if (!Platform.isAndroid) return true; + + return await _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.areNotificationsEnabled() ?? + false; + } + + Future schedule({ + required int id, + required String title, + required String body, + required DateTime at, + required NotificationDetails details, + }) async { + await _ensureInitialised(); + await _plugin.zonedSchedule( + id: id, + title: title, + body: body, + scheduledDate: tz.TZDateTime.from(at, tz.local), + notificationDetails: details, + androidScheduleMode: AndroidScheduleMode.inexact, + ); + } + + Future cancel(int id) async { + await _ensureInitialised(); + await _plugin.cancel(id: id); + } + + Future showNotification(String title, String body) async { + try { + await _ensureInitialised(); + await _show(title, body); + } catch (e) { + log('Notifications: could not post "$title" ($e)'); + } + } + + Future _show(String title, String body) => _plugin.show( + id: _random.nextInt(pow(2, 31).toInt() - 1), + title: title, + body: body, + notificationDetails: const NotificationDetails( + android: AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: 'Notifications about podcast episode downloads', + priority: Priority.high, + importance: Importance.max, + color: FccColors.gray90, + ), + iOS: DarwinNotificationDetails(threadIdentifier: 'fcc-ios-notif-channel'), + ), + ); +} diff --git a/mobile-app/lib/services/notification_service.g.dart b/mobile-app/lib/services/notification_service.g.dart new file mode 100644 index 000000000..8f0846530 --- /dev/null +++ b/mobile-app/lib/services/notification_service.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(notificationService) +final notificationServiceProvider = NotificationServiceProvider._(); + +final class NotificationServiceProvider + extends + $FunctionalProvider< + NotificationService, + NotificationService, + NotificationService + > + with $Provider { + NotificationServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'notificationServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$notificationServiceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + NotificationService create(Ref ref) { + return notificationService(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(NotificationService value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$notificationServiceHash() => + r'585c1e42ea844e71a2b76b80b165adfe2c5c8529'; diff --git a/mobile-app/lib/services/remote_config_service.dart b/mobile-app/lib/services/remote_config_service.dart new file mode 100644 index 000000000..64779f5a3 --- /dev/null +++ b/mobile-app/lib/services/remote_config_service.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; + +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'remote_config_service.freezed.dart'; +part 'remote_config_service.g.dart'; + +const _activationOverridesKey = 'activation_overrides'; +const _minAppVersionKey = 'min_app_version'; + +@freezed +abstract class RemoteConfig with _$RemoteConfig { + const factory RemoteConfig({ + required Map activationOverrides, + required String minAppVersion, + }) = _RemoteConfig; + + const RemoteConfig._(); + + bool? activationOverride(String superBlock, [String? block]) => + activationOverrides[block == null ? superBlock : '$superBlock/$block']; +} + +Future initRemoteConfig() async { + final config = FirebaseRemoteConfig.instance; + + try { + await config.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(seconds: 10), + minimumFetchInterval: const Duration(hours: 1), + ), + ); + final packageInfo = await PackageInfo.fromPlatform(); + await config.setDefaults({ + _activationOverridesKey: '{}', + _minAppVersionKey: packageInfo.version, + }); + await config.fetchAndActivate(); + } catch (e, stack) { + log('Unable to fetch remote config, using cached or default values: $e'); + unawaited( + FirebaseCrashlytics.instance.recordError( + e, + stack, + reason: 'while fetching Remote Config', + ), + ); + } +} + +@Riverpod(keepAlive: true) +class RemoteConfigNotifier extends _$RemoteConfigNotifier { + @override + RemoteConfig build() { + final config = FirebaseRemoteConfig.instance; + + final sub = config.onConfigUpdated.listen( + (_) async { + await config.activate(); + if (ref.mounted) state = _read(config); + }, + onError: (Object e, StackTrace stack) { + log('Remote config updates failed: $e'); + unawaited( + FirebaseCrashlytics.instance.recordError( + e, + stack, + reason: 'while listening for Remote Config updates', + ), + ); + }, + ); + ref.onDispose(sub.cancel); + + return _read(config); + } + + RemoteConfig _read(FirebaseRemoteConfig config) => RemoteConfig( + activationOverrides: _activationOverrides(config), + minAppVersion: config.getString(_minAppVersionKey), + ); + + Map _activationOverrides(FirebaseRemoteConfig config) { + try { + final decoded = jsonDecode(config.getString(_activationOverridesKey)); + if (decoded is! Map) return const {}; + + return { + for (final MapEntry(:key, :value) in decoded.entries) + if (value is bool) key: value, + }; + } catch (e) { + log('Invalid $_activationOverridesKey, ignoring overrides: $e'); + return const {}; + } + } +} diff --git a/mobile-app/lib/services/remote_config_service.freezed.dart b/mobile-app/lib/services/remote_config_service.freezed.dart new file mode 100644 index 000000000..891c3d1b4 --- /dev/null +++ b/mobile-app/lib/services/remote_config_service.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'remote_config_service.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$RemoteConfig { + + Map get activationOverrides; String get minAppVersion; +/// Create a copy of RemoteConfig +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$RemoteConfigCopyWith get copyWith => _$RemoteConfigCopyWithImpl(this as RemoteConfig, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is RemoteConfig&&const DeepCollectionEquality().equals(other.activationOverrides, activationOverrides)&&(identical(other.minAppVersion, minAppVersion) || other.minAppVersion == minAppVersion)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(activationOverrides),minAppVersion); + +@override +String toString() { + return 'RemoteConfig(activationOverrides: $activationOverrides, minAppVersion: $minAppVersion)'; +} + + +} + +/// @nodoc +abstract mixin class $RemoteConfigCopyWith<$Res> { + factory $RemoteConfigCopyWith(RemoteConfig value, $Res Function(RemoteConfig) _then) = _$RemoteConfigCopyWithImpl; +@useResult +$Res call({ + Map activationOverrides, String minAppVersion +}); + + + + +} +/// @nodoc +class _$RemoteConfigCopyWithImpl<$Res> + implements $RemoteConfigCopyWith<$Res> { + _$RemoteConfigCopyWithImpl(this._self, this._then); + + final RemoteConfig _self; + final $Res Function(RemoteConfig) _then; + +/// Create a copy of RemoteConfig +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? activationOverrides = null,Object? minAppVersion = null,}) { + return _then(_self.copyWith( +activationOverrides: null == activationOverrides ? _self.activationOverrides : activationOverrides // ignore: cast_nullable_to_non_nullable +as Map,minAppVersion: null == minAppVersion ? _self.minAppVersion : minAppVersion // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [RemoteConfig]. +extension RemoteConfigPatterns on RemoteConfig { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _RemoteConfig value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _RemoteConfig() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _RemoteConfig value) $default,){ +final _that = this; +switch (_that) { +case _RemoteConfig(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _RemoteConfig value)? $default,){ +final _that = this; +switch (_that) { +case _RemoteConfig() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map activationOverrides, String minAppVersion)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _RemoteConfig() when $default != null: +return $default(_that.activationOverrides,_that.minAppVersion);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map activationOverrides, String minAppVersion) $default,) {final _that = this; +switch (_that) { +case _RemoteConfig(): +return $default(_that.activationOverrides,_that.minAppVersion);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map activationOverrides, String minAppVersion)? $default,) {final _that = this; +switch (_that) { +case _RemoteConfig() when $default != null: +return $default(_that.activationOverrides,_that.minAppVersion);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _RemoteConfig extends RemoteConfig { + const _RemoteConfig({required final Map activationOverrides, required this.minAppVersion}): _activationOverrides = activationOverrides,super._(); + + + final Map _activationOverrides; +@override Map get activationOverrides { + if (_activationOverrides is EqualUnmodifiableMapView) return _activationOverrides; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_activationOverrides); +} + +@override final String minAppVersion; + +/// Create a copy of RemoteConfig +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$RemoteConfigCopyWith<_RemoteConfig> get copyWith => __$RemoteConfigCopyWithImpl<_RemoteConfig>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _RemoteConfig&&const DeepCollectionEquality().equals(other._activationOverrides, _activationOverrides)&&(identical(other.minAppVersion, minAppVersion) || other.minAppVersion == minAppVersion)); +} + + +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_activationOverrides),minAppVersion); + +@override +String toString() { + return 'RemoteConfig(activationOverrides: $activationOverrides, minAppVersion: $minAppVersion)'; +} + + +} + +/// @nodoc +abstract mixin class _$RemoteConfigCopyWith<$Res> implements $RemoteConfigCopyWith<$Res> { + factory _$RemoteConfigCopyWith(_RemoteConfig value, $Res Function(_RemoteConfig) _then) = __$RemoteConfigCopyWithImpl; +@override @useResult +$Res call({ + Map activationOverrides, String minAppVersion +}); + + + + +} +/// @nodoc +class __$RemoteConfigCopyWithImpl<$Res> + implements _$RemoteConfigCopyWith<$Res> { + __$RemoteConfigCopyWithImpl(this._self, this._then); + + final _RemoteConfig _self; + final $Res Function(_RemoteConfig) _then; + +/// Create a copy of RemoteConfig +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? activationOverrides = null,Object? minAppVersion = null,}) { + return _then(_RemoteConfig( +activationOverrides: null == activationOverrides ? _self._activationOverrides : activationOverrides // ignore: cast_nullable_to_non_nullable +as Map,minAppVersion: null == minAppVersion ? _self.minAppVersion : minAppVersion // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/mobile-app/lib/services/remote_config_service.g.dart b/mobile-app/lib/services/remote_config_service.g.dart new file mode 100644 index 000000000..b3cf42ef5 --- /dev/null +++ b/mobile-app/lib/services/remote_config_service.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'remote_config_service.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(RemoteConfigNotifier) +final remoteConfigProvider = RemoteConfigNotifierProvider._(); + +final class RemoteConfigNotifierProvider + extends $NotifierProvider { + RemoteConfigNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'remoteConfigProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$remoteConfigNotifierHash(); + + @$internal + @override + RemoteConfigNotifier create() => RemoteConfigNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(RemoteConfig value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$remoteConfigNotifierHash() => + r'57a4319c84867773353ffbfb431ca7016b9367e9'; + +abstract class _$RemoteConfigNotifier extends $Notifier { + RemoteConfig build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + RemoteConfig, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/settings/ui/settings/delete_account/delete_account_view.dart b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_view.dart new file mode 100644 index 000000000..6b8adc7c3 --- /dev/null +++ b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_view.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/settings/ui/settings/delete_account/delete_account_viewmodel.dart'; +import 'package:freecodecamp/widgets/dialog/confirm_dialog.dart'; +import 'package:freecodecamp/widgets/section_divider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const _paragraphStyle = TextStyle(fontSize: 18, height: 1.2); + +class DeleteAccountView extends ConsumerWidget { + const DeleteAccountView({super.key}); + + Future _delete(BuildContext context, WidgetRef ref) async { + final confirmed = await showConfirmDialog( + context, + title: 'Delete Account', + description: + 'Are you sure you want to delete your account? - this deletes everything related to your account', + confirmLabel: 'Delete Account', + destructive: true, + ); + + if (!confirmed || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final message = await ref + .read(deleteAccountProvider.notifier) + .deleteAccount(); + + messenger.showSnackBar(SnackBar(content: Text(message))); + + // NOTE: Signed out means the account was deleted or the session expired. + if (context.mounted && !ref.read(authProvider).isLoggedIn) { + const LearnLandingRoute().go(context); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDeleting = ref.watch(deleteAccountProvider); + + return PopScope( + canPop: !isDeleting, + child: Scaffold( + appBar: AppBar(title: const Text('Delete Account')), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Text( + 'This will really delete all your data, including all your progress and account information.', + style: _paragraphStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + "We won't be able to recover any of it for you later, even if you change your mind.", + style: _paragraphStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + "If there's something we could do better, send us an email instead and we'll do our best:", + style: _paragraphStyle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + InkWell( + onTap: () => + launchUrl(Uri.parse('mailto:support@freecodecamp.org')), + child: Text( + 'support@freecodecamp.org', + style: _paragraphStyle.copyWith( + decoration: TextDecoration.underline, + ), + ), + ), + const SizedBox(height: 12), + sectionDivider, + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: isDeleting + ? null + : () => _delete(context, ref), + style: TextButton.styleFrom( + backgroundColor: Colors.red.shade100, + foregroundColor: Colors.red.shade900, + side: BorderSide(width: 2, color: Colors.red.shade900), + disabledBackgroundColor: Colors.red.shade50, + disabledForegroundColor: Colors.red.shade700, + shape: const RoundedRectangleBorder(), + ), + child: const Text( + 'I am 100% certain. Delete everything related to this account', + textAlign: TextAlign.center, + style: _paragraphStyle, + ), + ), + ), + ], + ), + ), + if (isDeleting) + const ModalBarrier(dismissible: false, color: Colors.black54), + if (isDeleting) const Center(child: CircularProgressIndicator()), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.dart b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.dart new file mode 100644 index 000000000..aba1baa79 --- /dev/null +++ b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.dart @@ -0,0 +1,27 @@ +import 'package:freecodecamp/auth/repositories/session_repository.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'delete_account_viewmodel.g.dart'; + +@riverpod +class DeleteAccountNotifier extends _$DeleteAccountNotifier { + @override + bool build() => false; + + Future deleteAccount() async { + final auth = ref.read(authProvider.notifier); + state = true; + + try { + await auth.deleteAccount(); + return 'Account deleted successfully'; + } on SessionExpiredException { + return 'Your session has expired. Please log in again.'; + } catch (_) { + return 'Account deletion failed. Please try again later.'; + } finally { + if (ref.mounted) state = false; + } + } +} diff --git a/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.g.dart b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.g.dart new file mode 100644 index 000000000..000d74ba2 --- /dev/null +++ b/mobile-app/lib/settings/ui/settings/delete_account/delete_account_viewmodel.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'delete_account_viewmodel.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(DeleteAccountNotifier) +final deleteAccountProvider = DeleteAccountNotifierProvider._(); + +final class DeleteAccountNotifierProvider + extends $NotifierProvider { + DeleteAccountNotifierProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'deleteAccountProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$deleteAccountNotifierHash(); + + @$internal + @override + DeleteAccountNotifier create() => DeleteAccountNotifier(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$deleteAccountNotifierHash() => + r'2a476c024e549495fc997667db5a4b27b9d06461'; + +abstract class _$DeleteAccountNotifier extends $Notifier { + bool build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/mobile-app/lib/settings/ui/settings/settings_view.dart b/mobile-app/lib/settings/ui/settings/settings_view.dart new file mode 100644 index 000000000..82503a30a --- /dev/null +++ b/mobile-app/lib/settings/ui/settings/settings_view.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/routing/settings.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/utils/custom_tab.dart'; +import 'package:freecodecamp/widgets/dialog/confirm_dialog.dart'; +import 'package:freecodecamp/widgets/drawer/drawer.dart'; +import 'package:freecodecamp/widgets/section_divider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SettingsView extends ConsumerWidget { + const SettingsView({super.key}); + + Future _confirmResetCache(BuildContext context) async { + final confirmed = await showConfirmDialog( + context, + title: 'Reset Cache', + description: 'Clears all local data and progress', + confirmLabel: 'Clear', + ); + + if (!confirmed) return; + + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Cache cleared'))); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoggedIn = ref.watch(authProvider.select((s) => s.isLoggedIn)); + + return Scaffold( + appBar: AppBar(title: const Text('SETTINGS')), + drawer: const DrawerWidget(), + body: Column( + children: [ + if (isLoggedIn) ...[ + ListTile( + leading: const Icon(Icons.delete_forever), + title: const Text('Delete Account'), + subtitle: const Text('Delete your freeCodeCamp account'), + onTap: () => + const SettingsDeleteAccountRoute().push(context), + ), + sectionDivider, + ], + ListTile( + leading: const Icon(Icons.dataset_linked), + title: const Text('Reset Cache'), + subtitle: const Text('Clears all local data and progress'), + onTap: () => _confirmResetCache(context), + ), + sectionDivider, + ListTile( + leading: const Icon(Icons.privacy_tip), + title: const Text('Privacy Policy'), + subtitle: const Text('Read our privacy policy'), + onTap: () => openInCustomTab( + 'https://www.freecodecamp.org/news/privacy-policy/', + ), + ), + sectionDivider, + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/code_radio/code_radio_view.dart b/mobile-app/lib/ui/views/code_radio/code_radio_view.dart deleted file mode 100644 index b3e028338..000000000 --- a/mobile-app/lib/ui/views/code_radio/code_radio_view.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/code-radio/code_radio_model.dart'; -import 'package:freecodecamp/ui/views/code_radio/code_radio_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -class CodeRadioView extends StatelessWidget { - const CodeRadioView({super.key}); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => CodeRadioViewModel(), - onViewModelReady: (model) => model.init(), - builder: (context, model, child) => Scaffold( - backgroundColor: const Color(0xFF0a0a23), - appBar: AppBar( - title: Text(context.t.code_radio), - ), - drawer: const DrawerWidgetView(), - body: template(context, model), - ), - ); - } - - Widget template(BuildContext ctxt, CodeRadioViewModel model) { - return Container( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - StreamBuilder( - stream: model.webSocketController.stream.where( - (event) => event != {} && jsonDecode(event).containsKey('pub')), - builder: (context, snapshot) { - if (snapshot.hasData) { - final data = jsonDecode(snapshot.data.toString()); - - CodeRadio radio = CodeRadio.fromJson(data['pub']['data']['np']); - - if (!model.audioService.isPlaying('coderadio') && - !model.stoppedManually) { - model.toggleRadio(radio); - } - - model.setAndGetLastId(radio); - - return Expanded( - child: Column( - children: [ - albumArt(ctxt, radio), - Padding( - padding: const EdgeInsets.only(top: 16), - child: playingSong(radio, model), - ), - if (MediaQuery.of(context).size.height > 600) - Padding( - padding: const EdgeInsets.only(top: 16), - child: nextSong(radio, ctxt), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Row( - children: [ - Expanded( - child: playPauseButton(model, ctxt), - ), - ], - ), - ], - ), - ) - ], - ), - ); - } else if (snapshot.hasError) { - return Center( - child: Text( - context.t.coderadio_unable_to_load, - textAlign: TextAlign.center, - ), - ); - } - - return const Center(child: CircularProgressIndicator()); - }, - ), - ], - ), - ); - } - - Widget albumArt(BuildContext ctxt, CodeRadio? radio) { - var album = MediaQuery.of(ctxt).size; - - return Stack( - alignment: Alignment.center, - children: [ - Container( - constraints: BoxConstraints( - minHeight: album.height * 0.45, - minWidth: album.width, - maxHeight: album.height * 0.45, - maxWidth: album.width), - color: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - child: Image.network( - radio!.nowPlaying.artUrl, - fit: BoxFit.cover, - ), - ), - Text( - ctxt.t.coderadio_listening( - radio.totalListeners.toString(), - ), - textAlign: TextAlign.center, - style: const TextStyle( - color: Color.fromRGBO(1, 1, 1, 0.5), - fontSize: 36, - fontWeight: FontWeight.w800, - ), - ) - ], - ); - } - - StreamBuilder playPauseButton(CodeRadioViewModel model, BuildContext ctxt) { - return StreamBuilder( - stream: model.audioService.playbackState.stream, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (!snapshot.data.playing) { - model.stoppedManually = true; - } - return ElevatedButton.icon( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - const Color.fromRGBO(0x2A, 0x2A, 0x40, 1)), - ), - onPressed: () { - model.pauseUnpauseRadio(); - }, - icon: Icon(!snapshot.data.playing ? Icons.play_arrow : Icons.pause), - label: Text( - !snapshot.data.playing - ? ctxt.t.coderadio_play - : ctxt.t.coderadio_pause, - ), - ); - } - return ElevatedButton.icon( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - const Color.fromRGBO(0x2A, 0x2A, 0x40, 1)), - ), - onPressed: () { - model.pauseUnpauseRadio(); - }, - icon: Icon(model.stoppedManually ? Icons.play_arrow : Icons.pause), - label: Text( - model.stoppedManually - ? ctxt.t.coderadio_play - : ctxt.t.coderadio_pause, - ), - ); - }, - ); - } - - Widget nextSong(CodeRadio? radio, BuildContext ctxt) { - return ListTile( - title: Text(ctxt.t.coderadio_next_song), - subtitle: Row( - children: [ - Expanded( - child: Text( - '${radio!.nextPlaying.title}\n${radio.nextPlaying.album}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - tileColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - isThreeLine: true, - leading: Image.network(radio.nextPlaying.artUrl), - ); - } - - Widget playingSong(CodeRadio? radio, CodeRadioViewModel model) { - if (model.timer != null) { - model.timer!.cancel(); - } - - model.startProgressBar(radio!.elapsed, radio.duration); - - return Container( - padding: const EdgeInsets.all(8), - color: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - child: Column( - children: [ - Text( - radio.nowPlaying.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 24, fontWeight: FontWeight.bold, height: 1.5), - ), - Row( - children: [ - Expanded( - child: Text( - radio.nextPlaying.artist, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16, height: 1.5), - ), - ) - ], - ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: StreamBuilder( - stream: model.audioStateController.stream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return LinearProgressIndicator( - value: radio.duration != 0 - ? double.parse(snapshot.data.toString()) / - radio.duration - : 1, - ); - } - - return const LinearProgressIndicator( - value: 0, - ); - }, - ), - ) - ], - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/code_radio/code_radio_viewmodel.dart b/mobile-app/lib/ui/views/code_radio/code_radio_viewmodel.dart deleted file mode 100644 index 47767ebbf..000000000 --- a/mobile-app/lib/ui/views/code_radio/code_radio_viewmodel.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/code-radio/code_radio_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked/stacked.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -class CodeRadioViewModel extends BaseViewModel { - final audioService = locator().audioHandler; - bool stoppedManually = false; - - final _webSocketChannel = WebSocketChannel.connect(Uri.parse( - 'wss://coderadio-admin-v2.freecodecamp.org/api/live/nowplaying/websocket')); - final _webSocketController = StreamController.broadcast(); - StreamController get webSocketController => _webSocketController; - - int _counter = 0; - int get counter => _counter; - - final _audioStateController = StreamController(); - StreamController get audioStateController => _audioStateController; - - Timer? _timer; - Timer? get timer => _timer; - - void init() async { - _webSocketChannel.sink.add(jsonEncode({ - 'subs': {'station:coderadio': {}} - })); - await _webSocketChannel.ready; - await _webSocketController.addStream(_webSocketChannel.stream); - } - - void startProgressBar(int timeElapsed, int duration) { - _counter = timeElapsed; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - _counter++; - _audioStateController.sink.add(_counter); - - if (_counter == duration) { - timer.cancel(); - } - }); - } - - void pauseUnpauseRadio() async { - if (!audioService.isPlaying('coderadio')) { - stoppedManually = false; - - await audioService.play(); - notifyListeners(); - } else { - stoppedManually = true; - await audioService.pause(); - notifyListeners(); - } - } - - Future setAndGetLastId(CodeRadio radio) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - if (prefs.getString('lastSongId') == null) { - await prefs.setString('lastSongId', radio.nowPlaying.id); - } - - if (radio.nowPlaying.id != prefs.getString('lastSongId')) { - setBackgroundWidget(radio); - if (!stoppedManually) { - audioService.play(); - } - await prefs.setString('lastSongId', radio.nowPlaying.id); - } - } - - Future toggleRadio(CodeRadio radio) async { - setBackgroundWidget(radio); - audioService.play(); - audioService.seek(Duration(seconds: radio.elapsed)); - } - - Future setBackgroundWidget(CodeRadio radio) async { - if (audioService.isPlaying('coderadio')) { - audioService.stop(); - } - - await audioService.codeRadioMusic(radio); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/block_template_view.dart b/mobile-app/lib/ui/views/learn/block/block_template_view.dart deleted file mode 100644 index edf01cd54..000000000 --- a/mobile-app/lib/ui/views/learn/block/block_template_view.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; - -import 'package:stacked/stacked.dart'; - -class BlockTemplateView extends StatelessWidget { - final Block block; - final bool isOpen; - final Function isOpenFunction; - - const BlockTemplateView({ - super.key, - required this.block, - required this.isOpen, - required this.isOpenFunction, - }); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - onViewModelReady: (model) async { - model.init(block.challengeTiles); - model.setIsDev = await model.developerService.developmentMode(); - }, - viewModelBuilder: () => BlockTemplateViewModel(), - builder: ( - context, - model, - child, - ) { - final (icon, color) = model.getIconData(block.label); - final isDisabled = block.isDisabledByOverride; - - return Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: StreamBuilder( - stream: model.auth.progress.stream, - builder: (context, snapshot) { - return Opacity( - opacity: isDisabled ? 0.7 : 1.0, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - ), - color: const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - ), - padding: const EdgeInsets.all(8), - width: MediaQuery.of(context).size.width, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (icon != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: Icon(icon, color: color, size: 30), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - block.name, - style: TextStyle( - wordSpacing: 0, - letterSpacing: 0, - fontSize: 22, - fontWeight: FontWeight.bold, - color: color, - ), - ), - ), - ), - ], - ), - Row( - children: [ - model.getLayout( - block.layout, - model, - block, - isDisabled ? false : isOpen, - isDisabled - ? model.showDisabledSnackbar - : isOpenFunction, - color, - ), - ], - ), - ], - ), - ), - ], - ), - ), - ); - }), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart deleted file mode 100644 index 84d00a244..000000000 --- a/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/dialogue/dialogue_view.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/grid/grid_view.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/link/link_view.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/list/list_view.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class BlockTemplateViewModel extends BaseViewModel { - final NavigationService _navigationService = locator(); - final AuthenticationService auth = locator(); - final SnackbarService _snack = locator(); - final LocaleService _localeService = locator(); - - bool _isDev = false; - bool get isDev => _isDev; - - int _challengesCompleted = 0; - int get challengesCompleted => _challengesCompleted; - - final learnOfflineService = locator(); - - final developerService = locator(); - - final learnService = locator(); - - set setIsDev(bool value) { - _isDev = value; - notifyListeners(); - } - - void showDisabledSnackbar() { - final t = lookupAppLocalizations(_localeService.locale); - _snack.showSnackbar( - title: t.temporarily_unavailable, - message: '', - ); - Future.delayed(const Duration(milliseconds: 2500), () { - _snack.closeSnackbar(); - }); - } - - void routeToChallengeView(Block block, String challengeId) { - _navigationService.navigateTo( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - challengeId: challengeId, - block: block, - ), - ); - } - - Future routeToCertification(Block block) async { - String challengeId = block.challengeTiles[0].id; - - routeToChallengeView( - block, - challengeId, - ); - } - - void init(List challengeBatch) async { - setNumberOfCompletedChallenges(challengeBatch); - auth.progress.stream.listen( - (val) => setNumberOfCompletedChallenges(challengeBatch), - ); - notifyListeners(); - } - - void setNumberOfCompletedChallenges( - List challengeBatch, - ) async { - int count = 0; - - FccUserModel? user = await auth.userModel; - if (user != null) { - Iterable completedChallengeIds = user.completedChallenges.map( - (e) => e.id, - ); - - for (ChallengeListTile challenge in challengeBatch) { - if (completedChallengeIds.contains(challenge.id)) { - count++; - } - } - _challengesCompleted = count; - notifyListeners(); - } - } - - Future completedChallenge(String incomingId) async { - FccUserModel? user = await auth.userModel; - if (user != null) { - for (CompletedChallenge challenge in user.completedChallenges) { - if (challenge.id == incomingId) { - return true; - } - } - } - - return false; - } - - Widget getLayout( - BlockLayout layout, - BlockTemplateViewModel model, - Block block, - bool isOpen, - Function isOpenFunction, - Color color, - ) { - switch (layout) { - case BlockLayout.challengeGrid: - return BlockGridView( - block: block, - model: model, - isOpen: isOpen, - isOpenFunction: isOpenFunction, - color: color, - ); - case BlockLayout.challengeDialogue: - return BlockDialogueView( - block: block, - model: model, - isOpen: isOpen, - isOpenFunction: isOpenFunction, - ); - case BlockLayout.challengeList: - return BlockListView( - block: block, - model: model, - isOpen: isOpen, - isOpenFunction: isOpenFunction, - ); - case BlockLayout.challengeLink: - return BlockLinkView( - block: block, - model: model, - color: color, - ); - default: - return BlockGridView( - block: block, - model: model, - isOpen: isOpen, - isOpenFunction: isOpenFunction, - color: color, - ); - } - } - - (IconData?, Color) getIconData(BlockLabel type) { - switch (type) { - case BlockLabel.lecture: - return (Icons.menu_book_outlined, FccColors.blue30); - case BlockLabel.quiz: - return (Icons.help_outline, FccColors.orange30); - case BlockLabel.lab: - return (Icons.science_outlined, FccColors.green40); - case BlockLabel.workshop: - return (Icons.build_outlined, FccColors.purple10); - case BlockLabel.exam: - return (Icons.school, FccColors.red15); - case BlockLabel.review: - return (Icons.edit_document, FccColors.yellow10); - case BlockLabel.warmup: - return (Icons.fitbit_outlined, FccColors.blue30); - case BlockLabel.learn: - return (Icons.school_outlined, FccColors.green40); - case BlockLabel.practice: - return (Icons.edit_outlined, FccColors.purple10); - default: // BlockLabel.legacy or unknown - return (null, FccColors.blue05); - } - } -} diff --git a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart b/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart deleted file mode 100644 index c34d47fdc..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/dialogue/dialogue_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_tile.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class BlockDialogueView extends StatelessWidget { - const BlockDialogueView( - {super.key, - required this.block, - required this.model, - required this.isOpen, - required this.isOpenFunction}); - - final Block block; - final BlockTemplateViewModel model; - final bool isOpen; - final Function isOpenFunction; - - @override - Widget build(BuildContext context) { - List challenges = block.challenges; - List> structure = []; - List dialogueHeaders = []; - int dialogueIndex = 0; - - dialogueHeaders.add(challenges[0]); - structure.add([]); - - for (int i = 1; i < challenges.length; i++) { - if (challenges[i].title.contains('Dialogue')) { - structure.add([]); - dialogueHeaders.add(challenges[i]); - dialogueIndex++; - } else { - structure[dialogueIndex].add(challenges[i]); - } - } - - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => BlockDialogueViewModel(), - builder: (context, childModel, child) => Expanded( - child: Column( - children: [ - ...parser.parse( - '

${block.description.join(' ')}

', - isSelectable: false, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - 'p': Style(margin: Margins.zero), - }, - ), - _buildToggleButton(context), - _buildChallengeList(context, structure, dialogueHeaders), - ], - ), - ), - ); - } - - Widget _buildToggleButton(BuildContext context) { - return Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextButton( - onPressed: () { - isOpenFunction(); - }, - style: TextButton.styleFrom( - backgroundColor: const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: const BorderSide( - color: Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - ), - ), - ), - child: Text( - isOpen ? context.t.hide_tasks : context.t.show_tasks, - ), - ), - ), - ], - ); - } - - Widget _buildChallengeList( - BuildContext context, - List> structure, - List dialogueHeaders, - ) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 42, - maxWidth: MediaQuery.of(context).size.width - 42, - minHeight: 0, - maxHeight: 400, - ), - child: ListView.builder( - padding: const EdgeInsets.all(0), - physics: const ClampingScrollPhysics(), - shrinkWrap: true, - itemCount: structure.length, - itemBuilder: (context, dialogueBlock) { - return isOpen - ? _buildDialogueBlock( - context, structure, dialogueHeaders, dialogueBlock) - : Container(); - }, - ), - ), - ], - ); - } - - Widget _buildDialogueBlock( - BuildContext context, - List> structure, - List dialogueHeaders, - int dialogueBlock, - ) { - return FutureBuilder( - future: model.completedChallenge( - dialogueHeaders[dialogueBlock].id, - ), - builder: (context, snapshot) { - bool isCompleted = snapshot.data ?? false; - return Column( - children: [ - _buildDialogueHeader( - context, - dialogueHeaders, - dialogueBlock, - isCompleted, - ), - _buildTaskGrid(context, structure, dialogueBlock), - ], - ); - }, - ); - } - - Widget _buildDialogueHeader( - BuildContext context, - List dialogueHeaders, - int dialogueBlock, - bool isCompleted, - ) { - return Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: InkWell( - onTap: () => model.routeToChallengeView( - block, - dialogueHeaders[dialogueBlock].id, - ), - child: Container( - constraints: const BoxConstraints( - minHeight: 75, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: isCompleted - ? const Color.fromRGBO(0x00, 0x2e, 0xad, 0.3) - : const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - border: Border.all( - color: isCompleted - ? const Color.fromRGBO(0xbc, 0xe8, 0xf1, 1) - : const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - dialogueHeaders[dialogueBlock].title, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildTaskGrid( - BuildContext context, - List> structure, - int dialogueBlock, - ) { - return ConstrainedBox( - constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 38, - maxWidth: MediaQuery.of(context).size.width - 38, - minHeight: 100, - maxHeight: 5000, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: - const EdgeInsets.only(right: 8, left: 8, bottom: 12, top: 4), - child: ScrollShadow( - child: GridView.builder( - physics: const ClampingScrollPhysics(), - shrinkWrap: true, - itemCount: structure[dialogueBlock].length, - padding: const EdgeInsets.all(0), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 3, - crossAxisSpacing: 3, - ), - itemBuilder: (context, task) { - final challenge = structure[dialogueBlock][task]; - - final match = RegExp(r'\d+').firstMatch(challenge.title); - final taskNumber = - match != null ? int.parse(match.group(0)!) : task + 1; - - return ChallengeTile( - block: block, - model: model, - challengeId: challenge.id, - step: taskNumber, - isDownloaded: false, - ); - }, - ), - ), - ), - ], - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_viewmodel.dart deleted file mode 100644 index 6d9dee201..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_viewmodel.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class BlockDialogueViewModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart b/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart deleted file mode 100644 index 494f66e42..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/grid/grid_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_tile.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class BlockGridView extends StatelessWidget { - const BlockGridView({ - super.key, - required this.block, - required this.model, - required this.isOpen, - required this.isOpenFunction, - required this.color, - }); - - final Block block; - final BlockTemplateViewModel model; - final bool isOpen; - final Function isOpenFunction; - final Color color; - - @override - Widget build(BuildContext context) { - // We want to make sure never to divide by 0 and show - // a progress percentage of 1% if non have been completed. - - double progress = model.challengesCompleted == 0 - ? 0.01 - : model.challengesCompleted / block.challenges.length; - - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => BlockGridViewModel(), - builder: (context, childModel, child) => Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - model.challengesCompleted == block.challenges.length - ? Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.check_circle, - color: color, - ), - ), - Text( - context.t.completed, - style: TextStyle( - color: color, fontWeight: FontWeight.bold), - ) - ], - ), - ) - : Container( - padding: const EdgeInsets.all(10), - // For some dumb reason the progress indicator does not - // get a specified width from the column. - width: MediaQuery.of(context).size.width * 0.9, - child: LinearProgressIndicator( - minHeight: 12, - value: progress, - valueColor: AlwaysStoppedAnimation( - color, - ), - backgroundColor: - const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - borderRadius: BorderRadius.circular(10), - ), - ), - ], - ), - ...parser.parse( - '

${block.description.join(' ')}

', - isSelectable: false, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - 'p': Style(margin: Margins.zero), - }, - ), - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextButton( - onPressed: () { - isOpenFunction(); - }, - style: TextButton.styleFrom( - backgroundColor: - const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: const BorderSide( - color: Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - ), - ), - ), - child: Text( - isOpen ? context.t.hide_steps : context.t.show_steps, - ), - ), - ), - ], - ), - if (isOpen) - Container( - padding: const EdgeInsets.all(8), - constraints: BoxConstraints( - maxHeight: 300, - ), - child: Builder(builder: (context) { - final tiles = block.challengeTiles.length; - - final isScrollable = tiles > 24; - - return ScrollShadow( - child: GridView.builder( - shrinkWrap: true, - padding: const EdgeInsets.all(0), - physics: isScrollable - ? const AlwaysScrollableScrollPhysics() - : const NeverScrollableScrollPhysics(), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 3, - crossAxisSpacing: 3, - ), - itemCount: block.challenges.length, - itemBuilder: (context, step) { - final challenge = block.challengeTiles[step]; - final match = RegExp(r'\d+').firstMatch(challenge.name); - final stepNumber = match != null - ? int.parse(match.group(0)!) - : step + 1; - - return ChallengeTile( - block: block, - model: model, - step: stepNumber, - challengeId: challenge.id, - isDownloaded: false, - ); - }, - ), - ); - }), - ) - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/templates/grid/grid_viewmodel.dart deleted file mode 100644 index f2ece42f2..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_viewmodel.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class BlockGridViewModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart b/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart deleted file mode 100644 index efa8225ab..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/link/link_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class BlockLinkView extends StatelessWidget { - const BlockLinkView({ - super.key, - required this.block, - required this.model, - required this.color, - }); - - final Block block; - final BlockTemplateViewModel model; - final Color color; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => BlockLinkViewModel(), - builder: (context, childModel, child) { - return Expanded( - child: Column( - children: [ - Row( - children: [ - model.challengesCompleted == block.challenges.length - ? Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.check_circle, - color: color, - ), - ), - Text( - context.t.completed, - style: TextStyle( - color: color, fontWeight: FontWeight.bold), - ) - ], - ), - ) - : Container() - ], - ), - ...parser.parse( - '

${block.description.join(' ')}

', - isSelectable: false, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - 'p': Style(margin: Margins.zero), - }, - ), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: TextButton( - onPressed: () { - model.routeToCertification(block); - }, - style: TextButton.styleFrom( - backgroundColor: color, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - ), - ), - child: Text( - context.t.start, - style: const TextStyle( - fontWeight: FontWeight.bold, - color: FccColors.gray90, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/templates/link/link_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/templates/link/link_viewmodel.dart deleted file mode 100644 index d89e88b48..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/link/link_viewmodel.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class BlockLinkViewModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart b/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart deleted file mode 100644 index c2558392a..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/block/templates/grid/grid_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class BlockListView extends StatelessWidget { - const BlockListView({ - super.key, - required this.block, - required this.model, - required this.isOpen, - required this.isOpenFunction, - }); - - final Block block; - final BlockTemplateViewModel model; - final bool isOpen; - final Function isOpenFunction; - - @override - Widget build(BuildContext context) { - double progress = model.challengesCompleted == 0 - ? 0.01 - : model.challengesCompleted / block.challenges.length; - - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => BlockGridViewModel(), - builder: (context, childModel, child) => Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - model.challengesCompleted == block.challenges.length - ? Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.check_circle, - color: Color.fromRGBO(0x99, 0xc9, 0xff, 1), - ), - ), - Text( - context.t.completed, - style: TextStyle( - color: Color.fromRGBO(0x99, 0xc9, 0xff, 1), - fontWeight: FontWeight.bold), - ) - ], - ), - ) - : Container( - padding: const EdgeInsets.all(10), - // For some dumb reason the progress indicator does not - // get a specified width from the column. - width: MediaQuery.of(context).size.width * 0.9, - child: LinearProgressIndicator( - minHeight: 12, - value: progress, - valueColor: AlwaysStoppedAnimation( - Color.fromRGBO(0x99, 0xc9, 0xff, 1), - ), - backgroundColor: - const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - borderRadius: BorderRadius.circular(10), - ), - ), - ], - ), - ...parser.parse( - '

${block.description.join(' ')}

', - isSelectable: false, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - 'p': Style(margin: Margins.zero), - }, - ), - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextButton( - onPressed: () { - isOpenFunction(); - }, - style: TextButton.styleFrom( - backgroundColor: - const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: const BorderSide( - color: Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - ), - ), - ), - child: Text( - isOpen ? context.t.hide_steps : context.t.show_steps, - ), - ), - ), - ], - ), - if (isOpen) - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - width: MediaQuery.of(context).size.width - 34, - child: ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.all(0), - physics: const ClampingScrollPhysics(), - itemCount: block.challenges.length, - itemBuilder: (context, index) { - return FutureBuilder( - future: model.completedChallenge( - block.challenges[index].id, - ), - builder: (context, snapshot) { - bool isCompleted = snapshot.data ?? false; - - return InkWell( - onTap: () { - model.routeToChallengeView( - block, - block.challenges[index].id, - ); - }, - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: isCompleted - ? const BorderSide( - width: 1, - color: Color.fromRGBO( - 0xbc, 0xe8, 0xf1, 1), - ) - : const BorderSide( - color: Color.fromRGBO( - 0x3b, 0x3b, 0x4f, 1), - ), - ), - color: isCompleted - ? const Color.fromRGBO( - 0x00, 0x2e, 0xad, 0.3) - : const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - block.challenges[index].title, - style: TextStyle(fontSize: 18), - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/block/templates/list/list_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/templates/list/list_viewmodel.dart deleted file mode 100644 index 663792ee9..000000000 --- a/mobile-app/lib/ui/views/learn/block/templates/list/list_viewmodel.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class BlockListViewModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart deleted file mode 100644 index 34e8990a7..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart +++ /dev/null @@ -1,612 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/enums/panel_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_widgets/project_preview.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/console/console_view.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class ChallengeView extends StatelessWidget { - const ChallengeView({ - super.key, - required this.block, - required this.challenge, - required this.isProject, - this.challengeDate, - }); - - final Challenge challenge; - final Block block; - final bool isProject; - // Used for daily challenges - final DateTime? challengeDate; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => ChallengeViewModel(), - onViewModelReady: (model) { - model.init( - block: block, - challenge: challenge, - challengeDate: challengeDate, - ); - }, - onDispose: (model) { - model.closeWebViews(); - model.disposeOfListeners(); - }, - builder: (context, model, child) { - int maxChallenges = block.challenges.length; - - ChallengeFile currFile = model.currentFile(challenge); - model.initFile(challenge, currFile); - if (model.showPanel) { - FocusManager.instance.primaryFocus?.unfocus(); - } - if (model.editor == null) { - return const Center( - child: CircularProgressIndicator(), - ); - } - WidgetsBinding.instance.addPostFrameCallback((_) { - final scaffoldState = model.scaffoldKey.currentState; - if (!model.drawerOpened) { - model.setDrawerOpened = true; - if (scaffoldState != null && !(scaffoldState.isEndDrawerOpen)) { - scaffoldState.openEndDrawer(); - } - } - }); - return Scaffold( - key: model.scaffoldKey, - appBar: _buildAppBar(context, model), - endDrawer: _buildEndDrawer( - context, - model, - maxChallenges, - ), - onEndDrawerChanged: (isOpened) => model.setShowPanel = isOpened, - bottomNavigationBar: _buildBottomBar( - context, - model, - ), - body: _buildBody( - context, - model, - ), - ); - }, - ); - } - - PreferredSizeWidget _buildAppBar( - BuildContext context, - ChallengeViewModel model, - ) { - final bool isPreviewOrConsole = model.showPreview || model.showConsole; - - return AppBar( - automaticallyImplyLeading: !isPreviewOrConsole, - actions: [ - Container(), - ], - title: (() { - if (model.showPreview) { - return Text( - context.t.preview, - style: const TextStyle(fontWeight: FontWeight.bold), - ); - } else if (model.showConsole) { - return Text( - context.t.console, - style: const TextStyle(fontWeight: FontWeight.bold), - ); - } else { - return Row( - children: [ - _customTabBar( - model, - challenge, - context, - ), - ], - ); - } - })(), - bottom: const PreferredSize( - preferredSize: Size.fromHeight(1.0), - child: Divider( - height: 1, - thickness: 1, - color: FccColors.gray45, - ), - ), - ); - } - - Widget _buildEndDrawer( - BuildContext context, - ChallengeViewModel model, - int maxChallenges, - ) { - return Drawer( - width: MediaQuery.of(context).size.width * 0.9, - child: SafeArea( - child: model.getPanelWidget( - panelType: model.panelType, - challenge: challenge, - model: model, - maxChallenges: maxChallenges, - ), - ), - ); - } - - Widget _buildBottomBar( - BuildContext context, - ChallengeViewModel model, - ) { - return Container( - decoration: const BoxDecoration( - border: Border( - top: BorderSide(color: FccColors.gray45), - ), - ), - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: _customBottomBar( - model, - challenge, - context, - ), - ); - } - - Widget _buildBody( - BuildContext context, - ChallengeViewModel model, - ) { - if (model.showConsole) { - return Column( - children: [ - JavaScriptConsole( - messages: [ - ...model.testConsoleMessages.isEmpty - ? [JavaScriptConsole.defaultMessage] - : model.testConsoleMessages, - model.userConsoleMessages.isNotEmpty - ? '

// console output

' - : '', - ...model.userConsoleMessages - ], - ), - ], - ); - } else if (model.showPreview) { - return Column( - children: [ - ProjectPreview( - challenge: challenge, - model: model, - ), - ], - ); - } else { - return Column( - children: [ - if (model.showTestsPanel) - Expanded( - child: testList( - // Use the challenge from the viewmodel as the source of truth - // as the challenge object changes dynamically when the user switch languages. - model.challenge!, - model, - ), - ), - if (!model.showTestsPanel) - Expanded( - child: model.editor!, - ), - ], - ); - } - } - - Widget _customTabBar( - ChallengeViewModel model, - Challenge challenge, - BuildContext context, - ) { - return Expanded( - child: Row( - children: [ - Expanded( - child: challengeDate != null - ? _dailyChallengeLanguageSelector( - model: model, challengeDate: challengeDate!) - : _fileSelector( - model: model, - challenge: challenge, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _testsTabButton(context, model), - ), - ], - ), - ); - } - - Widget _customBottomBar( - ChallengeViewModel model, - Challenge challenge, - BuildContext context, - ) { - bool keyboard = MediaQuery.of(context).viewInsets.bottom != 0; - - return BottomAppBar( - height: keyboard ? 116 : 72, - padding: keyboard ? const EdgeInsets.only(bottom: 8) : null, - color: const Color(0x990a0a23), - child: Column( - children: [ - if (keyboard) - SymbolBar( - model: model, - editor: model.editor!, - ), - Row( - children: [ - ..._panelIconButtons( - model, - challenge, - block, - ), - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - color: !model.hasTypedInEditor - ? const Color.fromARGB(255, 9, 79, 125) - : model.completedChallenge - ? const Color.fromRGBO(60, 118, 61, 1) - : const Color.fromRGBO(0x1D, 0x9B, 0xF0, 1), - child: IconButton( - icon: model.runningTests - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(), - ) - : model.completedChallenge - ? const Icon( - Icons.arrow_forward_rounded, - size: 30, - ) - : const Icon( - Icons.done_rounded, - size: 30, - ), - onPressed: model.hasTypedInEditor - ? () => model.runTests(context) - : null, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - - Widget _fileSelector({ - required ChallengeViewModel model, - required Challenge challenge, - }) { - if (challenge.files.length > 1) { - return DropdownButton( - isExpanded: true, - dropdownColor: FccColors.gray85, - value: model.currentSelectedFile, - items: [ - for (ChallengeFile file in challenge.files) - DropdownMenuItem( - value: model.getFullFileName(file), - child: Text( - model.getFullFileName(file), - style: TextStyle( - color: model.currentSelectedFile == - model.getFullFileName(file) && - !model.showTestsPanel - ? FccColors.blue50 - : Colors.white, - fontWeight: model.currentSelectedFile == - model.getFullFileName(file) && - !model.showTestsPanel - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ), - ], - onChanged: (file) { - model.setShowTestsPanel = false; - model.setCurrentSelectedFile = - file ?? model.getFullFileName(challenge.files[0]); - model.setMounted = false; - ChallengeFile currFile = model.currentFile(challenge); - model.initFile(challenge, currFile); - }, - underline: const SizedBox(), - selectedItemBuilder: (context) { - return challenge.files.map((file) { - return Center( - child: Text( - model.getFullFileName(file), - style: TextStyle( - color: model.currentSelectedFile == - model.getFullFileName(file) && - !model.showTestsPanel - ? FccColors.blue50 - : Colors.white, - fontWeight: model.currentSelectedFile == - model.getFullFileName(file) && - !model.showTestsPanel - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ); - }).toList(); - }, - icon: const Icon(Icons.arrow_drop_down), - ); - } else { - return TextButton.icon( - onPressed: () { - model.setShowTestsPanel = false; - }, - label: Text( - '${challenge.files[0].name}.${challenge.files[0].ext.name}', - style: TextStyle( - color: !model.showTestsPanel ? FccColors.blue50 : Colors.white, - ), - ), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(FccColors.gray90), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - ), - icon: Icon( - Icons.insert_drive_file_outlined, - size: 30, - color: !model.showTestsPanel ? FccColors.blue50 : Colors.white, - ), - iconAlignment: IconAlignment.end, - ); - } - } - - Widget _dailyChallengeLanguageSelector( - {required ChallengeViewModel model, required DateTime challengeDate}) { - return DropdownButton( - isExpanded: true, - dropdownColor: FccColors.gray85, - value: model.selectedDailyChallengeLanguage, - items: DailyChallengeLanguage.values.map((lang) { - final isSelected = model.selectedDailyChallengeLanguage == lang && - !model.showTestsPanel; - return DropdownMenuItem( - value: lang, - child: Text( - lang == DailyChallengeLanguage.python ? 'Python' : 'JavaScript', - style: TextStyle( - color: isSelected ? FccColors.blue50 : Colors.white, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - ); - }).toList(), - onChanged: (lang) { - if (lang != null) { - model.setSelectedDailyChallengeLanguage(lang, challengeDate); - } - }, - underline: const SizedBox(), - selectedItemBuilder: (context) { - return DailyChallengeLanguage.values.map((lang) { - final isSelected = model.selectedDailyChallengeLanguage == lang && - !model.showTestsPanel; - return Center( - child: Text( - lang == DailyChallengeLanguage.python ? 'Python' : 'JavaScript', - style: TextStyle( - color: isSelected ? FccColors.blue50 : Colors.white, - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - ), - ), - ); - }).toList(); - }, - icon: const Icon(Icons.arrow_drop_down), - ); - } - - Widget _testsTabButton(BuildContext context, ChallengeViewModel model) { - return TextButton.icon( - onPressed: () { - model.setShowTestsPanel = !model.showTestsPanel; - model.setMounted = false; - }, - label: Text( - context.t.tests, - style: TextStyle( - color: model.showTestsPanel ? FccColors.blue50 : Colors.white, - ), - ), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(FccColors.gray90), - shape: WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - ), - icon: Icon( - Icons.science_outlined, - size: 30, - color: model.showTestsPanel ? FccColors.blue50 : Colors.white, - ), - iconAlignment: IconAlignment.end, - ); - } - - Widget _panelIconButton({ - required bool isActive, - required IconData icon, - required VoidCallback? onPressed, - }) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - color: - isActive ? Colors.white : const Color.fromRGBO(0x3B, 0x3B, 0x4F, 1), - child: IconButton( - icon: Icon( - icon, - size: 32, - color: isActive - ? const Color.fromRGBO(0x3B, 0x3B, 0x4F, 1) - : Colors.white, - ), - onPressed: onPressed, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - ), - ); - } - - Widget _testTile({ - required dynamic test, - required BuildContext context, - required ChallengeViewModel model, - required int index, - }) { - final parser = HTMLParser(context: context); - final widgets = parser.parse( - test.instruction, - isSelectable: true, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray00, - ), - 'p': Style(margin: Margins.zero), - }, - ); - return Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets, - ), - ), - if (model.testConsoleMessages.isNotEmpty || - model.userConsoleMessages.isNotEmpty) ...[ - const SizedBox(width: 8), - Icon( - model.failedTestIndexes.contains(index) - ? Icons.cancel_rounded - : Icons.check_circle_rounded, - ), - ] - ], - ), - ); - } - - List _panelIconButtons( - ChallengeViewModel model, - Challenge challenge, - Block block, - ) { - const noPreviewChallengeTypes = [1, 20, 23, 26, 27, 28, 29]; - - return [ - _panelIconButton( - isActive: model.showPanel && model.panelType == PanelType.instruction, - icon: Icons.info_outline_rounded, - onPressed: () { - if (model.panelType != PanelType.instruction) { - model.setPanelType = PanelType.instruction; - } - model.setShowPanel = !model.showPanel; - model.scaffoldKey.currentState?.openEndDrawer(); - }, - ), - if (!noPreviewChallengeTypes.contains(challenge.challengeType)) - _panelIconButton( - isActive: model.showPreview, - icon: Icons.remove_red_eye_outlined, - onPressed: () async { - ChallengeFile currFile = model.currentFile(challenge); - model.setShowPreview = !model.showPreview; - model.setShowConsole = false; - model.setMounted = false; - model.initFile(challenge, currFile); - }, - ), - _panelIconButton( - isActive: model.showConsole, - icon: Icons.terminal, - onPressed: () { - model.setShowConsole = !model.showConsole; - model.setShowPreview = false; - model.setMounted = false; - }, - ), - ]; - } - - Widget testList(Challenge challenge, ChallengeViewModel model) { - return ListView.separated( - shrinkWrap: true, - physics: const ClampingScrollPhysics(), - itemCount: challenge.tests.length, - itemBuilder: (context, index) { - final test = challenge.tests[index]; - return _testTile( - test: test, - context: context, - model: model, - index: index, - ); - }, - separatorBuilder: (BuildContext context, int i) => const Divider( - color: Colors.white, - height: 1, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart deleted file mode 100644 index 147bc7d35..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ /dev/null @@ -1,983 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/enums/dialog_type.dart'; -import 'package:freecodecamp/enums/ext_type.dart'; -import 'package:freecodecamp/enums/panel_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/learn/learn_file_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/test_runner.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/description/description_widget_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/hint/hint_widget_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/pass/pass_widget_view.dart'; -import 'package:freecodecamp/ui/widgets/setup_dialog_ui.dart'; -import 'package:html/dom.dart'; -import 'package:html/parser.dart'; -import 'package:phone_ide/controller/custom_text_controller.dart'; -import 'package:phone_ide/models/textfield_data.dart'; -import 'package:phone_ide/phone_ide.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class ChallengeViewModel extends BaseViewModel { - bool get isDailyChallenge => _isDailyChallenge; - final InAppLocalhostServer _localhostServer = - InAppLocalhostServer(documentRoot: 'assets/test_runner'); - - Editor? _editor; - Editor? get editor => _editor; - - String? _editorText; - String? get editorText => _editorText; - - String _editorLanguage = 'html'; - String get editorLanguage => _editorLanguage; - - String _currentSelectedFile = ''; - String get currentSelectedFile => _currentSelectedFile; - - bool _showPreview = false; - bool get showPreview => _showPreview; - - bool _showProjectPreview = false; - bool get showProjectPreview => _showProjectPreview; - - bool _showConsole = false; - bool get showConsole => _showConsole; - - String _hint = ''; - String get hint => _hint; - - bool _showPanel = true; - bool get showPanel => _showPanel; - - bool _runningTests = false; - bool get runningTests => _runningTests; - - List _failedTestIndexes = []; - List get failedTestIndexes => _failedTestIndexes; - - bool _hasTypedInEditor = false; - bool get hasTypedInEditor => _hasTypedInEditor; - - bool _completedChallenge = false; - bool get completedChallenge => _completedChallenge; - - bool _symbolBarIsScrollable = true; - bool get symbolBarIsScrollable => _symbolBarIsScrollable; - - List _testConsoleMessages = []; - List get testConsoleMessages => _testConsoleMessages; - - List _userConsoleMessages = []; - List get userConsoleMessages => _userConsoleMessages; - - ScrollController symbolBarScrollController = ScrollController(); - - PanelType _panelType = PanelType.instruction; - PanelType get panelType => _panelType; - - InAppWebViewController? _webviewController; - InAppWebViewController? get webviewController => _webviewController; - - InAppWebViewController? _testController; - InAppWebViewController? get testController => _testController; - - DateTime? _challengeDate; - bool _isDailyChallenge = false; - - DailyChallengeLanguage? _selectedDailyChallengeLanguage; - DailyChallengeLanguage? get selectedDailyChallengeLanguage => - _selectedDailyChallengeLanguage; - - final HeadlessInAppWebView _babelWebView = HeadlessInAppWebView( - initialData: InAppWebViewInitialData( - data: 'Babel', - mimeType: 'text/html', - baseUrl: WebUri('http://localhost:8080/babel-transformer'), - ), - onConsoleMessage: (controller, console) { - log('Babel Console message: ${console.message}'); - }, - onLoadStop: (controller, url) async { - final res = await controller.injectJavascriptFileFromAsset( - assetFilePath: 'assets/test_runner/babel/babel.min.js'); - log('Babel load: $res'); - }, - initialSettings: InAppWebViewSettings( - isInspectable: true, - ), - ); - late final HeadlessInAppWebView _testRunnerWebView = HeadlessInAppWebView( - initialData: InAppWebViewInitialData( - data: 'Test Runner', - mimeType: 'text/html', - baseUrl: WebUri('http://localhost:8080/test-runner'), - ), - onWebViewCreated: (controller) { - _testController = controller; - }, - onConsoleMessage: (controller, console) { - if (console.messageLevel == ConsoleMessageLevel.LOG) { - setUserConsoleMessages = [ - ...userConsoleMessages, - '

${console.message}

', - ]; - } - }, - onLoadStop: (controller, url) async { - ScriptBuilder builder = ScriptBuilder(); - final res = await controller.callAsyncJavaScript( - functionBody: ScriptBuilder.runnerScript, - arguments: { - 'userCode': '', - 'workerType': builder.getWorkerType(challenge?.challengeType ?? 0), - 'combinedCode': '', - 'editableRegionContent': '', - 'hooks': { - 'beforeAll': '', - 'beforeEach': '', - 'afterEach': '', - }, - }, - ); - log('TestRunner: $res'); - }, - initialSettings: InAppWebViewSettings( - isInspectable: true, - mediaPlaybackRequiresUserGesture: false, - ), - ); - bool _closingWebViews = false; - bool _webViewsClosed = false; - - bool _mounted = false; - bool get mounted => _mounted; - - String _editableRegionContent = ''; - String get editableRegionContent => _editableRegionContent; - - SnackbarService snackbar = locator(); - - Challenge? _challenge; - Challenge? get challenge => _challenge; - - Block? _block; - Block? get block => _block; - - EditorOptions defaultEditorOptions = EditorOptions(); - - TextFieldData? _textFieldData; - TextFieldData? get textFieldData => _textFieldData; - - final _dialogService = locator(); - final NavigationService _navigationService = locator(); - final LearnFileService fileService = locator(); - final LearnService learnService = locator(); - final learnOfflineService = locator(); - final dailyChallengeService = locator(); - - final _dio = DioService.dio; - - late StreamSubscription _textFieldDataSub; - late StreamSubscription _onTextChangeSub; - StreamSubscription? _editableRegionSub; - - set setCurrentSelectedFile(String value) { - _currentSelectedFile = value; - notifyListeners(); - } - - set setIsRunningTests(bool value) { - _runningTests = value; - notifyListeners(); - } - - set setEditableRegionContent(String value) { - _editableRegionContent = value; - notifyListeners(); - } - - set setShowPanel(bool value) { - _showPanel = value; - notifyListeners(); - } - - set setWebviewController(InAppWebViewController value) { - _webviewController = value; - notifyListeners(); - } - - set setShowPreview(bool value) { - _showPreview = value; - notifyListeners(); - } - - set setShowProjectPreview(bool value) { - _showProjectPreview = value; - notifyListeners(); - } - - set setShowConsole(bool value) { - _showConsole = value; - notifyListeners(); - } - - set setHasTypedInEditor(bool value) { - _hasTypedInEditor = true; - notifyListeners(); - } - - set setEditorText(String value) { - _editorText = value; - notifyListeners(); - } - - set setHint(String hint) { - _hint = hint; - notifyListeners(); - } - - set setPanelType(PanelType panelType) { - _panelType = panelType; - notifyListeners(); - } - - set setCompletedChallenge(bool completed) { - _completedChallenge = completed; - notifyListeners(); - } - - set setChallenge(Challenge challenge) { - _challenge = challenge; - notifyListeners(); - } - - set setBlock(Block block) { - _block = block; - notifyListeners(); - } - - set setMounted(bool value) { - _mounted = value; - notifyListeners(); - } - - set setTextFieldData(TextFieldData textfieldData) { - _textFieldData = textfieldData; - notifyListeners(); - } - - set setSymbolBarIsScrollable(bool value) { - _symbolBarIsScrollable = value; - notifyListeners(); - } - - set setEditor(Editor editor) { - _editor = editor; - notifyListeners(); - } - - set setEditorLanguage(String value) { - _editorLanguage = value; - notifyListeners(); - } - - set setTestConsoleMessages(List messages) { - _testConsoleMessages = messages; - notifyListeners(); - } - - set setUserConsoleMessages(List messages) { - _userConsoleMessages = messages; - notifyListeners(); - } - - bool _showTestsPanel = false; - bool get showTestsPanel => _showTestsPanel; - - set setShowTestsPanel(bool value) { - _showTestsPanel = value; - notifyListeners(); - } - - bool _drawerOpened = false; - bool get drawerOpened => _drawerOpened; - set setDrawerOpened(bool value) { - _drawerOpened = value; - notifyListeners(); - } - - GlobalKey _scaffoldKey = GlobalKey(); - GlobalKey get scaffoldKey => _scaffoldKey; - set setScaffoldKey(GlobalKey key) { - _scaffoldKey = key; - notifyListeners(); - } - - void init({ - required Block block, - required Challenge challenge, - required DateTime? challengeDate, - }) async { - _challengeDate = challengeDate; - _isDailyChallenge = challengeDate != null; - - if (challengeDate != null) { - await loadSelectedDailyChallengeLanguage(); - } - - setupDialogUi(); - - setChallenge = challenge; - setBlock = block; - - final webViewsStarted = await _startWebViews(); - if (!webViewsStarted) return; - - listenToSymbolBarScrollController(); - } - - bool get _shouldStopWebViewSetup => _closingWebViews || _webViewsClosed; - - Future _startWebViews() async { - final setupSteps = [ - _localhostServer.start, - _babelWebView.run, - _testRunnerWebView.run, - ]; - - for (final setupStep in setupSteps) { - if (_shouldStopWebViewSetup) return false; - await setupStep(); - } - - return !_shouldStopWebViewSetup; - } - - Future setSelectedDailyChallengeLanguage( - DailyChallengeLanguage lang, DateTime challengeDate) async { - setShowTestsPanel = false; - _selectedDailyChallengeLanguage = lang; - - SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString('selectedDailyChallengeLanguage', lang.name); - - // Switching languages require re-fetching the challenge data - // as we only store the data for a single language in the `challenge` object. - // This, however, is not expensive because we do cache the challenge data in dailyChallengeService. - final formattedChallengeDate = formatChallengeDate(challengeDate); - Challenge dailyChallenge = await dailyChallengeService.getDailyChallenge( - formattedChallengeDate, - _block!, - language: lang, - ); - - setChallenge = dailyChallenge; - setMounted = false; - initFile(dailyChallenge, dailyChallenge.files[0]); - } - - void closeWebViews() async { - if (_closingWebViews || _webViewsClosed) return; - _closingWebViews = true; - - try { - await _testRunnerWebView.dispose(); - } catch (e) { - log('Test runner dispose error: $e'); - } - try { - await _babelWebView.dispose(); - } catch (e) { - log('Babel dispose error: $e'); - } - try { - await _localhostServer.close(); - } catch (e) { - log('Localhost server close error: $e'); - } - - _webViewsClosed = true; - _closingWebViews = false; - } - - void initFile( - Challenge challenge, - ChallengeFile currFile, - ) async { - if (!_mounted) { - String fileContents = await fileService.getExactFileFromCache( - challenge, - currFile, - ); - - setCurrentSelectedFile = getFullFileName(currFile); - setEditorText = fileContents; - setEditorLanguage = currFile.ext.value; - initEditor(challenge, currFile); - setMounted = true; - } - } - - void initEditor(Challenge challenge, ChallengeFile file) { - bool editableRegion = file.editableRegionBoundaries.isNotEmpty; - - EditorOptions options = EditorOptions( - regionOptions: editableRegion - ? EditorRegionOptions( - start: file.editableRegionBoundaries[0], - end: file.editableRegionBoundaries[1], - ) - : null, - fontFamily: 'Hack', - ); - - Editor editor = Editor( - key: ValueKey(editorText), - defaultLanguage: editorLanguage, - defaultValue: editorText ?? '', - path: getFullFilePath(challenge, file), - options: options, - ); - - setEditor = editor; - - initEditorListeners(challenge, file, editor); - } - - void initEditorListeners( - Challenge challenge, - ChallengeFile file, - Editor editor, - ) { - bool editableRegion = file.editableRegionBoundaries.isNotEmpty; - - _textFieldDataSub = editor.textfieldData.stream.listen((textfieldData) { - setTextFieldData = textfieldData; - setShowPanel = false; - }); - - _onTextChangeSub = editor.onTextChange.stream.listen((text) { - fileService.saveFileInCache( - challenge, - currentSelectedFile, - text, - ); - - setEditorText = text; - setHasTypedInEditor = true; - setCompletedChallenge = false; - }); - - if (editableRegion) { - _editableRegionSub = editor.editableRegion.stream.listen((region) { - setEditableRegionContent = region; - }); - } - } - - void disposeOfListeners() { - _textFieldDataSub.cancel(); - _onTextChangeSub.cancel(); - - if (_editableRegionSub != null) { - _editableRegionSub!.cancel(); - } - } - - void listenToSymbolBarScrollController() { - symbolBarScrollController.addListener(() { - ScrollPosition sp = symbolBarScrollController.position; - - if (sp.pixels >= sp.maxScrollExtent) { - setSymbolBarIsScrollable = false; - } else if (!symbolBarIsScrollable) { - setSymbolBarIsScrollable = true; - } - }); - } - - // This function allows the symbols to be insterted into the text controllers - void insertSymbol(String symbol, Editor editor) async { - final TextEditingControllerIDE focused = textFieldData!.controller; - final RegionPosition position = textFieldData!.position; - final String text = focused.text; - final selection = focused.selection; - - if (symbol == 'Tab') { - symbol = '\t'; - } - - final newText = text.replaceRange(selection.start, selection.end, symbol); - focused.value = TextEditingValue( - text: newText, - selection: TextSelection.collapsed( - offset: selection.start + 1, - ), - ); - - editor.textfieldData.sink.add( - TextFieldData(controller: focused, position: position), - ); - } - - // This prevents the user from requesting the challenge more than once - // when swichting between preview and the challenge. - - Future initChallenge(String url) async { - // NOTE: Function is not used anywhere - SharedPreferences prefs = await SharedPreferences.getInstance(); - Response res = await _dio.get(url); - - if (prefs.getString(url) == null) { - if (res.statusCode == 200) { - Challenge challenge = Challenge.fromJson(res.data); - - await prefs.setString(url, res.data); - - return challenge; - } - } - - Challenge challenge = Challenge.fromJson( - jsonDecode(prefs.getString(url) as String), - ); - - return challenge; - } - - // This parses the preview document with the correct viewport size. This document - // is then displayed in the preview panel. The preview document does not modify - // the original challenge document. - - Future parsePreviewDocument(String doc) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - Challenge? currChallenge = challenge; - - if (currChallenge == null) return parse(doc).outerHtml; - - List cssFiles = currChallenge.files - .where((ChallengeFile file) => file.ext == Ext.css) - .toList(); - - List jsFiles = currChallenge.files - .where((ChallengeFile file) => file.ext == Ext.js) - .toList(); - - Document document = parse(doc); - String? cssParsed, jsParsed; - - List currentFile = currChallenge.files - .where((element) => element.ext == Ext.html) - .toList(); - - String text = prefs.getString( - '${currChallenge.id}.${getFullFileName(currentFile[0])}', - ) ?? - currentFile[0].contents; - - // TODO: Remove check since we do it in function also - if (cssFiles.isNotEmpty) { - cssParsed = await fileService.parseCssDocumentsAsStyleTags( - currChallenge, - text, - ); - - document = parse(cssParsed); - } - - if (jsFiles.isNotEmpty) { - jsParsed = await fileService.parseJsDocumentsAsScriptTags( - currChallenge, - cssParsed ?? text, - _babelWebView.webViewController, - ); - - document = parse(jsParsed); - } - - String viewPort = ''' - '''; - - Document viewPortParsed = parse(viewPort); - Node meta = viewPortParsed.getElementsByTagName('META')[0]; - - document.getElementsByTagName('HEAD')[0].append(meta); - - return document.outerHtml; - } - - Future providePreview(Challenge challenge) async { - String cacheString = await fileService.getFirstFileFromCache( - challenge, - Ext.html, - ); - - Future document = parsePreviewDocument(cacheString); - - return document; - } - - String parseUsersConsoleMessages(String string) { - if (!string.startsWith('testMSG')) { - return '

$string

'; - } - - return string; - } - - void refresh() { - setChallenge = challenge!; - notifyListeners(); - } - - String getFullFileName(ChallengeFile file) { - return '${file.name}.${file.ext.value}'; - } - - String getFullFilePath(Challenge challenge, ChallengeFile file) { - return '/${challenge.id}/${getFullFileName(file)}'; - } - - ChallengeFile currentFile(Challenge challengeParam) { - // For daily challenges, we don't use the `challenge` param passed from the view - // but use the `_challenge` variable as the source of truth instead. - // This is because when a camper switches languages, - // we load a new `challenge` object and store it in `_challenge`. - Challenge currChallenge = - (isDailyChallenge && challenge != null) ? challenge! : challengeParam; - - if (currentSelectedFile.isNotEmpty) { - ChallengeFile file = currChallenge.files.firstWhere( - (file) => getFullFileName(file) == currentSelectedFile, - ); - return file; - } - - List? fileWithEditableRegion = currChallenge.files - .where((file) => file.editableRegionBoundaries.isNotEmpty) - .toList(); - - if (fileWithEditableRegion.isNotEmpty) { - return fileWithEditableRegion[0]; - } - - return currChallenge.files[0]; - } - - void resetCode(Editor editor, BuildContext context) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - DialogResponse? res = await _dialogService.showCustomDialog( - barrierDismissible: true, - variant: DialogType.buttonForm, - title: context.t.reset_code, - description: context.t.reset_description, - mainButtonTitle: context.t.reset, - ); - - if (res?.confirmed != true) { - return; - } - - Challenge currChallenge = challenge!; - - for (ChallengeFile file in currChallenge.files) { - // NOTE: Removes file content from cache - await prefs.remove('${currChallenge.id}.${getFullFileName(file)}'); - // NOTE: Removes file editable region boundaries from cache - await prefs.remove(getFullFilePath(currChallenge, file)); - } - - if (isDailyChallenge) { - final formattedChallengeDate = formatChallengeDate(_challengeDate!); - Challenge refreshedChallenge = - await dailyChallengeService.getDailyChallenge( - formattedChallengeDate, - block!, - language: selectedDailyChallengeLanguage!, - ); - - setChallenge = refreshedChallenge; - - closeWebViews(); - disposeOfListeners(); - - _navigationService.replaceWith( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - block: block!, - challengeId: currChallenge.id, - challengeDate: _challengeDate, - ), - ); - } else { - // For standard challenges, keep the existing behavior - var challengeIndex = block!.challengeTiles.indexWhere( - (element) => element.id == currChallenge.id, - ); - - String slug = block!.challengeTiles[challengeIndex].id; - String url = LearnService.baseUrl; - String challengeUrl = - '$url/challenges/${block!.superBlock.dashedName}/${block!.dashedName}/$slug.json'; - - await prefs.remove(challengeUrl); - - closeWebViews(); - disposeOfListeners(); - - _navigationService.replaceWith( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - block: block!, - challengeId: currChallenge.id, - ), - ); - } - } - - String replacePlaceholders( - String instruction, - Map? failedTestErr, - ) { - return instruction - .replaceAll( - '--fcc-expected--', (failedTestErr?['expected'] ?? '').toString()) - .replaceAll( - '--fcc-actual--', (failedTestErr?['actual'] ?? '').toString()); - } - - void runTests(BuildContext context) async { - setShowPanel = false; - setIsRunningTests = true; - _failedTestIndexes = []; - List failedTests = []; - List failedResults = []; - - ScriptBuilder builder = ScriptBuilder(); - List failedTestsConsole = []; - - _userConsoleMessages = []; - setTestConsoleMessages = ['

// running tests

']; - String userCode; - try { - userCode = await builder.buildUserCode( - challenge!, - _babelWebView.webViewController, - ); - } catch (e) { - String errorMessage = e.toString(); - if (errorMessage.contains('Babel transpilation failed')) { - errorMessage = errorMessage.replaceFirst('Exception: ', ''); - } - String userFriendlyMessage = parseSyntaxError( - errorMessage, - lineMessage: context.t.syntax_error_line, - generalMessage: context.t.syntax_error_general, - ); - - setPanelType = PanelType.hint; - setHint = '

$userFriendlyMessage

'; - setTestConsoleMessages = [ - ...testConsoleMessages, - '

$userFriendlyMessage

', - '

// tests completed

', - ]; - setIsRunningTests = false; - _scaffoldKey.currentState?.openEndDrawer(); - return; - } - - InAppWebViewController? runnerController = testController; - if (runnerController == null) { - setTestConsoleMessages = [ - ...testConsoleMessages, - '

Test runner is still initializing. Please try again.

', - '

// tests completed

', - ]; - setIsRunningTests = false; - return; - } - - Future checkUserCodeForErrors(String userCode) async { - if ([1, 26, 28].contains(challenge!.challengeType)) { - final evalResult = await runnerController.callAsyncJavaScript( - functionBody: userCode, - ); - if (evalResult != null && evalResult.error != null) { - setUserConsoleMessages = [ - if (userConsoleMessages.isNotEmpty) - ...userConsoleMessages.sublist(0, userConsoleMessages.length - 1), - '

${evalResult.error}

', - ]; - } - } - } - - Future updateTestRunner(String userCode) async { - // ignore: unused_local_variable - final updateTestRunnerRes = await runnerController.callAsyncJavaScript( - functionBody: ScriptBuilder.runnerScript, - arguments: { - 'userCode': userCode, - 'workerType': builder.getWorkerType(challenge!.challengeType), - 'combinedCode': await builder.combinedCode(challenge!), - 'editableRegionContent': editableRegionContent, - 'hooks': { - 'beforeAll': challenge!.hooks.beforeAll, - 'beforeEach': challenge!.hooks.beforeEach, - 'afterEach': challenge!.hooks.afterEach, - }, - }, - ); - } - - Future>> evaluateTests(String userCode) async { - List failingTests = []; - List failingResults = []; - List failedIndexes = []; - - for (int i = 0; i < challenge!.tests.length; i++) { - ChallengeTest test = challenge!.tests[i]; - final testRes = await runnerController.callAsyncJavaScript( - functionBody: ScriptBuilder.testExecutionScript, - arguments: { - 'testStr': test.javaScript, - }, - ); - if (testRes != null && testRes.value['pass'] == null) { - failingTests.add(test); - failingResults.add(testRes); - failedIndexes.add(i); - } else if (testRes == null) { - log('TEST RESULT NULL: $testRes'); - throw Exception('Test result is null ${testRes.toString()}'); - } - } - - return { - 'failingTests': failingTests, - 'failingResults': failingResults, - 'failedIndexes': failedIndexes - }; - } - - void logFailures( - List tests, - List results, - ) { - final pairCount = - tests.length < results.length ? tests.length : results.length; - - for (int i = 0; i < pairCount; i++) { - final test = tests[i]; - final testErr = results[i].value['err']; - - log('TEST FAILED: ${test.instruction} - ${test.javaScript} - $testErr'); - failedTestsConsole.add( - '
  • ${replacePlaceholders(test.instruction, testErr)}
  • ', - ); - } - } - - await checkUserCodeForErrors(userCode); - await updateTestRunner(userCode); - var { - 'failingTests': failingTests, - 'failingResults': failingResults, - 'failedIndexes': failedIndexes - } = await evaluateTests(userCode); - - if (failingTests.isNotEmpty) { - failedTests = failingTests as List; - failedResults = failingResults as List; - logFailures(failedTests, failedResults); - _failedTestIndexes = failedIndexes as List; - } - - final failedTest = failedTests.isEmpty ? null : failedTests.first; - final failedTestErr = - failedResults.isEmpty ? null : failedResults.first.value['err'] as Map?; - - if (failedTest != null) { - setPanelType = PanelType.hint; - setHint = replacePlaceholders(failedTest.instruction, failedTestErr); - _scaffoldKey.currentState?.openEndDrawer(); - } else { - setPanelType = PanelType.pass; - setCompletedChallenge = true; - _scaffoldKey.currentState?.openEndDrawer(); - } - - setTestConsoleMessages = [ - ...testConsoleMessages, - failedTestsConsole.isNotEmpty - ? '
      ${failedTestsConsole.join()}
    ' - : '', - '

    // tests completed

    ', - ]; - setIsRunningTests = false; - // TODO: Do we still need this variable - setShowPanel = true; - } - - Future loadSelectedDailyChallengeLanguage() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - String? langStr = prefs.getString('selectedDailyChallengeLanguage'); - - _selectedDailyChallengeLanguage = - DailyChallengeService.parseLanguageFromString(langStr); - notifyListeners(); - } - - Widget getPanelWidget({ - required PanelType panelType, - required Challenge challenge, - required ChallengeViewModel model, - required int maxChallenges, - }) { - switch (panelType) { - case PanelType.instruction: - return DescriptionView( - description: challenge.description, - instructions: challenge.instructions, - challengeModel: model, - maxChallenges: maxChallenges, - title: challenge.title, - ); - case PanelType.hint: - return HintWidgetView( - hint: model.hint, - challengeModel: model, - editor: model.editor!, - ); - case PanelType.pass: - return PassWidgetView( - challengeModel: model, - maxChallenges: maxChallenges, - ); - default: - return Container(); - } - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart deleted file mode 100644 index 61e0d37c3..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/english/english_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/explanation_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/scene/scene_view.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class EnglishView extends StatelessWidget { - const EnglishView({ - super.key, - required this.challenge, - required this.block, - required this.currentChallengeNum, - }); - - final Challenge challenge; - final Block block; - final int currentChallengeNum; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => EnglishViewModel(), - builder: (context, model, child) { - return Scaffold( - backgroundColor: FccColors.gray90, - persistentFooterAlignment: AlignmentDirectional.topStart, - appBar: AppBar( - backgroundColor: FccColors.gray90, - title: Text(handleChallengeTitle(challenge, block)), - ), - body: SafeArea( - child: ListView( - children: [ - ChallengeCard( - title: challenge.title, - child: Column( - children: [ - ...parser.parse( - challenge.description, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': - Style(color: FccColors.gray05), - }, - ), - ...parser.parse( - challenge.instructions, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': - Style(color: FccColors.gray05), - }, - ), - ], - ), - ), - if (challenge.scene != null) ...[ - ChallengeCard( - title: context.t.challenge_card_scene, - child: SceneView( - scene: challenge.scene!, - ), - ), - ], - if (challenge.fillInTheBlank != null) - ChallengeCard( - title: context.t.challenge_card_fill_blank, - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - children: model.getFillInBlankWidgets( - challenge, - context, - ), - ), - ), - ), - if (model.feedback.isNotEmpty) - ChallengeCard( - title: context.t.challenge_card_feedback, - child: Column( - children: parser.parse(model.feedback), - ), - ), - if (challenge.explanation != null && - challenge.explanation!.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_explanation, - child: Explanation( - explanation: challenge.explanation ?? '', - ), - ), - ], - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(8), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: model.allInputsCorrect - ? () => model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ) - : () => {model.checkAnswers(challenge)}, - child: Text( - model.allInputsCorrect || - challenge.fillInTheBlank == null - ? context.t.go_to_next_challenge - : context.t.check_answers, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(8), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: () => model.learnService.forumHelpDialog( - challenge, - block, - context, - ), - child: Text( - context.t.ask_for_help, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart deleted file mode 100644 index 53d45b1e4..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_viewmodel.dart +++ /dev/null @@ -1,231 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:html/parser.dart'; -import 'package:stacked/stacked.dart'; - -class EnglishViewModel extends BaseViewModel { - final LearnOfflineService learnOfflineService = - locator(); - - final LearnService learnService = locator(); - - Map _currentBlankValues = {}; - Map get currentBlankValues => _currentBlankValues; - - Map _inputValuesCorrect = {}; - Map get inputValuesCorrect => _inputValuesCorrect; - - String _feedback = ''; - String get feedback => _feedback; - - bool _allInputsCorrect = false; - bool get allInputsCorrect => _allInputsCorrect; - - final StreamController> fills = - StreamController>.broadcast(); - - set setCurrentBlankValues(Map value) { - _currentBlankValues = value; - notifyListeners(); - } - - set setInptuValuesCorrect(Map value) { - _inputValuesCorrect = value; - notifyListeners(); - } - - set setAllInputsCorrect(bool value) { - _allInputsCorrect = value; - notifyListeners(); - } - - set setFeedback(String value) { - _feedback = value; - notifyListeners(); - } - - void initBlankInputStreamListener() { - fills.stream.listen((Map event) { - setCurrentBlankValues = event; - }); - } - - double calculateTextWidth(String text, TextStyle style) { - final TextPainter textPainter = TextPainter( - text: TextSpan(text: text.replaceAll('BLANK', ''), style: style), - textDirection: TextDirection.ltr, - )..layout(); - return textPainter.size.width; - } - - void checkAnswers(Challenge challenge) { - List inputKeys = currentBlankValues.keys.toList(); - List inputValues = currentBlankValues.values.toList(); - - Map correctIncorrect = {}; - - for (int i = 0; i < inputKeys.length; i++) { - if (challenge.fillInTheBlank == null) break; - inputValues[i] = inputValues[i].trim(); - - bool value = inputValues[i].toLowerCase() == challenge.fillInTheBlank!.blanks[i].answer.toLowerCase(); - correctIncorrect['blank_correct_$i'] = value; - } - - setInptuValuesCorrect = correctIncorrect; - - setAllInputsCorrect = correctIncorrect.values.every( - (value) => value == true, - ); - - if (!allInputsCorrect) { - int firstIncorrectIndex = correctIncorrect.values.toList().indexOf(false); - - if (firstIncorrectIndex != -1) { - Blank blank = challenge.fillInTheBlank!.blanks[firstIncorrectIndex]; - setFeedback = blank.feedback; - } - } else { - setFeedback = ''; - } - } - - OutlineInputBorder handleInputBorderColor(int inputIndex) { - if (inputValuesCorrect.isEmpty) { - return const OutlineInputBorder( - borderSide: BorderSide(color: FccColors.blue50), - ); - } - - if (inputValuesCorrect['blank_correct_$inputIndex'] == true) { - return const OutlineInputBorder( - borderSide: BorderSide( - width: 2, - color: Colors.green, - ), - ); - } else { - return const OutlineInputBorder( - borderSide: BorderSide( - width: 2, - color: Colors.red, - ), - ); - } - } - - List getFillInBlankWidgets( - Challenge challenge, - BuildContext context, - ) { - List widgets = []; - - List sentences = challenge.fillInTheBlank!.sentence.split('\n'); - - int blankIndex = 0; - for (String sentence in sentences) { - List children = []; - - List words = sentence.split(' '); - for (String word in words) { - if (word.contains('BLANK')) { - String uniqueId = 'blank_$blankIndex'; - - if (currentBlankValues[uniqueId] == null) { - currentBlankValues.addAll({uniqueId: ''}); - } - - // The blank word is sometimes concatenated with the previous or next word - List splitWord = word.split('BLANK'); - - if (splitWord.isNotEmpty) { - children.add( - Text( - parseFragment(splitWord[0]).text ?? '', - textAlign: TextAlign.start, - style: const TextStyle(fontSize: 20, letterSpacing: 0), - ), - ); - } - - children.add( - Container( - margin: const EdgeInsets.only( - left: 5, - right: 5, - ), - width: calculateTextWidth( - challenge.fillInTheBlank!.blanks[blankIndex].answer, - const TextStyle(fontSize: 20), - ) + - 20, - child: TextFormField( - initialValue: currentBlankValues[uniqueId], - cursorHeight: 19, - onChanged: (value) { - Map local = currentBlankValues; - local[uniqueId] = value; - fills.add(local); - }, - smartQuotesType: SmartQuotesType.disabled, - spellCheckConfiguration: - const SpellCheckConfiguration.disabled(), - autocorrect: false, - decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - ), - focusedBorder: handleInputBorderColor(blankIndex), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(5), - ), - isDense: true, - enabledBorder: handleInputBorderColor(blankIndex), - ), - style: const TextStyle( - fontSize: 19, - letterSpacing: 0, - ), - ), - ), - ); - - if (splitWord.length > 1) { - children.add( - Text( - parseFragment(splitWord[1]).text ?? '', - style: const TextStyle(fontSize: 20, letterSpacing: 0), - ), - ); - } - - blankIndex++; - } else { - children.add( - Text( - parseFragment(word).text ?? '', - style: const TextStyle(fontSize: 20, letterSpacing: 0), - ), - ); - } - } - - widgets.add( - Padding( - padding: const EdgeInsets.only(top: 16), - child: Align( - alignment: Alignment.topLeft, - child: Wrap(spacing: 3, runSpacing: 3, children: children), - ), - ), - ); - } - - return widgets; - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart deleted file mode 100644 index 293d486bd..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart +++ /dev/null @@ -1,243 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/assignment_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/example_editor.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/explanation_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/scene/scene_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/transcript_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/youtube_player_widget.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class MultipleChoiceView extends StatelessWidget { - const MultipleChoiceView({ - super.key, - required this.challenge, - required this.block, - required this.currentChallengeNum, - }); - - final Challenge challenge; - final Block block; - final int currentChallengeNum; - - Widget buildDivider() { - return const Divider( - color: Colors.white, - thickness: 1, - ); - } - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => MultipleChoiceViewmodel(), - onViewModelReady: (model) => model.initChallenge(challenge), - builder: (context, model, child) { - return Scaffold( - backgroundColor: const Color(0xFF0a0a23), - appBar: AppBar( - backgroundColor: const Color(0xFF0a0a23), - title: Text(handleChallengeTitle(challenge, block)), - ), - body: SafeArea( - child: ListView( - children: [ - if (challenge.description.isNotEmpty) - ChallengeCard( - title: context.t.challenge_card_description, - child: Column( - children: parser.parse( - challenge.description, - ), - ), - ), - if (challenge.nodules?.isNotEmpty ?? false) - ExampleEditor( - nodules: challenge.nodules!, - parser: parser, - ), - if (challenge.videoId != null) ...[ - ChallengeCard( - title: context.t.challenge_card_watch_video, - child: YoutubePlayerWidget( - videoId: challenge.videoId!, - ), - ), - ], - if (challenge.transcript.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_transcript, - child: Transcript( - transcript: challenge.transcript, - isCollapsible: challenge.videoId != null, - ), - ), - ], - if (challenge.scene != null) ...[ - ChallengeCard( - title: context.t.challenge_card_scene, - child: SceneView( - scene: challenge.scene!, - ), - ), - ] else if (challenge.audio != null) ...[ - ChallengeCard( - title: context.t.challenge_card_listen_audio, - child: AudioPlayerView( - audio: challenge.audio!, - ), - ), - ], - if (challenge.instructions.isNotEmpty) - ChallengeCard( - title: context.t.instructions, - child: Column( - children: parser.parse( - challenge.instructions, - ), - ), - ), - if (challenge.assignments != null && - challenge.assignments!.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_assignments, - child: Column( - children: [ - for (final (i, assignment) - in challenge.assignments!.indexed) - Assignment( - label: assignment, - value: model.assignmentsStatus[i], - onTap: () { - model.setAssignmentStatus(i); - }, - onChanged: (value) { - model.setAssignmentStatus(i); - }), - ], - ), - ), - ], - QuizWidget( - isValidated: model.isValidated, - questions: model.quizQuestions, - onChanged: (questionIndex, answerIndex) { - model.setSelectedAnswer(questionIndex, answerIndex); - }), - const SizedBox(height: 8), - if (challenge.explanation != null && - challenge.explanation!.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_explanation, - child: Explanation( - explanation: challenge.explanation ?? '', - ), - ), - ], - const SizedBox(height: 8), - if (model.errMessage.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - model.errMessage, - style: const TextStyle( - fontSize: 18, - ), - ), - ), - ], - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide( - width: 2, - color: Colors.white, - ), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - ), - onPressed: - model.assignmentsStatus.every((element) => element) && - model.quizQuestions - .every((q) => q.selectedAnswer != -1) - ? () { - if (model.isValidated && - model.hasPassedAllQuestions) { - model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ); - } else { - model.validateChallenge( - context.t.some_answers_are_incorrect); - } - } - : null, - child: Text( - model.isValidated - ? model.hasPassedAllQuestions - ? context.t.next_challenge - : context.t.try_again - : context.t.questions_check, - style: const TextStyle(fontSize: 20), - ), - ), - ), - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(8), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: () => model.learnService.forumHelpDialog( - challenge, - block, - context, - ), - child: Text( - context.t.ask_for_help, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 50), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart deleted file mode 100644 index ccebf5e9c..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:stacked/stacked.dart'; - -class MultipleChoiceViewmodel extends BaseViewModel { - bool _isValidated = false; - bool get isValidated => _isValidated; - - bool _hasPassedAllQuestions = false; - bool get hasPassedAllQuestions => _hasPassedAllQuestions; - - String _errMessage = ''; - String get errMessage => _errMessage; - - List _assignmentsStatus = []; - List get assignmentsStatus => _assignmentsStatus; - - List _quizQuestions = []; - List get quizQuestions => _quizQuestions; - - final LearnService learnService = locator(); - - set setIsValidated(bool status) { - _isValidated = status; - notifyListeners(); - } - - set setHasPassedAllQuestions(bool status) { - _hasPassedAllQuestions = status; - notifyListeners(); - } - - set setErrMessage(String message) { - _errMessage = message; - notifyListeners(); - } - - set setQuizQuestions(List questions) { - _quizQuestions = questions; - notifyListeners(); - } - - set setAssignmentsStatus(List status) { - _assignmentsStatus = status; - notifyListeners(); - } - - void setAssignmentStatus(int index) { - setAssignmentsStatus = List.from(_assignmentsStatus) - ..[index] = !_assignmentsStatus[index]; - notifyListeners(); - } - - void initChallenge(Challenge challenge) { - setAssignmentsStatus = List.filled( - challenge.assignments?.length ?? 0, - false, - ); - - setQuizQuestions = (challenge.questions ?? []) - .map((q) => QuizWidgetQuestion( - text: q.text, answers: q.answers, solution: q.solution)) - .toList(); - } - - void setSelectedAnswer(int questionIndex, int answerIndex) { - final question = quizQuestions[questionIndex]; - question.selectedAnswer = answerIndex; - - if (isValidated) { - // Reset the validation status when user changes the selection - setIsValidated = false; - } - - if (errMessage.isNotEmpty) { - // Clear the error message when user changes the selection - setErrMessage = ''; - } - - notifyListeners(); - } - - void validateChallenge(String incorrectAnswersMessage) { - // Loop through each question and set isCorrect status - setQuizQuestions = List.from(quizQuestions) - ..asMap().forEach((i, question) { - question.isCorrect = question.selectedAnswer == question.solution - 1; - }); - - setHasPassedAllQuestions = - quizQuestions.every((question) => question.isCorrect == true); - - setIsValidated = true; - - // Show the error message if there are multiple questions. - // Otherwise, the validation message is sufficient. - if (quizQuestions.length > 1) { - setErrMessage = hasPassedAllQuestions ? '' : incorrectAnswersMessage; - } - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart deleted file mode 100644 index ed6c8b94b..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart +++ /dev/null @@ -1,180 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -class PythonProjectView extends StatelessWidget { - const PythonProjectView({ - super.key, - required this.challenge, - required this.block, - }); - - final Challenge challenge; - final Block block; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => PythonProjectViewModel(), - builder: (context, model, child) { - return Scaffold( - appBar: AppBar( - title: Text(challenge.title), - ), - body: SafeArea( - bottom: false, - child: ListView( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 48), - children: [ - ...parser.parse(challenge.description), - buildDivider(), - ...parser.parse(challenge.instructions), - buildDivider(), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - context.t.solution_link, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: TextFormField( - controller: model.linkController, - onChanged: (value) => model.setValidLink = null, - decoration: InputDecoration( - hintText: context.t.project_link_hint, - errorText: model.validLink != null && !model.validLink! - ? model.linkErrMsg - : null, - errorMaxLines: 5, - border: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.white, - width: 2, - ), - ), - enabledBorder: OutlineInputBorder( - borderSide: model.validLink != null && model.validLink! - ? const BorderSide( - color: Colors.green, - ) - : const BorderSide( - color: Colors.white, - ), - ), - focusedBorder: OutlineInputBorder( - borderSide: model.validLink != null && model.validLink! - ? const BorderSide( - color: Colors.green, - width: 2, - ) - : const BorderSide( - color: Colors.white, - width: 2, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: const Color.fromRGBO( - 0x3b, - 0x3b, - 0x4f, - 1, - ), - side: const BorderSide( - width: 2, - color: Colors.white, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: model.linkController.text.isEmpty - ? null - : () { - model.validLink != null && model.validLink! - ? model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ) - : model.checkLink( - invalidLinkMessage: - context.t.project_link_invalid, - ownWorkMessage: - context.t.project_link_own_work, - insecureUrlMessage: - context.t.project_link_insecure, - publicUrlMessage: - context.t.project_link_public_url, - ); - }, - child: Text( - model.validLink != null && model.validLink! - ? context.t.solution_link_submit - : context.t.solution_link_check, - style: const TextStyle(fontSize: 20), - ), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: const Color.fromRGBO( - 0x3b, - 0x3b, - 0x4f, - 1, - ), - side: const BorderSide( - width: 2, - color: Colors.white, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () async { - model.learnService.forumHelpDialog( - challenge, - block, - context, - ); - }, - child: Text( - context.t.ask_for_help, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart deleted file mode 100644 index 504fc3ecf..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:stacked/stacked.dart'; - -class PythonProjectViewModel extends BaseViewModel { - final LearnService learnService = locator(); - - TextEditingController linkController = TextEditingController(); - - final fCCRegex = RegExp( - r'codepen\.io\/freecodecamp|freecodecamp\.rocks|github\.com\/freecodecamp|\.freecodecamp\.org', - caseSensitive: false, - ); - final localhostRegex = RegExp(r'localhost:'); - final httpRegex = RegExp(r'http(?!s|([^s]+?localhost))'); - - bool? _validLink; - bool? get validLink => _validLink; - - String _linkErrMsg = ''; - String get linkErrMsg => _linkErrMsg; - - set setValidLink(bool? status) { - _validLink = status; - notifyListeners(); - } - - set setLinkErrMsg(String msg) { - _linkErrMsg = msg; - notifyListeners(); - } - - bool isUrl(String url) { - try { - return Uri.parse(url).isAbsolute; - } on FormatException { - return false; - } - } - - void checkLink({ - required String invalidLinkMessage, - required String ownWorkMessage, - required String insecureUrlMessage, - required String publicUrlMessage, - }) { - if (!isUrl(linkController.text)) { - setValidLink = false; - setLinkErrMsg = invalidLinkMessage; - return; - } else if (fCCRegex.hasMatch(linkController.text)) { - setValidLink = false; - setLinkErrMsg = ownWorkMessage; - return; - } else if (httpRegex.hasMatch(linkController.text)) { - setValidLink = false; - setLinkErrMsg = insecureUrlMessage; - return; - } else if (localhostRegex.hasMatch(linkController.text)) { - setValidLink = false; - setLinkErrMsg = publicUrlMessage; - return; - } else { - setValidLink = true; - } - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart deleted file mode 100644 index 266c52da1..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/python/python_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/transcript_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/youtube_player_widget.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class PythonView extends StatelessWidget { - const PythonView({ - super.key, - required this.challenge, - required this.block, - required this.currentChallengeNum, - }); - - final Challenge challenge; - final Block block; - final int currentChallengeNum; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => PythonViewModel(), - onViewModelReady: (model) => model.initChallenge(challenge), - builder: (context, model, child) { - return Scaffold( - backgroundColor: FccColors.gray90, - appBar: AppBar( - title: Text(handleChallengeTitle(challenge, block)), - ), - body: SafeArea( - bottom: false, - child: ListView( - children: [ - Container( - margin: const EdgeInsets.all(8), - child: Center( - child: Text( - challenge.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(height: 12), - if (challenge.description.isNotEmpty) - ChallengeCard( - title: context.t.challenge_card_description, - child: Align( - alignment: Alignment.centerLeft, - child: Column( - children: parser.parse( - challenge.description, - ), - ), - ), - ), - const SizedBox(height: 12), - if (challenge.videoId != null) - ChallengeCard( - title: context.t.challenge_card_video, - child: YoutubePlayerWidget( - videoId: challenge.videoId!, - ), - ), - const SizedBox(height: 12), - if (challenge.transcript.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_transcript, - child: Transcript( - transcript: challenge.transcript, - isCollapsible: challenge.videoId != null, - ), - ), - ], - if (challenge.instructions.isNotEmpty) - ChallengeCard( - title: context.t.instructions, - child: Column( - children: parser.parse( - challenge.instructions, - ), - ), - ), - QuizWidget( - isValidated: model.isValidated, - questions: model.quizQuestions, - onChanged: (questionIndex, answerIndex) { - model.setSelectedAnswer(questionIndex, answerIndex); - }), - const SizedBox(height: 16), - if (model.errMessage.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - model.errMessage, - style: const TextStyle( - fontSize: 18, - ), - ), - ), - ], - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide( - width: 2, - color: Colors.white, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: - model.quizQuestions.every((q) => q.selectedAnswer != -1) - ? () { - if (model.isValidated && - model.hasPassedAllQuestions) { - model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ); - } else { - model.validateChallenge( - context.t.some_answers_are_incorrect); - } - } - : null, - child: Text( - model.isValidated - ? model.hasPassedAllQuestions - ? context.t.next_challenge - : context.t.try_again - : context.t.questions_check, - style: const TextStyle(fontSize: 20), - ), - ), - ), - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(8), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: () => model.learnService.forumHelpDialog( - challenge, - block, - context, - ), - child: Text( - context.t.ask_for_help, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 50), - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart deleted file mode 100644 index 44472df58..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:stacked/stacked.dart'; - -class PythonViewModel extends BaseViewModel { - bool _isValidated = false; - bool get isValidated => _isValidated; - - bool _hasPassedAllQuestions = false; - bool get hasPassedAllQuestions => _hasPassedAllQuestions; - - String _errMessage = ''; - String get errMessage => _errMessage; - - List _quizQuestions = []; - List get quizQuestions => _quizQuestions; - - final LearnService learnService = locator(); - - set setQuizQuestions(List questions) { - _quizQuestions = questions; - notifyListeners(); - } - - set setIsValidated(bool status) { - _isValidated = status; - notifyListeners(); - } - - set setHasPassedAllQuestions(bool status) { - _hasPassedAllQuestions = status; - notifyListeners(); - } - - set setErrMessage(String message) { - _errMessage = message; - notifyListeners(); - } - - void initChallenge(Challenge challenge) { - setQuizQuestions = (challenge.questions ?? []) - .map((q) => QuizWidgetQuestion( - text: q.text, answers: q.answers, solution: q.solution)) - .toList(); - } - - void setSelectedAnswer(int questionIndex, int answerIndex) { - final question = quizQuestions[questionIndex]; - question.selectedAnswer = answerIndex; - - if (isValidated) { - // Reset the validation status when user changes the selection - setIsValidated = false; - } - - if (errMessage.isNotEmpty) { - // Clear the error message when user changes the selection - setErrMessage = ''; - } - - notifyListeners(); - } - - void validateChallenge(String incorrectAnswersMessage) { - // Loop through each question and set isCorrect status - setQuizQuestions = List.from(quizQuestions) - ..asMap().forEach((i, question) { - question.isCorrect = question.selectedAnswer == question.solution - 1; - }); - - setHasPassedAllQuestions = - quizQuestions.every((question) => question.isCorrect == true); - - setIsValidated = true; - - // Show the error message if there are multiple questions. - // Otherwise, the validation message is sufficient. - if (quizQuestions.length > 1) { - setErrMessage = hasPassedAllQuestions ? '' : incorrectAnswersMessage; - } - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart deleted file mode 100644 index e269520de..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class QuizView extends StatelessWidget { - const QuizView({ - super.key, - required this.challenge, - required this.block, - }); - - final Challenge challenge; - final Block block; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => QuizViewModel(), - onViewModelReady: (model) => model.initChallenge(challenge), - builder: (context, model, child) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) { - return; - } - - // Show confirmation dialog - final shouldPop = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(context.t.quiz_leave_title), - content: Text(context.t.quiz_leave_message), - actions: [ - TextButton( - style: TextButton.styleFrom( - backgroundColor: FccColors.gray80, - ), - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.t.cancel), - ), - TextButton( - style: TextButton.styleFrom( - backgroundColor: FccColors.red15, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text( - context.t.leave, - style: const TextStyle(color: FccColors.red90), - ), - ), - ], - ), - ); - - // If user confirmed leaving, allow navigation - if (shouldPop == true) { - Navigator.of(context).pop(); - } - }, - child: Scaffold( - backgroundColor: FccColors.gray90, - persistentFooterAlignment: AlignmentDirectional.topStart, - appBar: AppBar( - backgroundColor: FccColors.gray90, - ), - body: SafeArea( - bottom: false, // Don't pad bottom as we have a sticky footer - child: ListView( - // Add bottom padding to ensure content isn't hidden behind sticky footer - padding: const EdgeInsets.only(bottom: 80), - children: [ - ChallengeCard( - title: challenge.title, - child: Column( - children: [ - ...parser.parse( - challenge.description, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': - Style(color: FccColors.gray05), - }, - ), - ...parser.parse( - challenge.instructions, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': - Style(color: FccColors.gray05), - }, - ), - ], - ), - ), - QuizWidget( - isValidated: model.isValidated, - questions: model.quizQuestions, - onChanged: (questionIndex, answerIndex) { - model.setSelectedAnswer(questionIndex, answerIndex); - }), - const SizedBox(height: 16), - ], - ), - ), - // Add sticky footer - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: FccColors.gray80, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, -2), - ), - ], - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Error message above the button - if (model.errMessage.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: Text( - model.errMessage, - style: const TextStyle( - fontSize: 18, - ), - ), - ), - ], - // Action button - ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: () { - if (model.isValidated && - model.hasPassedAllQuestions) { - model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ); - } else if (model.isValidated && - !model.hasPassedAllQuestions) { - model.resetQuiz(); - } else { - model.validateChallenge(context.t); - } - }, - child: Text( - model.isValidated - ? model.hasPassedAllQuestions - ? context.t.next_challenge - : context.t.try_again - : context.t.questions_check, - style: const TextStyle(fontSize: 20), - ), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart deleted file mode 100644 index 77787775c..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; -import 'package:stacked/stacked.dart'; - -class QuizViewModel extends BaseViewModel { - bool _isValidated = false; - bool get isValidated => _isValidated; - - bool _hasPassedAllQuestions = false; - bool get hasPassedAllQuestions => _hasPassedAllQuestions; - - List get unansweredQuestions => _quizQuestions - .asMap() - .entries - .where((entry) => entry.value.selectedAnswer == -1) - .map((entry) => entry.key + 1) // Convert to 1-based indexing for display - .toList(); - - String _errMessage = ''; - String get errMessage => _errMessage; - - List _quizQuestions = []; - List get quizQuestions => _quizQuestions; - - final LearnService learnService = locator(); - - set setQuizQuestions(List questions) { - _quizQuestions = questions; - notifyListeners(); - } - - set setIsValidated(bool status) { - _isValidated = status; - notifyListeners(); - } - - set setHasPassedAllQuestions(bool status) { - _hasPassedAllQuestions = status; - notifyListeners(); - } - - set setErrMessage(String message) { - _errMessage = message; - notifyListeners(); - } - - void initChallenge(Challenge challenge) { - // Randomly select a question set from challenge.quizzes - final questionSet = - (challenge.quizzes != null && challenge.quizzes!.isNotEmpty) - ? (challenge.quizzes!..shuffle()).first - : null; - final questions = questionSet?.questions ?? []; - - setQuizQuestions = questions - .map((q) => QuizWidgetQuestion( - text: q.text, - answers: q.answers, - solution: q.solution, - audioData: q.audioData, - )) - .toList(); - } - - void setSelectedAnswer(int questionIndex, int answerIndex) { - final question = quizQuestions[questionIndex]; - question.selectedAnswer = answerIndex; - - if (isValidated) { - // Reset the validation status when user changes the selection - setIsValidated = false; - } - - if (errMessage.isNotEmpty) { - // Clear the error message when user changes the selection - setErrMessage = ''; - } - - notifyListeners(); - } - - void validateChallenge(AppLocalizations t) { - if (unansweredQuestions.length > 1) { - setErrMessage = - t.quiz_unanswered_questions(unansweredQuestions.join(', ')); - return; - } - - // Loop through each question and set isCorrect status - setQuizQuestions = List.from(quizQuestions) - ..asMap().forEach((i, question) { - question.isCorrect = question.selectedAnswer == question.solution - 1; - }); - - final correctQuestionsCount = - quizQuestions.where((q) => q.isCorrect == true).length; - final totalQuestions = quizQuestions.length; - final minCorrectToPass = (totalQuestions * 0.9).ceil(); - - setHasPassedAllQuestions = unansweredQuestions.isEmpty && - correctQuestionsCount >= minCorrectToPass; - - setIsValidated = true; - - setErrMessage = hasPassedAllQuestions - ? '✅ ${t.quiz_passed(correctQuestionsCount, totalQuestions)}' - : '❌ ${t.quiz_failed(correctQuestionsCount, totalQuestions)}'; - } - - void resetQuiz() { - setQuizQuestions = quizQuestions - .map((q) => QuizWidgetQuestion( - text: q.text, - answers: q.answers, - solution: q.solution, - audioData: q.audioData, - )) - .toList(); - - setIsValidated = false; - setErrMessage = ''; - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart deleted file mode 100644 index c33d948f3..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/review/review_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/assignment_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/example_editor.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/scene/scene_view.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/transcript_widget.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/youtube_player_widget.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class ReviewView extends StatelessWidget { - const ReviewView({ - super.key, - required this.challenge, - required this.block, - }); - - final Challenge challenge; - final Block block; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => ReviewViewmodel(), - onViewModelReady: (model) => model.initChallenge(challenge, context), - builder: (context, model, child) { - return Scaffold( - backgroundColor: FccColors.gray90, - persistentFooterAlignment: AlignmentDirectional.topStart, - appBar: AppBar( - backgroundColor: FccColors.gray90, - title: Text(handleChallengeTitle(challenge, block))), - body: SafeArea( - child: ListView( - children: [ - ChallengeCard( - title: challenge.title, - child: Column( - children: [ - ...model.parsedDescription, - ...model.parsedInstructions, - ], - ), - ), - if (challenge.nodules?.isNotEmpty ?? false) - ExampleEditor( - nodules: challenge.nodules!, - parser: parser, - ), - if (challenge.videoId != null) ...[ - const SizedBox(height: 12), - ChallengeCard( - title: context.t.challenge_card_video, - child: YoutubePlayerWidget( - videoId: challenge.videoId!, - ), - ), - const SizedBox(height: 12), - ], - if (challenge.scene != null) ...[ - ChallengeCard( - title: context.t.challenge_card_scene, - child: SceneView( - scene: challenge.scene!, - ), - ), - ] else if (challenge.audio != null) ...[ - ChallengeCard( - title: context.t.challenge_card_listen_audio, - child: AudioPlayerView( - audio: challenge.audio!, - ), - ), - ], - if (challenge.transcript.isNotEmpty) ...[ - ChallengeCard( - title: context.t.challenge_card_transcript, - child: Transcript( - transcript: challenge.transcript, - isCollapsible: challenge.videoId != null, - ), - ), - ], - ChallengeCard( - title: context.t.challenge_card_assignments, - child: Column( - children: [ - for (final (i, assignment) - in challenge.assignments!.indexed) - Assignment( - label: assignment, - value: model.assignmentsStatus[i], - onTap: () { - model.setAssignmentStatus(i); - }, - onChanged: (value) { - model.setAssignmentStatus(i); - }), - ], - ), - ), - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(8), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 50), - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - side: BorderSide( - width: 2, - color: Colors.white, - ), - ), - ), - onPressed: model.assignmentsStatus - .every((element) => element) - ? () => model.learnService.goToNextChallenge( - block.challenges.length, - challenge, - block, - ) - : null, - child: Text( - context.t.next, - style: const TextStyle(fontSize: 20), - ), - ), - ), - ), - ], - ) - ], - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/review/review_viewmodel.dart deleted file mode 100644 index d92e90466..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_viewmodel.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class ReviewViewmodel extends BaseViewModel { - List _assignmentsStatus = []; - List get assignmentsStatus => _assignmentsStatus; - - final LearnService learnService = locator(); - - List _parsedInstructions = []; - List get parsedInstructions => _parsedInstructions; - - List _parsedDescription = []; - List get parsedDescription => _parsedDescription; - - set setParsedInstructions(List widgets) { - _parsedInstructions = widgets; - notifyListeners(); - } - - set setParsedDescription(List widgets) { - _parsedDescription = widgets; - notifyListeners(); - } - - set setAssignmentsStatus(List status) { - _assignmentsStatus = status; - notifyListeners(); - } - - void setAssignmentStatus(int index) { - setAssignmentsStatus = List.from(_assignmentsStatus) - ..[index] = !_assignmentsStatus[index]; - notifyListeners(); - } - - void initChallenge(Challenge challenge, BuildContext context) { - final parser = HTMLParser(context: context); - - setAssignmentsStatus = List.filled( - challenge.assignments?.length ?? 0, - false, - ); - - setParsedInstructions = parser.parse( - challenge.instructions, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - }, - ); - - setParsedDescription = parser.parse( - challenge.description, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart deleted file mode 100644 index b998a6cd6..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/english/english_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/python-project/python_project_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/python/python_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/quiz/quiz_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/review/review_view.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/templates/template_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class ChallengeTemplateView extends StatelessWidget { - const ChallengeTemplateView({ - super.key, - required this.block, - required this.challengeId, - this.challengeDate, - }); - - final Block block; - final String challengeId; - - // Used for daily challenges - final DateTime? challengeDate; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - onViewModelReady: (model) => - model.initiate(block, challengeId, challengeDate), - viewModelBuilder: () => ChallengeTemplateViewModel(), - builder: (context, model, child) => Scaffold( - resizeToAvoidBottomInset: false, - body: FutureBuilder( - future: model.challenge, - builder: (context, snapshot) { - if (snapshot.hasData) { - Challenge challenge = snapshot.data!; - - int challengeType = challenge.challengeType; - List tiles = block.challengeTiles; - int challNum = - tiles.indexWhere((el) => el.id == challenge.id) + 1; - switch (challengeType) { - case 0: - case 1: - case 14: - case 20: - case 23: - case 25: - case 26: - case 27: - case 28: - case 29: - return ChallengeView( - challenge: challenge, - block: block, - isProject: tiles.length > 1, - challengeDate: challengeDate, - ); - case 8: - return QuizView( - challenge: challenge, - block: block, - ); - case 10: - return PythonProjectView( - challenge: challenge, - block: block, - ); - case 11: - return PythonView( - challenge: challenge, - block: block, - currentChallengeNum: challNum, - ); - case 15: - case 19: - return MultipleChoiceView( - challenge: challenge, - block: block, - currentChallengeNum: challNum, - ); - case 21: - case 22: - return EnglishView( - challenge: challenge, - block: block, - currentChallengeNum: challNum, - ); - case 24: - case 31: - return ReviewView( - challenge: challenge, - block: block, - ); - default: - return Center( - child: Text( - context.t.unknown_challenge( - challenge.challengeType.toString(), - ), - ), - ); - } - } - - if (snapshot.hasError) { - return Center( - child: Text( - context.t.challenge_error( - snapshot.error.toString(), - snapshot.stackTrace.toString(), - ), - ), - ); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/template_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/template_viewmodel.dart deleted file mode 100644 index 08831561d..000000000 --- a/mobile-app/lib/ui/views/learn/challenge/templates/template_viewmodel.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:stacked/stacked.dart'; - -class ChallengeTemplateViewModel extends BaseViewModel { - Future? _challenge; - Future? get challenge => _challenge; - - final LearnOfflineService _learnOfflineService = - locator(); - final DailyChallengeService _dailyChallengeService = - locator(); - final LearnService _learnService = locator(); - - set setChallenge(Future challenge) { - _challenge = challenge; - notifyListeners(); - } - - void initiate(Block block, String challengeId, DateTime? challengeDate) { - if (challengeDate == null) { - final String base = LearnService.baseUrl; - - String url = - '$base/challenges/${block.superBlock.dashedName}/${block.dashedName}/$challengeId.json'; - - setChallenge = _learnOfflineService.getChallenge(url); - - _learnService.setLastVisitedChallenge(url, block); - } else { - final formattedChallengeDate = formatChallengeDate(challengeDate); - - setChallenge = _dailyChallengeService.getDailyChallenge( - formattedChallengeDate, block); - } - } -} diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart deleted file mode 100644 index f003d766d..000000000 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_block_view.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:focus_detector/focus_detector.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_view.dart'; -import 'package:freecodecamp/ui/views/learn/chapter/chapter_block_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class ChapterBlockView extends StatelessWidget { - const ChapterBlockView({ - super.key, - required this.moduleName, - required this.blocks, - }); - - final String moduleName; - final List blocks; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => ChapterBlockViewmodel(), - builder: (context, model, child) { - return Scaffold( - appBar: AppBar( - title: Text(moduleName), - ), - body: FocusDetector( - onFocusGained: () { - model.updateUserProgress(); - }, - child: ListView.builder( - itemCount: blocks.length, - padding: const EdgeInsets.all(8), - itemBuilder: (context, index) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (model.blockOpenStates.isEmpty) { - Map openStates = { - for (var block in blocks) block.dashedName: false - }; - - // Set first block open - String firstBlockKey = openStates.entries.toList()[0].key; - - openStates[firstBlockKey] = true; - - model.blockOpenStates = openStates; - } - }); - - return BlockTemplateView( - block: blocks[index], - isOpen: - model.blockOpenStates[blocks[index].dashedName] ?? false, - isOpenFunction: () => model.setBlockOpenClosedState( - blocks, - index, - ), - ); - }, - ), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart deleted file mode 100644 index c73acc945..000000000 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_block_viewmodel.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:stacked/stacked.dart'; - -class ChapterBlockViewmodel extends BaseViewModel { - Map _blockOpenStates = {}; - Map get blockOpenStates => _blockOpenStates; - final authenticationService = locator(); - - set blockOpenStates(Map openStates) { - _blockOpenStates = openStates; - notifyListeners(); - } - - setBlockOpenClosedState(List blocks, int block) { - Map local = blockOpenStates; - Block curr = blocks[block]; - - if (local[curr.dashedName] != null) { - local[curr.dashedName] = !local[curr.dashedName]!; - } - - blockOpenStates = local; - } - - void updateUserProgress() { - if (AuthenticationService.staticIsloggedIn) { - authenticationService.fetchUser(); - } - } -} diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart deleted file mode 100644 index 5b2c8696e..000000000 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/chapter/chapter_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class ChapterView extends StatelessWidget { - const ChapterView({ - super.key, - required this.superBlockDashedName, - required this.superBlockName, - }); - - final String superBlockDashedName; - final String superBlockName; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => ChapterViewModel(), - onViewModelReady: (model) => model.init( - superBlockDashedName, - superBlockName, - ), - builder: (context, model, child) { - return Scaffold( - backgroundColor: FccColors.gray90, - appBar: AppBar( - title: Text(superBlockName), - ), - body: StreamBuilder( - stream: model.auth.progress.stream, - builder: (context, snapshot) { - return FutureBuilder( - future: model.superBlockFuture, - builder: (context, snapshot) { - if (snapshot.hasError) { - return Center( - child: Text( - context.t.error_loading_chapters( - snapshot.error.toString(), - snapshot.stackTrace.toString(), - ), - ), - ); - } - - if (snapshot.hasData) { - SuperBlock superBlock = snapshot.data as SuperBlock; - - List exams = [ - 'responsive-web-design-certification-exam', - 'javascript-certification-exam', - 'python-certification-exam' - ]; - - List chapters = (superBlock.chapters - as List) - .where((chapter) => !exams.contains(chapter.dashedName)) - .toList(); - - if (chapters.isEmpty) { - return Center( - child: Text(context.t.no_blocks_available), - ); - } - - return ListView( - shrinkWrap: true, - children: [ - Column( - children: [ - ...[ - for (Chapter chapter in chapters) - chapterBlock( - superBlock, chapter, model, context) - ] - ], - ), - ], - ); - } - - return Center( - child: CircularProgressIndicator(), - ); - }, - ); - }, - ), - ); - }, - ); - } - - Widget chapterBlock( - SuperBlock superBlock, - Chapter chapter, - ChapterViewModel model, - BuildContext context, - ) { - bool disabled = chapter.dashedName == 'frontend-libraries' && !model.isDev; - - return Container( - padding: EdgeInsets.all(8), - margin: EdgeInsets.symmetric(vertical: 8, horizontal: 8), - decoration: BoxDecoration( - color: const Color.fromRGBO(0x1b, 0x1b, 0x32, 1), - borderRadius: BorderRadius.all( - Radius.circular(10), - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - chapter.name, - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - ), - ), - ), - if (chapter.comingSoon != null && chapter.comingSoon == true || - disabled) - Container( - margin: const EdgeInsets.all(8), - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - border: Border.all( - color: Colors.white, - width: 2, - ), - borderRadius: BorderRadius.circular(5)), - child: Text(context.t.coming_soon), - ), - for (Module module in chapter.modules as List) - chapterButton(context, module, model, chapter) - ], - ), - ), - ], - ), - ); - } - - Container chapterButton( - BuildContext context, - Module module, - ChapterViewModel model, - Chapter chapter, - ) { - bool disabled = chapter.dashedName == 'frontend-libraries' && !model.isDev; - if (module.comingSoon != null && module.comingSoon == true || disabled) { - return Container(); - } - - bool isSingleStep = model.isSingleStepModule(module); - Color moduleColor = model.getModuleColor(module); - bool isReview = module.blocks != null && - module.blocks!.isNotEmpty && - module.blocks!.first.label == BlockLabel.review; - bool isCertProject = module.moduleType == ModuleType.certProject; - Color backgroundColor = isSingleStep - ? moduleColor.withValues(alpha: 0.3) - : moduleColor.withValues(alpha: 0.1); - Color borderColor = isSingleStep - ? moduleColor.withValues(alpha: 0.5) - : moduleColor.withValues(alpha: 0.4); - Color textColor = isSingleStep ? moduleColor : Colors.white; - - return Container( - margin: const EdgeInsets.all(5), - constraints: BoxConstraints(minHeight: 100, maxHeight: 200), - width: MediaQuery.of(context).size.width * 0.90, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(10.0), - border: Border.all( - color: borderColor, - width: 2, - ), - ), - child: TextButton( - style: ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.all(12), - ), - alignment: Alignment.centerLeft, - shape: WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10.0), - ), - ), - backgroundColor: WidgetStatePropertyAll(Colors.transparent), - overlayColor: WidgetStatePropertyAll( - Colors.white.withValues(alpha: 0.1), - ), - ), - onPressed: () { - if (isSingleStep && module.blocks!.isNotEmpty) { - Block firstBlock = module.blocks!.first; - if (firstBlock.challenges.isNotEmpty) { - model.routeToChallengeView( - firstBlock, - firstBlock.challenges.first.id, - ); - } - } else { - model.routeToBlockView(module.blocks!, module.name); - } - }, - child: Row( - children: [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - module.name, - style: TextStyle( - fontSize: 21, - fontWeight: FontWeight.bold, - color: textColor, - ), - ), - if (isCertProject) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - context.t.certification_project, - style: TextStyle( - color: moduleColor.withValues(alpha: 0.8), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - if (isReview) - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text( - context.t.review, - style: TextStyle( - color: moduleColor.withValues(alpha: 0.8), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ), - if (!isSingleStep && !isReview) - FutureBuilder( - future: model.calculateProgress(module), - builder: (context, snapshot) { - String steps = snapshot.data ?? '0'; - - return Text( - context.t.steps_complete(steps), - style: TextStyle( - color: FccColors.gray15, - fontSize: 18, - ), - ); - }, - ), - ], - ), - ), - SizedBox(width: 8), - Icon( - isSingleStep - ? Icons.play_arrow_rounded - : Icons.arrow_forward_ios_outlined, - size: 25, - color: textColor, - ) - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart deleted file mode 100644 index de1e93d2b..000000000 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_viewmodel.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class ChapterViewModel extends BaseViewModel { - final _dio = DioService.dio; - final NavigationService _navigationService = locator(); - final AuthenticationService auth = locator(); - final RemoteConfigService _remoteConfigService = - locator(); - - bool _isDev = false; - bool get isDev => _isDev; - - Future? superBlockFuture; - - set setIsDev(bool value) { - _isDev = value; - notifyListeners(); - } - - void init(String superBlockDashedName, superBlockName) async { - superBlockFuture = requestChapters(superBlockDashedName, superBlockName); - developmentMode(); - } - - Future calculateProgress(Module module) async { - int steps = 0; - num completedCount = 0; - - for (int i = 0; i < module.blocks!.length; i++) { - steps += module.blocks![i].challenges.length; - - for (int j = 0; j < module.blocks![i].challenges.length; j++) { - completedCount += - await completedChallenge(module.blocks![i].challenges[j].id) - ? 1 - : 0; - } - } - - return '$completedCount/$steps'; - } - - Future completedChallenge(String incomingId) async { - FccUserModel? user = await auth.userModel; - - if (user != null) { - for (CompletedChallenge challenge in user.completedChallenges) { - if (challenge.id == incomingId) { - return true; - } - } - } - - return false; - } - - Future requestChapters( - String superBlockDashedName, String superBlockName) async { - String baseUrl = LearnService.baseUrl; - - final Response res = await _dio.get( - '$baseUrl/$superBlockDashedName.json', - ); - if (res.statusCode == 200) { - final superBlock = SuperBlock.fromJson( - res.data, - superBlockDashedName, - superBlockName, - ); - - return _filterUnavailableBlocks(superBlock); - } - - return null; - } - - SuperBlock _filterUnavailableBlocks(SuperBlock superBlock) { - if (superBlock.chapters == null) { - return superBlock; - } - - final List filteredChapters = superBlock.chapters! - .map((chapter) { - final List filteredModules = chapter.modules - ?.map((module) { - final List filteredBlocks = (module.blocks ?? []) - .where((block) => _remoteConfigService.isActive( - superBlockDashedName: block.superBlock.dashedName, - blockDashedName: block.dashedName, - fallbackValue: true, - )) - .toList(); - - return Module( - name: module.name, - dashedName: module.dashedName, - comingSoon: module.comingSoon, - moduleType: module.moduleType, - blocks: filteredBlocks, - ); - }) - .where((module) => module.blocks?.isNotEmpty ?? false) - .toList() ?? - []; - - return Chapter( - name: chapter.name, - dashedName: chapter.dashedName, - comingSoon: chapter.comingSoon, - chapterType: chapter.chapterType, - modules: filteredModules, - ); - }) - .where((chapter) => chapter.modules?.isNotEmpty ?? false) - .toList(); - - return SuperBlock( - dashedName: superBlock.dashedName, - name: superBlock.name, - chapters: filteredChapters, - ); - } - - void developmentMode() async { - await dotenv.load(); - setIsDev = dotenv.env['DEVELOPMENTMODE'] == 'TRUE'; - } - - void routeToBlockView( - List blocks, - String moduleName, - ) { - _navigationService.navigateTo( - Routes.chapterBlockView, - arguments: ChapterBlockViewArguments( - blocks: blocks, - moduleName: moduleName, - ), - ); - } - - void routeToChallengeView(Block block, String challengeId) { - _navigationService.navigateTo( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - challengeId: challengeId, - block: block, - ), - ); - } - - bool isSingleStepModule(Module module) { - if (module.blocks == null || module.blocks!.isEmpty) { - return false; - } - - int totalChallenges = 0; - for (Block block in module.blocks!) { - totalChallenges += block.challenges.length; - } - - return totalChallenges == 1; - } - - Color getModuleColor(Module module) { - if (module.blocks == null || module.blocks!.isEmpty) { - return FccColors.blue30; - } - - Block firstBlock = module.blocks!.first; - - if (firstBlock.label == BlockLabel.review) { - return FccColors.orange30; - } - - if (module.moduleType == ModuleType.certProject) { - return FccColors.green40; - } - - return FccColors.blue30; - } -} diff --git a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart deleted file mode 100644 index 86dffb71e..000000000 --- a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart +++ /dev/null @@ -1,276 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -class DailyChallengeView extends StatelessWidget { - const DailyChallengeView({super.key}); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => DailyChallengeViewModel(), - onViewModelReady: (viewModel) => viewModel.init(), - builder: (context, model, child) { - return Scaffold( - appBar: AppBar( - leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(context).openDrawer(), - ), - ), - title: Text(context.t.daily_coding_challenges), - ), - drawer: const DrawerWidgetView(), - body: model.isBusy - ? const Center(child: CircularProgressIndicator()) - : model.blocks.isNotEmpty - ? Padding( - padding: const EdgeInsets.all(8), - child: ListView.builder( - itemCount: model.blocks.length, - itemBuilder: (context, index) { - final block = model.blocks[index]; - final isOpen = - model.blockOpenStates[block.monthYear] ?? false; - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: buildBlock(context, block, isOpen, model), - ); - }, - ), - ) - : Center( - child: Text( - context.t.daily_challenge_empty, - style: const TextStyle(fontSize: 16), - textAlign: TextAlign.center, - ), - ), - ); - }, - ); - } - - Widget buildBlock(BuildContext context, DailyChallengeBlock block, - bool isOpen, DailyChallengeViewModel model) { - HTMLParser parser = HTMLParser(context: context); - return FutureBuilder( - future: model.getCompletedChallengesForBlock(block), - builder: (context, snapshot) { - final completedCount = snapshot.data ?? 0; - final totalCount = block.challenges.length; - final isFullyCompleted = completedCount == totalCount; - double progress = - completedCount == 0 ? 0.01 : completedCount / totalCount; - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: FccColors.gray75, - ), - color: FccColors.gray85, - ), - padding: const EdgeInsets.all(8), - width: MediaQuery.of(context).size.width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - Icons.calendar_today, - color: FccColors.gray00, - size: 30, - ), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - block.displayMonth, - style: TextStyle( - wordSpacing: 0, - letterSpacing: 0, - fontSize: 22, - fontWeight: FontWeight.bold, - color: FccColors.gray00, - ), - ), - ), - ), - ], - ), - Row( - children: [ - isFullyCompleted - ? Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.check_circle, - color: FccColors.blue30, - ), - ), - Text( - context.t.completed, - style: TextStyle( - color: FccColors.blue30, - fontWeight: FontWeight.bold, - ), - ) - ], - ), - ) - : Container( - padding: const EdgeInsets.all(10), - width: MediaQuery.of(context).size.width * 0.9, - child: LinearProgressIndicator( - minHeight: 12, - value: progress, - valueColor: - AlwaysStoppedAnimation(FccColors.blue30), - backgroundColor: FccColors.gray80, - borderRadius: BorderRadius.circular(10), - ), - ), - ], - ), - ...parser.parse( - '

    ${block.description}

    ', - isSelectable: false, - customStyles: { - '*:not(h1):not(h2):not(h3):not(h4):not(h5):not(h6)': Style( - color: FccColors.gray05, - ), - 'p': Style(margin: Margins.zero), - }, - ), - Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextButton( - onPressed: () => model.toggleBlock(block.monthYear), - style: TextButton.styleFrom( - backgroundColor: FccColors.gray85, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: BorderSide( - color: FccColors.gray75, - ), - ), - ), - child: Text( - isOpen - ? context.t.hide_challenges - : context.t.show_challenges, - ), - ), - ), - ], - ), - if (isOpen) buildChallengeList(context, block, model), - ], - ), - ); - }, - ); - } - - Widget buildChallengeList(BuildContext context, DailyChallengeBlock block, - DailyChallengeViewModel model) { - return Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - width: MediaQuery.of(context).size.width - 34, - child: ListView.builder( - shrinkWrap: true, - padding: const EdgeInsets.all(0), - physics: const ClampingScrollPhysics(), - itemCount: block.challenges.length, - itemBuilder: (context, challengeIndex) { - final challenge = block.challenges[challengeIndex]; - return FutureBuilder( - future: model.checkIfChallengeCompleted(challenge.id), - builder: (context, snapshot) { - bool isCompleted = snapshot.data ?? false; - return InkWell( - onTap: () { - model.navigateToDailyChallenge(challenge); - }, - child: Semantics( - label: isCompleted - ? context.t.daily_challenge_item_completed_label( - challenge.challengeNumber, - challenge.title, - ) - : context.t.daily_challenge_item_not_completed_label( - challenge.challengeNumber, - challenge.title, - ), - button: true, - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: isCompleted - ? const BorderSide( - width: 1, - color: Color.fromRGBO(0xbc, 0xe8, 0xf1, 1), - ) - : const BorderSide( - color: Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - )), - color: isCompleted - ? Color.fromRGBO(0x00, 0x2e, 0xad, 0.3) - : const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - if (isCompleted) - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.check_circle, - color: Color.fromRGBO(0xbc, 0xe8, 0xf1, 1), - size: 20, - ), - ), - Expanded( - child: Text( - context.t.daily_challenge_item_title( - challenge.challengeNumber, - challenge.title, - ), - style: TextStyle(fontSize: 18), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart deleted file mode 100644 index 4219a10ab..000000000 --- a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class DailyChallengeViewModel extends BaseViewModel { - final NavigationService _navigationService = locator(); - final AuthenticationService _auth = locator(); - final DailyChallengeService _dailyChallengeService = - locator(); - final LocaleService _localeService = locator(); - - List _blocks = []; - List get blocks => _blocks; - - Map _blockOpenStates = {}; - Map get blockOpenStates => _blockOpenStates; - - Future init() async { - await _fetchAndGroupChallenges(); - _initAuthenticationListener(); - } - - void _initAuthenticationListener() { - // Listen to authentication state changes to refresh completion status - _auth.isLoggedIn.listen((isLoggedIn) async { - notifyListeners(); - }); - - // Also listen to progress stream for user data updates - _auth.progress.stream.listen((_) { - notifyListeners(); - }); - } - - void toggleBlock(String monthYear) { - _blockOpenStates[monthYear] = !(_blockOpenStates[monthYear] ?? false); - notifyListeners(); - } - - Future _fetchAndGroupChallenges() async { - setBusy(true); - - try { - final List challenges = - await _dailyChallengeService.fetchAllDailyChallenges(); - - if (challenges.isNotEmpty) { - Map> challengesByMonth = {}; - - // Group challenges by month - for (var challenge in challenges) { - String monthYear = formatMonthFromDate(challenge.date); - if (!challengesByMonth.containsKey(monthYear)) { - challengesByMonth[monthYear] = []; - } - challengesByMonth[monthYear]!.add(challenge); - } - - // Sort months in descending order (newest first) - List sortedMonths = challengesByMonth.keys.toList() - ..sort((a, b) { - DateTime dateA = parseMonthYear(a); - DateTime dateB = parseMonthYear(b); - return dateB.compareTo(dateA); - }); - - _blocks = []; - _blockOpenStates = {}; - final t = lookupAppLocalizations(_localeService.locale); - - // Create challenge groups for each month - for (String monthYear in sortedMonths) { - List monthChallenges = - challengesByMonth[monthYear]!; - - // Sort challenges within the month by challengeNumber in descending order (greater first) - monthChallenges.sort((a, b) { - return b.challengeNumber.compareTo(a.challengeNumber); - }); - - final block = DailyChallengeBlock( - monthYear: monthYear, - challenges: monthChallenges, - description: t.daily_challenge_month_description( - monthYear.split(' ').first), - ); - - _blocks.add(block); - _blockOpenStates[monthYear] = false; - } - - // Open the first (most recent) group by default - if (_blocks.isNotEmpty) { - _blockOpenStates[_blocks.first.monthYear] = true; - } - } - } catch (e) { - print('Exception occurred: $e'); - } finally { - setBusy(false); - } - } - - Future checkIfChallengeCompleted(String challengeId) async { - FccUserModel? user = await _auth.userModel; - if (user != null) { - for (CompletedDailyChallenge challenge - in user.completedDailyCodingChallenges) { - if (challenge.id == challengeId) { - return true; - } - } - } - return false; - } - - Future navigateToDailyChallenge( - DailyChallengeOverview challenge) async { - String monthYear = formatMonthFromDate(challenge.date); - final t = lookupAppLocalizations(_localeService.locale); - - // Map to curriculum block so ChallengeTemplateView can use it - Block block = DailyChallengeBlock( - monthYear: monthYear, - challenges: [challenge], - description: t.daily_challenge_month_description( - monthYear.split(' ').first), - ).toCurriculumBlock(); - - _navigationService.navigateTo( - Routes.challengeTemplateView, - arguments: ChallengeTemplateViewArguments( - challengeId: challenge.id, - block: block, - challengeDate: challenge.date, - ), - ); - } - - Future getCompletedChallengesForBlock(DailyChallengeBlock block) async { - int count = 0; - - for (var challenge in block.challenges) { - if (await checkIfChallengeCompleted(challenge.id)) { - count++; - } - } - - return count; - } -} diff --git a/mobile-app/lib/ui/views/learn/landing/landing_view.dart b/mobile-app/lib/ui/views/learn/landing/landing_view.dart deleted file mode 100644 index 07812f391..000000000 --- a/mobile-app/lib/ui/views/learn/landing/landing_view.dart +++ /dev/null @@ -1,455 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/motivational_quote_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/landing/landing_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/daily_challenge_card.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class LearnLandingView extends StatelessWidget { - const LearnLandingView({super.key}); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => LearnLandingViewModel(), - onViewModelReady: (model) => model.init(context), - builder: (context, model, child) => Scaffold( - backgroundColor: FccColors.gray90, - appBar: AppBar( - title: Text(context.t.learn), - ), - drawer: const DrawerWidgetView( - key: Key('drawer'), - ), - body: RefreshIndicator( - backgroundColor: const Color(0xFF0a0a23), - color: Colors.white, - onRefresh: () { - model.refresh(); - - return Future.delayed(Duration.zero); - }, - child: SingleChildScrollView( - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: FccColors.gray90, - ), - child: Column( - children: [ - const SizedBox(height: 32), - StreamBuilder( - stream: model.auth.isLoggedIn, - builder: (context, snapshot) { - return Column( - children: [ - if (model.isLoggedIn) welcomeMessage(model), - ], - ); - }, - ), - const QuoteWidget(), - if (!model.isLoggedIn) loginButton(model, context), - const SizedBox(height: 16), - DailyChallengeCard( - dailyChallenge: model.dailyChallenge, - isCompleted: model.isDailyChallengeCompleted, - ), - const SizedBox(height: 16), - if (model.isBusy) - const Center( - child: CircularProgressIndicator(), - ) - else - FutureBuilder>( - future: model.superBlockButtons, - builder: (context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data!.isEmpty) { - return errorMessage(context); - } - - if (snapshot.data is List) { - List widgets = snapshot.data!; - - return ListView.builder( - physics: const ClampingScrollPhysics(), - shrinkWrap: true, - itemCount: widgets.length, - itemBuilder: (BuildContext context, int i) { - return widgets[i]; - }, - ); - } - } - - return errorMessage(context); - }, - ), - ], - ), - ), - ), - ), - ), - ); - } - - Padding errorMessage(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric( - vertical: MediaQuery.of(context).size.width * 0.5, - horizontal: 8, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - context.t.error_three, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - height: 1.2, - ), - textAlign: TextAlign.center, - ), - ], - ), - ); - } - - Widget loginButton(LearnLandingViewModel model, BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromRGBO(0xf1, 0xbe, 0x32, 1), - minimumSize: const Size.fromHeight(50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () { - model.auth.routeToLogin(true); - }, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_save_progress, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.black, fontSize: 20), - ), - ), - ), - ), - ], - ), - ); - } - - Widget welcomeMessage(LearnLandingViewModel model) { - return FutureBuilder( - future: model.auth.userModel, - builder: ((context, snapshot) { - if (snapshot.hasData) { - FccUserModel user = snapshot.data as FccUserModel; - - return Text( - context.t.login_welcome_back( - user.username.startsWith('fcc') ? context.t.user : user.username, - ), - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 20, - ), - ); - } - - return const Center(child: CircularProgressIndicator()); - }), - ); - } -} - -class SuperBlockButton extends StatelessWidget { - SuperBlockButton({ - super.key, - required this.button, - required this.model, - }); - - final LearnLandingViewModel model; - final SuperBlockButtonData button; - - static const String learnAssetsPath = 'assets/learn'; - - final iconMap = { - SuperBlocks.respWebDesignV9: - '${SuperBlockButton.learnAssetsPath}/responsive-design.svg', - SuperBlocks.javascriptV9: - '${SuperBlockButton.learnAssetsPath}/javascript.svg', - SuperBlocks.frontEndDevLibsV9: - '${SuperBlockButton.learnAssetsPath}/react.svg', - SuperBlocks.pythonV9: '${SuperBlockButton.learnAssetsPath}/python.svg', - SuperBlocks.relationalDbV9: - '${SuperBlockButton.learnAssetsPath}/database.svg', - SuperBlocks.backEndDevApisV9: '${SuperBlockButton.learnAssetsPath}/api.svg', - SuperBlocks.respWebDesignNew: - '${SuperBlockButton.learnAssetsPath}/responsive-design.svg', - SuperBlocks.respWebDesign: - '${SuperBlockButton.learnAssetsPath}/responsive-design.svg', - SuperBlocks.jsAlgoDataStruct: - '${SuperBlockButton.learnAssetsPath}/javascript.svg', - SuperBlocks.jsAlgoDataStructNew: - '${SuperBlockButton.learnAssetsPath}/javascript.svg', - SuperBlocks.frontEndDevLibs: - '${SuperBlockButton.learnAssetsPath}/react.svg', - SuperBlocks.dataVis: '${SuperBlockButton.learnAssetsPath}/d3.svg', - SuperBlocks.backEndDevApis: '${SuperBlockButton.learnAssetsPath}/api.svg', - SuperBlocks.relationalDb: - '${SuperBlockButton.learnAssetsPath}/database.svg', - SuperBlocks.qualityAssurance: - '${SuperBlockButton.learnAssetsPath}/clipboard.svg', - SuperBlocks.sciCompPy: '${SuperBlockButton.learnAssetsPath}/python.svg', - SuperBlocks.dataAnalysisPy: - '${SuperBlockButton.learnAssetsPath}/analytics.svg', - SuperBlocks.infoSec: '${SuperBlockButton.learnAssetsPath}/shield.svg', - SuperBlocks.machineLearningPy: - '${SuperBlockButton.learnAssetsPath}/tensorflow.svg', - SuperBlocks.codingInterviewPrep: - '${SuperBlockButton.learnAssetsPath}/algorithm.svg', - SuperBlocks.theOdinProject: - '${SuperBlockButton.learnAssetsPath}/viking-helmet.svg', - SuperBlocks.projectEuler: - '${SuperBlockButton.learnAssetsPath}/graduation.svg', - SuperBlocks.collegeAlgebraPy: - '${SuperBlockButton.learnAssetsPath}/college-algebra.svg', - SuperBlocks.foundationalCSharp: - '${SuperBlockButton.learnAssetsPath}/c-sharp.svg', - SuperBlocks.a2English: '${SuperBlockButton.learnAssetsPath}/a2-english.svg', - SuperBlocks.b1English: '${SuperBlockButton.learnAssetsPath}/b1-english.svg', - SuperBlocks.a1Spanish: '${SuperBlockButton.learnAssetsPath}/a1-spanish.svg', - SuperBlocks.a1Chinese: '${SuperBlockButton.learnAssetsPath}/a1-chinese.svg', - SuperBlocks.rosettaCode: - '${SuperBlockButton.learnAssetsPath}/rosetta-code.svg', - SuperBlocks.pythonForEverybody: - '${SuperBlockButton.learnAssetsPath}/python.svg', - SuperBlocks.devPlayground: '${SuperBlockButton.learnAssetsPath}/code.svg', - }; - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: button.public ? 1.0 : 0.7, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 4, - ), - constraints: BoxConstraints( - minHeight: 80, - ), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.all(5), - backgroundColor: FccColors.gray80, - side: const BorderSide( - width: 2, - color: FccColors.gray75, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5)), - side: const BorderSide( - color: Colors.teal, - width: 2.0, - ), - ), - ), - onPressed: () { - button.public - ? model.routeToSuperBlock(button.path, button.name) - : model.disabledButtonSnack(button.disabledByManualOverride); - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SizedBox( - width: 36, - height: 36, - child: SvgPicture.asset( - iconMap[SuperBlocks.fromValue(button.path)] ?? - '${SuperBlockButton.learnAssetsPath}/graduation.svg', - fit: BoxFit.contain, - colorFilter: ColorFilter.mode( - FccColors.gray00, - BlendMode.srcIn, - ), - ), - ), - ), - Expanded( - flex: 8, - child: Text( - button.name, - textAlign: TextAlign.left, - style: const TextStyle(fontSize: 20), - ), - ), - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Icon(Icons.arrow_forward_ios), - ), - ), - ) - ], - ), - ), - ), - ); - } -} - -class DailyChallengeButton extends StatelessWidget { - const DailyChallengeButton({super.key}); - - @override - Widget build(BuildContext context) { - final navigationService = locator(); - - return Container( - padding: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 4, - ), - constraints: BoxConstraints( - minHeight: 80, - ), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.all(5), - backgroundColor: FccColors.gray80, - side: const BorderSide( - width: 2, - color: FccColors.gray75, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5)), - side: const BorderSide( - color: Colors.teal, - width: 2.0, - ), - ), - ), - onPressed: () { - navigationService.navigateTo(Routes.dailyChallengeView); - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.calendar_today, - color: FccColors.gray00, - size: 36, - ), - ), - ), - Expanded( - flex: 8, - child: Text( - context.t.daily_challenge_view_archive, - textAlign: TextAlign.left, - style: const TextStyle(fontSize: 20), - ), - ), - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Icon(Icons.arrow_forward_ios), - ), - ), - ) - ], - ), - ), - ); - } -} - -class QuoteWidget extends StatelessWidget { - const QuoteWidget({ - super.key, - }); - - @override - Widget build(BuildContext context) { - LearnLandingViewModel model = LearnLandingViewModel(); - - return FutureBuilder( - future: model.retrieveNewQuote(), - builder: (context, snapshot) { - if (snapshot.hasData) { - MotivationalQuote quote = snapshot.data as MotivationalQuote; - - return Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '"${quote.quote}"', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - '- ${quote.author}', - style: const TextStyle( - fontStyle: FontStyle.italic, - fontSize: 18, - ), - ), - ) - ], - ), - ), - ], - ), - ); - } - - return Container(); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart b/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart deleted file mode 100644 index a26570e01..000000000 --- a/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/models/learn/motivational_quote_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/views/learn/landing/landing_view.dart'; -import 'package:freecodecamp/ui/views/learn/utils/learn_globals.dart'; -import 'package:freecodecamp/ui/widgets/setup_dialog_ui.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class LearnLandingViewModel extends BaseViewModel { - final NavigationService _navigationService = locator(); - - final AuthenticationService _auth = locator(); - AuthenticationService get auth => _auth; - - final LearnService _learnService = locator(); - LearnService get learnService => _learnService; - - final SnackbarService _snack = locator(); - SnackbarService get snack => _snack; - - final LocaleService _localeService = locator(); - - final _learnOfflineService = locator(); - LearnOfflineService get learnOfflineService => _learnOfflineService; - - final _dailyChallengeService = DailyChallengeService(); - final _remoteConfigService = locator(); - - Future>? superBlockButtons; - final _dio = DioService.dio; - - bool _isLoggedIn = false; - bool get isLoggedIn => _isLoggedIn; - - DailyChallenge? _dailyChallenge; - DailyChallenge? get dailyChallenge => _dailyChallenge; - - bool _isDailyChallengeCompleted = false; - bool get isDailyChallengeCompleted => _isDailyChallengeCompleted; - - TextStyle headerStyle = TextStyle(fontSize: 20, fontWeight: FontWeight.bold); - - set setSuperBlockButtons(value) { - superBlockButtons = value; - notifyListeners(); - } - - set setDailyChallenge(DailyChallenge? value) { - _dailyChallenge = value; - notifyListeners(); - } - - set setIsDailyChallengeCompleted(bool value) { - _isDailyChallengeCompleted = value; - notifyListeners(); - } - - void init(BuildContext context) async { - setupDialogUi(); - retrieveNewQuote(); - initLoggedInListener(); - - _loadInitialData(); - } - - Future _loadInitialData() async { - setBusy(true); - - try { - // Run both API calls in parallel, but handle failures independently - final results = await Future.wait([ - requestSuperBlocks().catchError((e) => []), - _fetchDailyChallenge().catchError((e) { - setDailyChallenge = null; - setIsDailyChallengeCompleted = false; - return null; - }), - ]); - - setSuperBlockButtons = Future.value(results[0] as List); - } finally { - setBusy(false); - } - } - - void fastRouteToChallenge() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - List? lastVisitedChallenge = prefs.getStringList( - 'lastVisitedChallenge', - ); - - // Values - // 0: full challenge url - // 1: superblock dashed name - // 2: superblock name - // 3: block dashed name - - if (lastVisitedChallenge != null) { - Challenge challenge = await learnOfflineService.getChallenge( - lastVisitedChallenge[0], - ); - - String baseUrl = LearnService.baseUrl; - - final Response res = - await _dio.get('$baseUrl/${lastVisitedChallenge[1]}.json'); - - if (res.statusCode == 200) { - List blocks = SuperBlock.fromJson( - res.data, - lastVisitedChallenge[1], - lastVisitedChallenge[2], - ).blocks as List; - - Block block = blocks.firstWhere( - (element) => element.dashedName == lastVisitedChallenge[3], - ); - - _navigationService.navigateToSuperBlockView( - superBlockDashedName: lastVisitedChallenge[1], - superBlockName: lastVisitedChallenge[2], - hasInternet: true, - ); - - _navigationService.navigateToChallengeTemplateView( - block: block, - challengeId: challenge.id, - ); - } - } - } - - void refresh() async { - _loadInitialData(); - } - - void initLoggedInListener() { - _isLoggedIn = AuthenticationService.staticIsloggedIn; - notifyListeners(); - auth.isLoggedIn.listen((e) async { - _isLoggedIn = e; - await updateDailyChallengeCompletionStatus(); - notifyListeners(); - }); - } - - Future updateDailyChallengeCompletionStatus() async { - if (!_isLoggedIn) { - setIsDailyChallengeCompleted = false; - } else if (_dailyChallenge != null) { - final isCompleted = await _checkIfChallengeCompleted(_dailyChallenge!.id); - setIsDailyChallengeCompleted = isCompleted; - } - } - - void disabledButtonSnack(bool disabledByManualOverride) { - final t = lookupAppLocalizations(_localeService.locale); - final message = disabledByManualOverride - ? t.temporarily_unavailable - : t.not_available_web; - - snack.showSnackbar(title: message, message: ''); - Future.delayed(const Duration(milliseconds: 2500), () { - snack.closeSnackbar(); - }); - } - - Future> requestSuperBlocks() async { - String baseUrl = LearnService.baseUrl; - - TextStyle headerStyle = TextStyle(fontSize: 21); - - final Response res = await _dio.get('$baseUrl/available-superblocks.json'); - if (res.statusCode == 200) { - await dotenv.load(fileName: '.env'); - - bool showAllSB = - dotenv.get('SHOWALLSB', fallback: 'false').toLowerCase() == 'true'; - - Map superBlockStages = res.data['superblocks']; - - final t = lookupAppLocalizations(_localeService.locale); - Map stageOrder = { - 'core': t.stage_core, - 'english': t.stage_english, - 'spanish': t.stage_spanish, - 'chinese': t.stage_chinese, - 'extra': t.stage_extra, - 'professional': t.stage_professional, - }; - - List widgetOrder = []; - - for (var stage in stageOrder.keys) { - if (superBlockStages.containsKey(stage)) { - List stageBlocks = superBlockStages[stage]; - - widgetOrder.add( - Text( - stageOrder[stage] ?? '', - style: headerStyle, - ), - ); - - for (var superBlock in stageBlocks) { - if (superBlock['dashedName'].toString().contains('full-stack')) { - continue; - } - - final fallbackValue = showAllSB || superBlock['public'] == true; - final override = _remoteConfigService.getActivationOverride( - superBlockDashedName: superBlock['dashedName'], - ); - final isPublic = override ?? fallbackValue; - - Widget button = SuperBlockButton( - button: SuperBlockButtonData( - path: superBlock['dashedName'], - name: superBlock['title'], - public: isPublic, - disabledByManualOverride: override == false, - ), - model: this, - ); - - widgetOrder.add(button); - } - - // Insert daily challenge archive after core curriculum - if (stage == 'core') { - widgetOrder.add( - Text( - t.daily_challenge_archive_title, - style: headerStyle, - ), - ); - widgetOrder.add( - const DailyChallengeButton(), - ); - } - } - } - - return widgetOrder; - } - return []; - } - - Future _fetchDailyChallenge() async { - final todayChallenge = await _dailyChallengeService.fetchTodayChallenge(); - final isCompleted = await _checkIfChallengeCompleted(todayChallenge.id); - - setDailyChallenge = todayChallenge; - setIsDailyChallengeCompleted = isCompleted; - } - - Future _checkIfChallengeCompleted(String challengeId) async { - FccUserModel? user = await _auth.userModel; - if (user != null) { - for (CompletedDailyChallenge challenge - in user.completedDailyCodingChallenges) { - if (challenge.id == challengeId) { - return true; - } - } - } - return false; - } - - void routeToSuperBlock(String dashedName, String name) async { - if (chapterBasedSuperBlocks.contains(dashedName)) { - _navigationService.navigateTo( - Routes.chapterView, - arguments: ChapterViewArguments( - superBlockDashedName: dashedName, - superBlockName: name, - ), - ); - } else { - _navigationService.navigateTo( - Routes.superBlockView, - arguments: SuperBlockViewArguments( - superBlockDashedName: dashedName, - superBlockName: name, - hasInternet: await learnOfflineService.hasInternet(), - ), - ); - } - } - - void goBack() { - _navigationService.back(); - } - - Future retrieveNewQuote() async { - String path = 'assets/learn/motivational-quotes.json'; - String file = await rootBundle.loadString(path); - - int quoteLength = jsonDecode(file)['motivationalQuotes'].length; - - Random random = Random(); - - int randomValue = random.nextInt(quoteLength); - - dynamic json = jsonDecode(file)['motivationalQuotes'][randomValue]; - - MotivationalQuote quote = MotivationalQuote.fromJson(json); - - return quote; - } -} diff --git a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart b/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart deleted file mode 100644 index 70cf6baec..000000000 --- a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_view.dart'; -import 'package:freecodecamp/ui/views/learn/superblock/superblock_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class SuperBlockView extends StatelessWidget { - const SuperBlockView({ - super.key, - required this.superBlockDashedName, - required this.superBlockName, - required this.hasInternet, - }); - - final String superBlockDashedName; - final String superBlockName; - final bool hasInternet; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => SuperBlockViewModel(), - onViewModelReady: (model) { - if (AuthenticationService.staticIsloggedIn) { - model.auth.fetchUser(); - } - - model.setSuperBlockData = model.getSuperBlockData( - superBlockDashedName, - superBlockName, - hasInternet, - ); - }, - builder: (context, model, child) => Scaffold( - body: Column( - children: [ - Container( - color: FccColors.gray90, - child: SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.only( - top: 0, right: 8, left: 8, bottom: 16), - child: Row( - children: [ - IconButton( - icon: const Icon( - Icons.arrow_back_ios, - color: Colors.white, - ), - onPressed: () => Navigator.of(context).pop(), - ), - Expanded( - child: Text( - superBlockName, - style: const TextStyle( - fontSize: 18, - color: Colors.white, - ), - softWrap: true, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: FutureBuilder( - initialData: null, - future: model.superBlockData, - builder: ((context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data is SuperBlock) { - SuperBlock superBlock = snapshot.data as SuperBlock; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (model.blockOpenStates.isEmpty) { - Map openStates = { - if (superBlock.blocks != null) - for (var block in superBlock.blocks!) - block.dashedName: false - }; - - if (openStates.isEmpty) { - return; - } - - // Set first block open - String firstBlockKey = openStates.entries.first.key; - - openStates[firstBlockKey] = true; - - model.blockOpenStates = openStates; - } - }); - - return blockTemplate(context, model, superBlock); - } - } - - if (snapshot.hasError) { - return Center(child: Text(context.t.error)); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }), - ), - ), - ], - ), - ), - ); - } - - Widget blockTemplate( - BuildContext context, - SuperBlockViewModel model, - SuperBlock superBlock, - ) { - if (superBlock.blocks == null || superBlock.blocks!.isEmpty) { - return Center( - child: Text(context.t.no_blocks_available), - ); - } - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: NotificationListener( - onNotification: (notification) { - notification.disallowIndicator(); - return true; - }, - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: superBlock.blocks!.length, - itemBuilder: (context, block) { - return Padding( - padding: model.getPaddingBeginAndEnd( - block, - superBlock.blocks![block].challenges.length, - ), - child: Column( - children: [ - BlockTemplateView( - key: ValueKey(block), - block: superBlock.blocks![block], - isOpen: model.blockOpenStates[ - superBlock.blocks![block].dashedName] ?? - false, - isOpenFunction: () => model.setBlockOpenClosedState( - superBlock, - block, - ), - ) - ], - ), - ); - }, - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart b/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart deleted file mode 100644 index 9815f9b6b..000000000 --- a/mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; -import 'package:freecodecamp/service/learn/learn_offline_service.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:stacked/stacked.dart'; - -class SuperBlockViewModel extends BaseViewModel { - final _learnOfflineService = locator(); - LearnOfflineService get learnOfflineService => _learnOfflineService; - - final _learnService = locator(); - LearnService get learnService => _learnService; - final _remoteConfigService = locator(); - - final AuthenticationService _auth = locator(); - AuthenticationService get auth => _auth; - - final _dio = DioService.dio; - - Map _blockOpenStates = {}; - Map get blockOpenStates => _blockOpenStates; - - Future? _superBlockData; - Future? get superBlockData => _superBlockData; - - set setSuperBlockData(Future? superBlockData) { - _superBlockData = superBlockData; - notifyListeners(); - } - - set blockOpenStates(Map openStates) { - _blockOpenStates = openStates; - notifyListeners(); - } - - EdgeInsets getPaddingBeginAndEnd(int index, int challenges) { - if (index == 0) { - return const EdgeInsets.only(top: 16); - } else if (challenges == 1) { - return const EdgeInsets.only(bottom: 32); - } else { - return const EdgeInsets.all(0); - } - } - - setBlockOpenClosedState(SuperBlock superBlock, int block) { - Map local = blockOpenStates; - Block curr = superBlock.blocks![block]; - - if (local[curr.dashedName] != null) { - local[curr.dashedName] = !local[curr.dashedName]!; - } - - blockOpenStates = local; - } - - Future getSuperBlockData( - String dashedName, - String name, - bool hasInternet, - ) async { - String baseUrl = LearnService.baseUrl; - - if (!hasInternet) { - final cachedBlocks = - await _learnOfflineService.getCachedBlocks(dashedName) ?? []; - - return SuperBlock( - dashedName: dashedName, - name: name, - blocks: _filterAvailableBlocks(cachedBlocks), - ); - } - - final Response res = await _dio.get('$baseUrl/$dashedName.json'); - - if (res.statusCode == 200) { - final superBlock = SuperBlock.fromJson( - res.data, - dashedName, - name, - ); - - return SuperBlock( - dashedName: superBlock.dashedName, - name: superBlock.name, - blocks: _filterAvailableBlocks(superBlock.blocks ?? []), - ); - } else { - throw Exception(res.data); - } - } - - List _filterAvailableBlocks(List blocks) { - return blocks.map((block) { - final isActive = _remoteConfigService.isActive( - superBlockDashedName: block.superBlock.dashedName, - blockDashedName: block.dashedName, - fallbackValue: true, - ); - - if (isActive) return block; - - return Block( - superBlock: block.superBlock, - layout: block.layout, - label: block.label, - name: block.name, - dashedName: block.dashedName, - description: block.description, - challenges: block.challenges, - challengeTiles: block.challengeTiles, - order: block.order, - isDisabledByOverride: true, - ); - }).toList(); - } -} diff --git a/mobile-app/lib/ui/views/learn/test_runner.dart b/mobile-app/lib/ui/views/learn/test_runner.dart deleted file mode 100644 index fbc098808..000000000 --- a/mobile-app/lib/ui/views/learn/test_runner.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/enums/ext_type.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_file_service.dart'; - -class ScriptBuilder { - final LearnFileService fileService = locator(); - - // Challenges where Babel syntax errors are expected and should be ignored. - static const skipBabelErrorChallengeIds = { - // workshop-fcc-authors-page - '69e16a610b33e847d85be16e', // step 27 - '69e1e83ac6424325f77331e8', // step 28 - }; - - static const transpileScript = ''' -try { - return { success: true, code: Babel.transform(code, { presets: [["env", { "targets": "> 0.4%, not dead" }]] }).code }; -} catch (e) { - let errorMsg = e.message || e.toString(); - if (e.loc) { - errorMsg = "SyntaxError: " + e.message + " (" + e.loc.line + ":" + e.loc.column + ")"; - } - return { success: false, error: errorMsg }; -} -'''; - - static const hideFccHeaderStyle = ''' - -'''; - - static String runnerScript = ''' -await import("http://localhost:8080/dist/index.js"); -window.TestRunner = await window.FCCTestRunner.createTestRunner({ - source: userCode, - type: workerType, - assetPath: "/dist", - code: { - contents: combinedCode, - editableContents: editableRegionContent, - }, - hooks -}); -'''; - - static String testExecutionScript = ''' -const testRes = await window.TestRunner.runTest(testStr); -return testRes; -'''; - - Future buildUserCode( - Challenge challenge, - InAppWebViewController? babelController, { - bool testing = false, - }) async { - String challengeFile = await fileService.getFirstFileFromCache( - challenge, - getChallengeExt(challenge.challengeType), - testing: testing, - ); - - switch (challenge.challengeType) { - // JS-only challenges - case 1: - case 26: - case 28: - try { - return await _transpileJs( - challengeFile, - ScriptBuilder.transpileScript, - babelController, - ); - } catch (e) { - if (ScriptBuilder.skipBabelErrorChallengeIds.contains(challenge.id)) { - return challengeFile; - } - rethrow; - } - case 20: - case 23: - case 27: - case 29: - // Python challenges do not require transpilation, return the file as is. - return challengeFile; - default: - String parsedWithStyleTags = - await fileService.parseCssDocumentsAsStyleTags( - challenge, - challengeFile, - testing: testing, - ); - - String parsedWithScriptTags = - await fileService.parseJsDocumentsAsScriptTags( - challenge, - parsedWithStyleTags, - babelController, - testing: testing, - ); - - challengeFile = fileService.changeActiveFileLinks( - parsedWithScriptTags, - ); - - return challengeFile; - } - } - - Future _transpileJs( - String code, - String transpileScript, - InAppWebViewController? babelController, - ) async { - // TODO: Move to learn file service - if (babelController == null) { - throw Exception('Babel controller is required to transpile JS code.'); - } - - final babelRes = await babelController.callAsyncJavaScript( - functionBody: transpileScript, - arguments: {'code': code}, - ); - - if (babelRes?.error != null) { - throw Exception('Babel transpilation failed: ${babelRes?.error}'); - } - - final result = babelRes?.value as Map?; - if (result?['success'] == false) { - throw Exception('Babel transpilation failed: ${result?['error']}'); - } - - return result?['code'] ?? code; - } - - String getWorkerType(int challengeType) { - switch (challengeType) { - case 0: - case 14: - case 25: - return 'dom'; - case 1: - case 26: - case 28: - return 'javascript'; - case 20: - case 23: - case 27: - case 29: - return 'python'; - } - - return 'dom'; - } - - Ext getChallengeExt(int challengeType) { - switch (challengeType) { - // JS-only challenges - case 1: - case 26: - case 28: - return Ext.js; - case 20: - case 23: - case 27: - case 29: - return Ext.py; - default: - return Ext.html; - } - } - - Future combinedCode(Challenge challenge) async { - String combinedCode = ''; - - for (ChallengeFile file in challenge.files) { - combinedCode += await fileService.getExactFileFromCache(challenge, file); - } - - return combinedCode; - } -} diff --git a/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart b/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart deleted file mode 100644 index 22f4beca2..000000000 --- a/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; - -String handleChallengeTitle(Challenge challenge, Block block) { - if (block.challenges.length == 1 || - challenge.title.contains('Dialogue') || - challenge.title.contains('Step')) { - return ''; - } - - int numberOfDialogueHeaders = block.challenges - .where((challenge) => challenge.title.contains('Dialogue')) - .length; - - // English challenges have titles like "Task 1", "Task 2", etc. - // The number of tasks = total challenges - number of dialogue headers - if (challenge.title.contains('Task')) { - return '${challenge.title} of ${block.challenges.length - numberOfDialogueHeaders}'; - } else { - int challengeStepNumber = - block.challenges.indexWhere((c) => c.id == challenge.id) + 1; - - return 'Step $challengeStepNumber of ${block.challenges.length}'; - } -} - -// Takes a DateTime and returns a formatted string like "January 2025" -String formatMonthFromDate(DateTime date) { - const monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' - ]; - return '${monthNames[date.month - 1]} ${date.year}'; -} - -// Parses a month-year string like "January 2025" into a DateTime object -DateTime parseMonthYear(String monthYear) { - List parts = monthYear.split(' '); - String monthName = parts[0]; - int year = int.parse(parts[1]); - - const monthNames = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' - ]; - - int month = monthNames.indexOf(monthName) + 1; - return DateTime(year, month, 1); -} - -// Formats a DateTime as YYYY-MM-DD -String formatChallengeDate(DateTime date) { - return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; -} - -String parseSyntaxError( - String errorMessage, { - String Function(int line)? lineMessage, - String? generalMessage, -}) { - final syntaxErrorRegex = RegExp(r'SyntaxError:.*?\((\d+):(\d+)\)'); - final match = syntaxErrorRegex.firstMatch(errorMessage); - - if (match != null) { - final line = int.parse(match.group(1)!) - 1; - return lineMessage?.call(line) ?? - 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; - } - - if (errorMessage.contains('Babel transpilation failed')) { - return generalMessage ?? - 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; - } - - return errorMessage; -} diff --git a/mobile-app/lib/ui/views/learn/utils/learn_globals.dart b/mobile-app/lib/ui/views/learn/utils/learn_globals.dart deleted file mode 100644 index f4ff1ce75..000000000 --- a/mobile-app/lib/ui/views/learn/utils/learn_globals.dart +++ /dev/null @@ -1,17 +0,0 @@ -library; - -// Changing values in this file will require a hot restart to take effect - -List hasNoCert = ['the-odin-project', 'college-algebra-with-python']; -List hasDialogue = [ - 'a2-english-for-developers', - 'b1-english-for-developers', - 'a1-professional-spanish', -]; - -const chapterBasedSuperBlocks = [ - 'responsive-web-design-v9', - 'javascript-v9', - 'a1-professional-spanish', - 'python-v9' -]; diff --git a/mobile-app/lib/ui/views/learn/widgets/assignment_widget.dart b/mobile-app/lib/ui/views/learn/widgets/assignment_widget.dart deleted file mode 100644 index f87a70f8a..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/assignment_widget.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; - -class Assignment extends StatelessWidget { - const Assignment( - {super.key, - required this.label, - required this.value, - required this.onChanged, - required this.onTap}); - - final String label; - final bool value; - final ValueChanged onChanged; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - child: ListTile( - selected: value, - tileColor: const Color(0xFF0a0a23), - selectedTileColor: const Color(0xDEFFFFFF), - onTap: () { - onTap(); - }, - leading: Checkbox( - focusNode: FocusNode(), - value: value, - onChanged: (value) { - if (value != null) { - onChanged(!value); - } - }, - activeColor: const Color(0xDEFFFFFF), - checkColor: const Color(0xFF0a0a23), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - side: BorderSide( - color: value ? const Color(0xFF0a0a23) : const Color(0xFFAAAAAA), - width: 2, - ), - ), - title: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: parser.parse( - label, - isSelectable: false, - customStyles: value - ? { - '*': Style(color: FccColors.gray00), - } - : {}, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart deleted file mode 100644 index d9a3c83b9..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_view.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/audio/audio_player_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class AudioPlayerView extends StatelessWidget { - const AudioPlayerView({super.key, required this.audio}); - - final EnglishScene audio; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => AudioPlayerViewmodel(), - onViewModelReady: (model) => { - model.initPositionListener(), - model.audioService.loadCurriculumAudio(audio) - }, - onDispose: (model) => model.onDispose(), - builder: (context, model, child) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - child: StreamBuilder( - initialData: PlaybackState(), - stream: model.audioService.playbackState, - builder: (context, snapshot) { - final playerState = snapshot.data as PlaybackState; - - return InnerAudioWidget( - model: model, - audio: audio, - playerState: playerState, - ); - }, - ), - ), - ); - } -} - -class InnerAudioWidget extends StatelessWidget { - const InnerAudioWidget({ - super.key, - required this.model, - required this.audio, - required this.playerState, - }); - - final AudioPlayerViewmodel model; - final EnglishScene audio; - final PlaybackState playerState; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - initialData: Duration.zero, - stream: model.position.stream, - builder: (context, snapshot) { - if (snapshot.data == null) { - return const CircularProgressIndicator(); - } - - if (snapshot.hasData) { - final position = snapshot.data as Duration; - - bool canSeekForward = model.audioService.canSeek( - true, - position.inSeconds, - audio, - ); - - bool canSeekBackward = model.audioService.canSeek( - false, - position.inSeconds, - audio, - ); - - Duration? totalDuration = model.audioService.duration(); - int handleTotal = totalDuration?.inMilliseconds ?? 0; - bool hasZeroValue = handleTotal == 0 || position.inMilliseconds == 0; - return Column( - children: [ - LinearProgressIndicator( - backgroundColor: FccColors.gray75, - valueColor: const AlwaysStoppedAnimation( - FccColors.blue50, - ), - value: hasZeroValue ? 0 : position.inMilliseconds / handleTotal, - minHeight: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: canSeekBackward - ? () { - model.audioService.seek( - model.searchTimeStamp( - false, - position.inSeconds, - audio, - ), - ); - } - : null, - icon: const Icon(Icons.skip_previous), - ), - IconButton( - onPressed: () { - if (playerState.playing && - playerState.processingState != - AudioProcessingState.completed) { - model.audioService.pause(); - } else if (playerState.processingState == - AudioProcessingState.completed) { - model.audioService.seek( - model.searchTimeStamp( - false, - position.inSeconds, - audio, - ), - ); - model.audioService.play(); - } else { - model.audioService.play(); - } - }, - icon: playerState.playing && - playerState.processingState != - AudioProcessingState.completed - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), - ), - IconButton( - onPressed: canSeekForward - ? () { - model.audioService.seek( - model.searchTimeStamp( - true, - position.inSeconds, - audio, - ), - ); - } - : null, - icon: const Icon(Icons.skip_next), - ), - ], - ), - ], - ); - } - - return const CircularProgressIndicator(); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart deleted file mode 100644 index 88717f7e0..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'dart:async'; - -import 'package:audio_service/audio_service.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:stacked/stacked.dart'; - -class AudioPlayerViewmodel extends BaseViewModel { - final audioService = locator().audioHandler; - - StreamController position = StreamController.broadcast(); - - Duration? _totalDuration; - Duration? get totalDuration => _totalDuration; - - Duration searchTimeStamp( - bool forwards, - int currentPosition, - EnglishScene audio, - ) { - if (forwards) { - return Duration( - seconds: currentPosition + 2, - ); - } else { - return Duration( - milliseconds: currentPosition - 2, - ); - } - } - - void initPositionListener() { - AudioService.position.listen((event) { - if (position.isClosed) { - return; - } - - position.add(event); - }); - } - - void onDispose() { - position.close(); - audioService.stop(); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_card.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_card.dart deleted file mode 100644 index fc7154619..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_card.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; - -class ChallengeCard extends StatelessWidget { - const ChallengeCard({ - super.key, - required this.title, - required this.child, - }); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.0), - color: FccColors.gray85, - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 14.0, left: 8, right: 8), - child: Text( - title.toUpperCase(), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - child - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_tile.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_tile.dart deleted file mode 100644 index 3949df067..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_tile.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; - -class ChallengeTile extends StatelessWidget { - const ChallengeTile({ - super.key, - required this.block, - required this.model, - required this.step, - required this.isDownloaded, - required this.challengeId, - }); - - final Block block; - final BlockTemplateViewModel model; - final int step; - final bool isDownloaded; - final String challengeId; - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: model.completedChallenge(challengeId), - builder: (context, snapshot) { - final isCompleted = snapshot.data ?? false; - - return TextButton( - onPressed: () { - model.routeToChallengeView( - block, - challengeId, - ); - }, - style: TextButton.styleFrom( - backgroundColor: isCompleted - ? const Color.fromRGBO(0x00, 0x2e, 0xad, 0.3) - : const Color.fromRGBO(0x2a, 0x2a, 0x40, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(5), - side: isCompleted - ? const BorderSide( - width: 1, - color: Color.fromRGBO(0xbc, 0xe8, 0xf1, 1), - ) - : const BorderSide( - color: Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - ), - ), - ), - child: Text( - step.toString(), - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_preview.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_preview.dart deleted file mode 100644 index cdab8cb99..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/project_preview.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; - -class ProjectPreview extends StatelessWidget { - const ProjectPreview({ - super.key, - required this.challenge, - required this.model, - }); - - final Challenge challenge; - final ChallengeViewModel model; - - @override - Widget build(BuildContext context) { - return Expanded( - child: FutureBuilder( - future: model.providePreview(challenge), - builder: (context, snapshot) { - if (snapshot.hasData) { - if (snapshot.data is String) { - return InAppWebView( - initialData: InAppWebViewInitialData( - data: snapshot.data as String, - mimeType: 'text/html', - ), - onWebViewCreated: (controller) { - model.setWebviewController = controller; - }, - initialSettings: InAppWebViewSettings( - // TODO: Set this to true only in dev mode - isInspectable: true, - ), - ); - } - } - - if (snapshot.hasError) { - return Center( - child: Text(context.t.error), - ); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart deleted file mode 100644 index 0e2dd1ada..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:phone_ide/phone_ide.dart'; - -class SymbolBar extends StatelessWidget { - const SymbolBar({ - super.key, - required this.editor, - required this.model, - }); - - final Editor editor; - final ChallengeViewModel model; - - static const List symbols = [ - 'Tab', - '<', - '/', - '>', - '\\', - '\'', - '"', - '=', - '{', - '}' - ]; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - height: 50, - color: const Color(0xFF1b1b32), - child: ScrollShadow( - size: 12, - child: ListView.builder( - scrollDirection: Axis.horizontal, - controller: model.symbolBarScrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: symbols.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 1, - ), - child: TextButton( - onPressed: () { - model.insertSymbol(symbols[index], editor); - }, - style: TextButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.zero), - ), - ), - child: Text(symbols[index]), - ), - ); - }, - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/console/console_view.dart b/mobile-app/lib/ui/views/learn/widgets/console/console_view.dart deleted file mode 100644 index 2d3eec732..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/console/console_view.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/console/console_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class JavaScriptConsole extends StatelessWidget { - const JavaScriptConsole({super.key, required this.messages}); - - final List messages; - - static final String defaultMessage = ''' -

    /**
    - * Your test output will go here
    - */

    - '''; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => JavaScriptConsoleViewModel(), - builder: (context, model, child) { - HTMLParser parser = HTMLParser( - context: context, - ); - - final scrollController = ScrollController(); - - return Expanded( - child: Row( - children: [ - Expanded( - child: Scrollbar( - controller: scrollController, - child: ListView.builder( - padding: const EdgeInsets.all(8), - physics: const ClampingScrollPhysics(), - controller: scrollController, - primary: false, - itemCount: messages.isEmpty ? 1 : messages.length, - itemBuilder: (context, index) { - List htmlWidgets = parser.parse( - messages.isEmpty ? '' : messages[index], - customStyles: { - 'body': Style( - margin: Margins.symmetric( - vertical: 0, - horizontal: 8, - ), - ), - 'p': Style(margin: Margins.zero), - 'ol': Style( - padding: HtmlPaddings.only(inlineStart: 20), - ), - }, - ); - - return Row( - children: [ - for (int i = 0; i < htmlWidgets.length; i++) - Expanded( - child: htmlWidgets[i], - ) - ], - ); - }, - ), - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/console/console_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/console/console_viewmodel.dart deleted file mode 100644 index 2fef0f2b6..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/console/console_viewmodel.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:stacked/stacked.dart'; - -class JavaScriptConsoleViewModel extends BaseViewModel { - ScrollController controller = ScrollController(); - - Color getConsoleTextColor(ConsoleMessageLevel messageLevel) { - switch (messageLevel) { - case ConsoleMessageLevel.DEBUG: - return Colors.white.withValues(alpha: 0.87); - case ConsoleMessageLevel.ERROR: - return Colors.red.withValues(alpha: 0.87); - case ConsoleMessageLevel.LOG: - return Colors.white.withValues(alpha: 0.87); - case ConsoleMessageLevel.TIP: - return Colors.blue.withValues(alpha: 0.87); - case ConsoleMessageLevel.WARNING: - return Colors.yellow.withValues(alpha: 0.87); - default: - return Colors.white.withValues(alpha: 0.87); - } - } - - void scrollToBottom() {} -} diff --git a/mobile-app/lib/ui/views/learn/widgets/custom_alert_widget.dart b/mobile-app/lib/ui/views/learn/widgets/custom_alert_widget.dart deleted file mode 100644 index 795eedc14..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/custom_alert_widget.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/enums/alert_type.dart'; - -class CustomAlert extends StatelessWidget { - const CustomAlert({ - super.key, - required this.text, - required this.alertType, - }); - - final String text; - final Alert alertType; - - @override - Widget build(BuildContext context) { - Size screen = MediaQuery.of(context).size; - - AlertColor alert = returnColor(alertType); - - return Container( - padding: const EdgeInsets.all(8), - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: alert.backgroundColor, - border: Border.all( - width: 2, - color: alert.textColor, - ), - borderRadius: const BorderRadius.all( - Radius.circular(5), - ), - ), - constraints: const BoxConstraints(minHeight: 50), - width: screen.width, - child: Text( - text, - style: TextStyle( - color: alert.textColor, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart b/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart deleted file mode 100644 index cc25a1365..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart +++ /dev/null @@ -1,284 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; -import 'package:stacked_services/stacked_services.dart'; -import 'package:timezone/data/latest.dart' as tzdata; -import 'package:timezone/timezone.dart' as tz; - -class DailyChallengeCard extends StatefulWidget { - final DailyChallenge? dailyChallenge; - final bool isCompleted; - - const DailyChallengeCard({ - super.key, - this.dailyChallenge, - this.isCompleted = false, - }); - - @override - State createState() => _DailyChallengeCardState(); -} - -class _DailyChallengeCardState extends State { - Duration _timeLeft = Duration.zero; - Timer? countdownTimer; - final NavigationService _navigationService = locator(); - - void _navigateToDailyChallenge(BuildContext context) { - final challenge = widget.dailyChallenge; - if (challenge == null) return; - - final monthYear = formatMonthFromDate(challenge.date); - - final challengeOverview = DailyChallengeOverview( - id: challenge.id, - title: challenge.title, - date: challenge.date, - challengeNumber: challenge.challengeNumber, - ); - - final block = DailyChallengeBlock( - monthYear: monthYear, - challenges: [challengeOverview], - description: '', - ).toCurriculumBlock(); - - Navigator.of(context).pushNamed( - '/challenge-template-view', - arguments: ChallengeTemplateViewArguments( - challengeId: challenge.id, - block: block, - challengeDate: challenge.date, - ), - ); - } - - @override - void initState() { - super.initState(); - tzdata.initializeTimeZones(); - _updateTimeLeft(); - countdownTimer = Timer.periodic(Duration(seconds: 1), (_) { - _updateTimeLeft(); - }); - } - - void _updateTimeLeft() { - final chicago = tz.getLocation('America/Chicago'); - final now = tz.TZDateTime.now(chicago); - final nextMidnight = - tz.TZDateTime(chicago, now.year, now.month, now.day + 1); - setState(() { - _timeLeft = nextMidnight.difference(now); - }); - } - - @override - void dispose() { - countdownTimer?.cancel(); - super.dispose(); - } - - String _formatDuration(Duration d) { - String twoDigits(int n) => n.toString().padLeft(2, '0'); - return '${twoDigits(d.inHours)}:${twoDigits(d.inMinutes % 60)}:${twoDigits(d.inSeconds % 60)}'; - } - - Widget _buildCompletedContent() { - return Column( - children: [ - // Card label - Text( - context.t.daily_challenge_completed, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: FccColors.gray80, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 14), - // Countdown timer - Semantics( - label: context.t - .daily_challenge_countdown_label(_formatDuration(_timeLeft)), - liveRegion: true, - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Text( - context.t.daily_challenge_next_in(_formatDuration(_timeLeft)), - style: TextStyle( - color: FccColors.gray90, - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ), - ), - SizedBox(height: 14), - // CTA Button - SizedBox( - width: double.infinity, - child: Tooltip( - message: context.t.daily_challenge_view_past_tooltip, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.yellow50, - foregroundColor: FccColors.gray90, - elevation: 0, - padding: EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: () { - _navigationService.navigateTo(Routes.dailyChallengeView); - }, - icon: Icon( - Icons.history, - size: 20, - semanticLabel: context.t.daily_challenge_view_past, - ), - label: Text( - context.t.daily_challenge_view_past, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildNotCompletedContent() { - final challenge = widget.dailyChallenge; - if (challenge == null) { - return SizedBox(); - } - return Column( - children: [ - // Card label - Text( - context.t.daily_challenge_today, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: FccColors.gray80, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 4), - // Challenge title - Text( - challenge.title, - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: FccColors.gray90, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 14), - // Motivation text - Text( - context.t.daily_challenge_prompt, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, color: FccColors.gray85), - ), - SizedBox(height: 20), - // CTA Button - SizedBox( - width: double.infinity, - child: Tooltip( - message: context.t.daily_challenge_start_tooltip, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.yellow50, - foregroundColor: FccColors.gray90, - elevation: 0, - padding: EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - onPressed: () => _navigateToDailyChallenge(context), - icon: Icon( - Icons.arrow_forward_ios, - size: 20, - semanticLabel: context.t.daily_challenge_go_to_challenge, - ), - label: Text( - context.t.daily_challenge_start, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ); - } - - @override - Widget build(BuildContext context) { - if (widget.dailyChallenge == null) { - return SizedBox.shrink(); - } - - return Column( - children: [ - SizedBox(height: 16), - GestureDetector( - onTap: () {}, - child: Semantics( - label: widget.isCompleted - ? context.t.daily_challenge_completed_semantics - : context.t.daily_challenge_card_semantics, - container: true, - child: Container( - padding: const EdgeInsets.all(20), - width: MediaQuery.of(context).size.width - 24, - decoration: BoxDecoration( - color: FccColors.gray10, - border: Border.all(color: FccColors.gray10, width: 2), - borderRadius: BorderRadius.all(Radius.circular(16)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.10), - blurRadius: 16, - offset: Offset(0, 8), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - widget.isCompleted - ? _buildCompletedContent() - : _buildNotCompletedContent(), - ], - ), - ), - ), - ), - SizedBox(height: 16), - ], - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_model.dart b/mobile-app/lib/ui/views/learn/widgets/description/description_widget_model.dart deleted file mode 100644 index f84537025..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_model.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class DescriptionModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart b/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart deleted file mode 100644 index 426037eb2..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/description/description_widget_view.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/description/description_widget_model.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:stacked/stacked.dart'; - -class DescriptionView extends StatelessWidget { - const DescriptionView({ - super.key, - required this.description, - required this.instructions, - required this.challengeModel, - required this.maxChallenges, - required this.title, - }); - - final String description; - final String instructions; - final ChallengeViewModel challengeModel; - final int maxChallenges; - final String title; - - @override - Widget build(BuildContext context) { - List splitTitle = title.split(' '); - bool isMultiStepChallenge = splitTitle.length == 2 && - splitTitle[0] == 'Step' && - int.tryParse(splitTitle[1]) != null; - return ViewModelBuilder.reactive( - viewModelBuilder: () => DescriptionModel(), - builder: (context, model, child) { - HTMLParser parser = HTMLParser(context: context); - - return SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: instructions.isNotEmpty || description.isNotEmpty - ? [ - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 12, right: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - if (isMultiStepChallenge) - Text( - context.t.step_count( - splitTitle[1], - maxChallenges.toString(), - ), - style: const TextStyle( - fontSize: 14, - color: Colors.white70, - ), - ) - ], - ), - ), - ), - ], - ), - Expanded( - child: Builder( - builder: (context) { - final scrollController = ScrollController(); - return Scrollbar( - thumbVisibility: true, - trackVisibility: true, - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: parser.parse( - description, - ) + - parser.parse(instructions), - ), - ), - ); - }, - ), - ), - ] - : [], - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/example_editor.dart b/mobile-app/lib/ui/views/learn/widgets/example_editor.dart deleted file mode 100644 index 3be4e55a0..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/example_editor.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:phone_ide/editor/editor.dart'; -import 'package:phone_ide/editor/editor_options.dart'; - -class ExampleEditor extends StatelessWidget { - const ExampleEditor({ - super.key, - required this.nodules, - required this.parser, - }); - - final List nodules; - final HTMLParser parser; - - @override - Widget build(BuildContext context) { - return ChallengeCard( - title: context.t.challenge_card_lesson, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...nodules.map( - (nodule) { - return switch (nodule) { - ParagraphNodule(:final contents) => Column( - children: parser.parse( - contents, - customStyles: { - '*': Style( - fontSize: FontSize(18), - ), - 'ul': Style( - fontSize: FontSize(18), - padding: HtmlPaddings.only(left: 10)) - }, - ), - ), - InteractiveEditorNodule(:final files) => Column( - children: files - .map( - (file) => Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.ext.toUpperCase(), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 8), - Editor( - options: EditorOptions( - fontFamily: 'Hack', - takeFullHeight: false, - showLinebar: false, - isEditable: false, - ), - defaultLanguage: file.ext, - defaultValue: file.contents, - path: '/', - ), - ], - ), - ), - ) - .toList(), - ) - }; - }, - ) - ], - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart b/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart deleted file mode 100644 index 72b2443d2..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; - -class Explanation extends StatelessWidget { - const Explanation({ - super.key, - required this.explanation, - }); - - final String explanation; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ExpansionTile( - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - title: Text(context.t.tap_to_expand), - shape: const RoundedRectangleBorder( - side: BorderSide.none, - borderRadius: BorderRadius.zero, - ), - collapsedShape: const RoundedRectangleBorder( - side: BorderSide.none, - borderRadius: BorderRadius.zero, - ), - children: [ - ...parser.parse(explanation), - ], - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_model.dart b/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_model.dart deleted file mode 100644 index ef8eb6572..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_model.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class HintWidgetModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart b/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart deleted file mode 100644 index 577294903..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/hint/hint_widget_model.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:freecodecamp/utils/helpers.dart'; -import 'package:phone_ide/phone_ide.dart'; -import 'package:stacked/stacked.dart'; - -Future genForumLink( - Challenge challenge, - Block block, - BuildContext context, { - String editorText = '', -}) async { - Challenge? currChallenge = challenge; - - final HelpCategory helpCategory = challenge.helpCategory; - final String blockTitle = block.name; - - final userDeviceInfo = await getDeviceInfo(context); - - final titleText = '$blockTitle - ${currChallenge.title}'; - final t = context.t; - final String endingText = - '**${t.forum_mobile_info}**\n```txt\n$userDeviceInfo\n```\n\n**${t.forum_challenge}** $titleText\n\n**${t.forum_challenge_link}**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; - - final String userCode = await filesToMarkdown(currChallenge, editorText); - - final String textMessage = - '**${t.forum_tell_us_heading}**\n${t.forum_describe_issue}\n\n**${t.forum_code_so_far}**$userCode\n\n$endingText'; - final String altTextMessage = - '**${t.forum_tell_us_heading}**\n\n\n\n**${t.forum_code_so_far}**\n\n${t.forum_warning}\n\n${t.forum_code_too_long}\n\n${t.forum_additional_step}\n\n${t.forum_copy_editor_code}\n\n```\n${t.forum_replace_code}\n\n```\n$endingText'; - - String studentCode = Uri.encodeComponent(textMessage); - String altStudentCode = Uri.encodeComponent(altTextMessage); - - final String baseURL = - '$forumLocation/new-topic?category=${helpCategory.value}&title=$titleText&body='; - final String defaultURL = '$baseURL$studentCode'; - final String altURL = '$baseURL$altStudentCode'; - - return defaultURL.length < 8000 ? defaultURL : altURL; -} - -class HintWidgetView extends StatelessWidget { - const HintWidgetView( - {super.key, - required this.hint, - required this.challengeModel, - required this.editor}); - - final String hint; - final ChallengeViewModel challengeModel; - - final Editor editor; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => HintWidgetModel(), - builder: (context, model, child) => SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - context.t.hint, - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white.withValues(alpha: 0.87), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Expanded( - child: SingleChildScrollView( - child: Column( - children: parser.parse(hint), - ), - ), - ), - const SizedBox(height: 8), - Expanded( - flex: 0, - child: Align( - alignment: Alignment.bottomCenter, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () async { - challengeModel.learnService.forumHelpDialog( - challengeModel.challenge as Challenge, - challengeModel.block as Block, - context, - ); - }, - icon: const Icon(Icons.question_mark), - padding: const EdgeInsets.all(16), - ), - IconButton( - onPressed: () { - challengeModel.resetCode(editor, context); - }, - icon: const Icon(Icons.restart_alt), - padding: const EdgeInsets.all(16), - ), - ], - ), - ), - ) - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_model.dart b/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_model.dart deleted file mode 100644 index d171627ac..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_model.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter/services.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/motivational_quote_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class PassWidgetModel extends BaseViewModel { - final AuthenticationService auth = locator(); - - Future? quoteFuture; - - set setQuoteFuture(Future value) { - quoteFuture = value; - notifyListeners(); - } - - void initQuoute() { - setQuoteFuture = retrieveNewQuote(); - } - - Future numCompletedChallenges(ChallengeViewModel challengeModel) async { - FccUserModel? user = await auth.userModel; - if (user != null) { - Challenge? currChallenge = challengeModel.challenge; - List completedChallenges = user.completedChallenges; - Set completedChallengeIds = - completedChallenges.map((completed) => completed.id).toSet(); - int completedCount = 0; - List blockChallenges = - challengeModel.block?.challenges ?? []; - - for (ChallengeOrder challenge in blockChallenges) { - if (completedChallengeIds.contains(challenge.id)) { - completedCount += 1; - } - } - - if (currChallenge != null && - !completedChallengeIds.contains(currChallenge.id)) { - completedCount += 1; - } - - return completedCount; - } - return 0; - } - - Future retrieveNewQuote() async { - String path = 'assets/learn/motivational-quotes.json'; - String file = await rootBundle.loadString(path); - - int quoteLength = jsonDecode(file)['motivationalQuotes'].length; - - Random random = Random(); - - int randomValue = random.nextInt(quoteLength); - - dynamic json = jsonDecode(file)['motivationalQuotes'][randomValue]; - - MotivationalQuote quote = MotivationalQuote.fromJson(json); - - return quote; - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_view.dart b/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_view.dart deleted file mode 100644 index cef5c365e..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/pass/pass_widget_view.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/models/learn/motivational_quote_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/pass/pass_widget_model.dart'; -import 'package:stacked/stacked.dart'; - -class PassWidgetView extends StatelessWidget { - const PassWidgetView({ - super.key, - required this.challengeModel, - required this.maxChallenges, - }); - - final ChallengeViewModel challengeModel; - final int maxChallenges; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => PassWidgetModel(), - onViewModelReady: (model) => model.initQuoute(), - builder: (context, model, child) => SafeArea( - child: Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - context.t.passed, - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: Colors.white.withValues(alpha: 0.87), - ), - ), - ), - ], - ), - Expanded( - child: Scrollbar( - thumbVisibility: true, - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FutureBuilder( - future: model.quoteFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - MotivationalQuote quote = - snapshot.data as MotivationalQuote; - return Padding( - padding: const EdgeInsets.all(16), - child: Text( - '"${quote.quote}"', - style: TextStyle( - color: Colors.white.withValues(alpha: 0.87), - fontSize: 20, - ), - ), - ); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - StreamBuilder( - stream: AuthenticationService.isLoggedInStream.stream, - builder: (context, snapshot) { - // Hide progress bar for daily challenges as the challenges technically aren't related. - // Also, if the user access the challenge from the landing page, - // we don't have the total challenges count to compute the progress. - if (AuthenticationService.staticIsloggedIn && - !challengeModel.isDailyChallenge) { - return FutureBuilder( - future: model.numCompletedChallenges( - challengeModel, - ), - builder: (context, completedSnapshot) { - if (completedSnapshot.hasData) { - int completed = completedSnapshot.data as int; - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: LinearProgressIndicator( - value: completed / maxChallenges, - minHeight: 7, - backgroundColor: const Color.fromRGBO( - 0x3B, 0x3B, 0x4F, 1), - ), - ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: Row( - children: [ - Expanded( - child: Text( - context.t.completed_percent( - ((completed * 100) / - maxChallenges) - .round() - .toString(), - ), - textAlign: TextAlign.right, - ), - ), - ], - ), - ), - ], - ); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ); - } else if (!AuthenticationService.staticIsloggedIn) { - return Container( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromRGBO( - 0xf1, 0xbe, 0x32, 1), - padding: const EdgeInsets.all(8), - minimumSize: const Size.fromHeight(50), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(0), - ), - ), - onPressed: () { - model.auth.routeToLogin(true); - }, - child: Text( - context.t.login_save_progress, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.black, - fontSize: 20, - ), - ), - ), - ), - ], - ), - ); - } - - return Center(child: Container()); - }, - ), - FutureBuilder( - future: model.numCompletedChallenges(challengeModel), - builder: (context, completedSnapshot) { - if (completedSnapshot.hasData) { - int completed = completedSnapshot.data as int; - return PassButton( - model: challengeModel, - maxChallenges: maxChallenges, - completed: completed - 1, - ); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ) - ], - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -class PassButton extends StatelessWidget { - const PassButton({ - super.key, - required this.model, - required this.maxChallenges, - required this.completed, - }); - - final ChallengeViewModel model; - final int maxChallenges; - final int completed; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.all(16), - height: 50, - width: MediaQuery.of(context).size.width, - child: TextButton( - onPressed: () async { - // Close the end drawer if open before navigating. - // This is needed to ensure the back navigation works correctly. - final scaffoldState = model.scaffoldKey.currentState; - if (scaffoldState != null && scaffoldState.isEndDrawerOpen) { - scaffoldState.closeEndDrawer(); - await Future.delayed(const Duration(milliseconds: 300)); - } - - model.closeWebViews(); - model.disposeOfListeners(); - - model.learnService.goToNextChallenge( - maxChallenges, - model.challenge as Challenge, - model.block as Block, - ); - }, - style: TextButton.styleFrom( - backgroundColor: const Color.fromRGBO(60, 118, 61, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - margin: const EdgeInsets.only(right: 10), - child: Text( - context.t.next, - style: const TextStyle(fontSize: 20), - textAlign: TextAlign.center, - ), - ), - const Icon( - Icons.arrow_forward_rounded, - size: 30, - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart deleted file mode 100644 index a74135d1e..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart +++ /dev/null @@ -1,224 +0,0 @@ -import 'dart:developer'; - -import 'package:audio_service/audio_service.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; - -class QuizAudioPlayer extends StatefulWidget { - const QuizAudioPlayer({super.key, required this.audioData}); - - final QuizAudioData audioData; - - @override - State createState() => _QuizAudioPlayerState(); -} - -class _QuizAudioPlayerState extends State { - final audioHandler = locator().audioHandler; - bool _isLoading = false; - bool _hasError = false; - - Future _loadAndPlay() async { - try { - setState(() { - _isLoading = true; - _hasError = false; - }); - - await audioHandler.stop(); - await audioHandler.loadCurriculumAudio(widget.audioData.audio); - - if (mounted) { - setState(() { - _isLoading = false; - }); - audioHandler.play(); - } - } catch (e) { - if (mounted) { - log('QuizAudioPlayer: Error loading audio: $e'); - setState(() { - _isLoading = false; - _hasError = true; - }); - } - } - } - - @override - Widget build(BuildContext context) { - if (_hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text(context.t.audio_load_error), - ), - ); - } - - return Column( - children: [ - StreamBuilder( - initialData: PlaybackState(), - stream: audioHandler.playbackState, - builder: (context, snapshot) { - final playerState = snapshot.data as PlaybackState; - final quizAudio = widget.audioData.audio; - final isActive = audioHandler.curriculumAudioFile == - '${quizAudio.fileName}-${quizAudio.startTimeStamp}-${quizAudio.finishTimeStamp}'; - - return StreamBuilder( - initialData: Duration.zero, - stream: AudioService.position, - builder: (context, positionSnapshot) { - final currentPosition = isActive - ? (positionSnapshot.data ?? Duration.zero) - : Duration.zero; - final totalDuration = isActive - ? (audioHandler.duration() ?? Duration.zero) - : Duration.zero; - final hasZeroValue = totalDuration.inMilliseconds == 0 || - currentPosition.inMilliseconds == 0; - - return Column( - children: [ - LinearProgressIndicator( - backgroundColor: FccColors.gray75, - valueColor: const AlwaysStoppedAnimation( - FccColors.blue50, - ), - value: hasZeroValue - ? 0 - : currentPosition.inMilliseconds / - totalDuration.inMilliseconds, - minHeight: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_isLoading) - const Padding( - padding: EdgeInsets.all(8.0), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - else - IconButton( - onPressed: () { - if (!isActive) { - _loadAndPlay(); - } else if (playerState.playing && - playerState.processingState != - AudioProcessingState.completed) { - audioHandler.pause(); - } else if (playerState.processingState == - AudioProcessingState.completed) { - audioHandler.seek(Duration.zero); - audioHandler.play(); - } else { - audioHandler.play(); - } - }, - icon: isActive && - playerState.playing && - playerState.processingState != - AudioProcessingState.completed - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), - ), - ], - ), - ], - ); - }, - ); - }, - ), - if (widget.audioData.transcript.isNotEmpty) ...[ - const SizedBox(height: 8), - _TranscriptWidget(transcript: widget.audioData.transcript), - ], - ], - ); - } -} - -class _TranscriptWidget extends StatefulWidget { - const _TranscriptWidget({required this.transcript}); - - final List transcript; - - @override - State<_TranscriptWidget> createState() => _TranscriptWidgetState(); -} - -class _TranscriptWidgetState extends State<_TranscriptWidget> { - bool _isExpanded = false; - - @override - Widget build(BuildContext context) { - return ChallengeCard( - title: context.t.challenge_card_transcript, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () { - setState(() { - _isExpanded = !_isExpanded; - }); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.t.show_transcript, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - Icon( - _isExpanded ? Icons.expand_less : Icons.expand_more, - ), - ], - ), - ), - if (_isExpanded) ...[ - const SizedBox(height: 12), - ...widget.transcript.map((line) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: RichText( - text: TextSpan( - style: const TextStyle( - fontSize: 14, - color: FccColors.gray05, - ), - children: [ - TextSpan( - text: '${line.character}: ', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: line.text), - ], - ), - ), - ); - }), - ], - ], - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart deleted file mode 100644 index e2c6cbf19..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/quiz_audio_player.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; - -// Model that extends Question with selectedAnswer and validation status -class QuizWidgetQuestion { - final String text; - final List answers; - final int solution; - final QuizAudioData? audioData; - int selectedAnswer; - bool? isCorrect; - - QuizWidgetQuestion({ - required this.text, - required this.answers, - required this.solution, - this.audioData, - this.selectedAnswer = -1, - this.isCorrect, - }); -} - -class QuizWidget extends StatefulWidget { - final List questions; - final Function(int, int) onChanged; - final bool? isValidated; - - const QuizWidget({ - super.key, - required this.questions, - required this.onChanged, - this.isValidated, - }); - - @override - State createState() => _QuizWidgetState(); -} - -class _QuizWidgetState extends State { - late final HTMLParser parser; - late List> parsedQuestions; - late List>> parsedOptions; - - @override - void initState() { - super.initState(); - parser = HTMLParser(context: context); - _parseAll(); - } - - @override - void didUpdateWidget(covariant QuizWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (_areQuestionsChanged(oldWidget.questions, widget.questions)) { - _parseAll(); - } - } - - bool _areQuestionsChanged(List oldQuestions, - List newQuestions) { - if (oldQuestions.length != newQuestions.length) { - return true; - } - - for (int i = 0; i < oldQuestions.length; i++) { - // Only check for selectedAnswer changes as the other information is not changed between updates - if (oldQuestions[i].selectedAnswer != newQuestions[i].selectedAnswer) { - return true; - } - } - - return false; - } - - void _parseAll() { - parsedQuestions = - widget.questions.map((q) => parser.parse(q.text)).toList(); - parsedOptions = widget.questions - .map((q) => q.answers - .map((a) => parser.parse( - a.answer, - isSelectable: false, - customStyles: { - 'p': Style(margin: Margins.zero), - }, - )) - .toList()) - .toList(); - } - - @override - Widget build(BuildContext context) { - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: widget.questions.length, - itemBuilder: (context, index) { - return quizQuestion( - context: context, - questionNumber: widget.questions.length > 1 ? index + 1 : null, - questionIndex: index, - ); - }, - ); - } - - ChallengeCard quizQuestion({ - required BuildContext context, - required int questionIndex, - int? questionNumber, - }) { - final question = widget.questions[questionIndex]; - final selectedAnswer = question.selectedAnswer; - return ChallengeCard( - title: questionNumber != null - ? context.t.quiz_question_number(questionNumber) - : context.t.quiz_question, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...parsedQuestions[questionIndex], - if (question.audioData != null) ...[ - const SizedBox(height: 16), - QuizAudioPlayer(audioData: question.audioData!), - ], - const SizedBox(height: 8), - RadioGroup( - groupValue: selectedAnswer, - onChanged: (value) { - widget.onChanged(questionIndex, value ?? -1); - }, - child: Column( - children: [ - for (final answerObj in question.answers.asMap().entries) ...[ - option( - context: context, - answerObj: answerObj, - questionIndex: questionIndex, - ), - ], - ], - ), - ), - ], - ), - ); - } - - Container option({ - required BuildContext context, - required MapEntry answerObj, - required int questionIndex, - }) { - final question = widget.questions[questionIndex]; - final selectedAnswer = question.selectedAnswer; - final isCorrect = question.isCorrect; - final isSelected = answerObj.key == selectedAnswer; - final optionWidgets = parsedOptions[questionIndex][answerObj.key]; - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - child: Material( - child: RadioListTile( - key: ValueKey(answerObj.key), - selected: isSelected, - tileColor: const Color(0xFF0a0a23), - selectedTileColor: const Color(0xFF0a0a23), - value: answerObj.key, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - side: BorderSide( - color: const Color(0xFFAAAAAA), - width: 2, - ), - ), - title: Align( - alignment: Alignment.centerLeft, - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: 100, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: optionWidgets, - ), - ), - ), - subtitle: isSelected && widget.isValidated == true - ? validationStatusAndFeedback( - context: context, - isCorrect: isCorrect, - feedback: answerObj.value.feedback, - ) - : null, - ), - ), - ); - } - - Widget validationStatusAndFeedback({ - required BuildContext context, - bool? isCorrect, - String? feedback, - }) { - HTMLParser parser = HTMLParser(context: context); - final List feedbackWidgets = []; - - if (isCorrect == null) { - return const SizedBox.shrink(); - } - - feedbackWidgets.add( - Padding( - padding: EdgeInsets.only(left: 12, bottom: feedback == null ? 24 : 0), - child: Text( - isCorrect && isCorrect == true - ? context.t.quiz_correct - : context.t.quiz_incorrect, - style: TextStyle( - color: isCorrect && isCorrect == true - ? FccColors.green40 - : FccColors.red15, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ), - ); - - if (feedback != null && feedback.isNotEmpty) { - feedbackWidgets.addAll( - parser.parse( - feedback, - customStyles: { - '*:not(pre):not(code)': Style( - color: isCorrect == true ? FccColors.green40 : FccColors.red15, - ), - }, - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: feedbackWidgets, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart deleted file mode 100644 index 6596dda7a..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_view.dart +++ /dev/null @@ -1,551 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/scene/scene_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class SceneView extends StatelessWidget { - const SceneView({ - super.key, - required this.scene, - }); - - final Scene scene; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => SceneViewModel(), - onViewModelReady: (model) { - model.initPositionListener(); - model.audioService.loadCurriculumAudio(scene.setup.audio); - model.initScene(scene); - }, - onDispose: (model) => model.onDispose(), - builder: (context, model, child) { - model.preloadBackgrounds(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16), - child: Column( - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: Container( - decoration: BoxDecoration( - color: Colors.black, - border: Border.all(color: FccColors.gray75, width: 2), - ), - child: _SceneCanvas(model: model), - ), - ), - const SizedBox(height: 16), - _AudioControls(scene: scene, model: model), - ], - ), - ); - }, - ); - } -} - -void _showFullscreenOverlay(BuildContext context, SceneViewModel model) { - Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - pageBuilder: (context, animation, secondaryAnimation) { - return _FullscreenSceneOverlay(model: model); - }, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition(opacity: animation, child: child); - }, - ), - ); -} - -class _FullscreenSceneOverlay extends StatefulWidget { - const _FullscreenSceneOverlay({required this.model}); - - final SceneViewModel model; - - @override - State<_FullscreenSceneOverlay> createState() => - _FullscreenSceneOverlayState(); -} - -class _FullscreenSceneOverlayState extends State<_FullscreenSceneOverlay> { - final GlobalKey _controlsKey = GlobalKey(); - double _controlsHeight = 0; - - @override - void initState() { - super.initState(); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - WidgetsBinding.instance - .addPostFrameCallback((_) => _measureControlsHeight()); - } - - void _measureControlsHeight() { - final renderBox = - _controlsKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox != null && renderBox.hasSize) { - setState(() { - _controlsHeight = renderBox.size.height; - }); - } - } - - @override - void dispose() { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: Stack( - children: [ - Positioned.fill( - child: _SceneCanvas( - model: widget.model, - dialogueBottomPadding: _controlsHeight, - ), - ), - Positioned( - top: 16, - right: 16, - child: Container( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), - child: IconButton( - icon: const Icon(Icons.fullscreen_exit, - color: Colors.white, size: 32), - onPressed: () => Navigator.of(context).pop(), - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints(), - ), - ), - ), - Positioned( - left: 0, - right: 0, - bottom: 0, - child: _FullscreenControls( - key: _controlsKey, - model: widget.model, - ), - ), - ], - ), - ); - } -} - -class _FullscreenControls extends StatelessWidget { - const _FullscreenControls({super.key, required this.model}); - - final SceneViewModel model; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - initialData: PlaybackState(), - stream: model.audioService.playbackState, - builder: (context, snapshot) { - final playerState = snapshot.data as PlaybackState; - - return StreamBuilder( - initialData: Duration.zero, - stream: model.position.stream, - builder: (context, positionSnapshot) { - if (positionSnapshot.data == null) { - return const SizedBox.shrink(); - } - - final position = positionSnapshot.data as Duration; - final totalDuration = model.audioService.duration(); - final totalMs = totalDuration?.inMilliseconds ?? 0; - - final hasZeroValue = totalMs == 0 || position.inMilliseconds == 0; - final progress = - hasZeroValue ? 0.0 : position.inMilliseconds / totalMs; - - final isPlaying = playerState.playing && - playerState.processingState != AudioProcessingState.completed; - final isCompleted = - playerState.processingState == AudioProcessingState.completed; - - return Container( - color: Colors.black54, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - IconButton( - onPressed: () { - if (isPlaying) { - model.audioService.pause(); - } else { - model.onPlay(); - } - }, - icon: Icon( - isCompleted - ? Icons.replay - : isPlaying - ? Icons.pause - : Icons.play_arrow, - color: Colors.white, - size: 32, - ), - ), - const SizedBox(width: 12), - Expanded( - child: LinearProgressIndicator( - backgroundColor: FccColors.gray75, - valueColor: - const AlwaysStoppedAnimation(FccColors.blue50), - value: progress.clamp(0.0, 1.0), - minHeight: 6, - ), - ), - const SizedBox(width: 12), - IconButton( - onPressed: () => model.toggleCaptions(), - icon: Icon( - model.showCaptions - ? Icons.closed_caption - : Icons.closed_caption_off, - color: Colors.white, - size: 28, - ), - ), - ], - ), - ); - }, - ); - }, - ); - } -} - -class _SceneCanvas extends StatelessWidget { - const _SceneCanvas({required this.model, this.dialogueBottomPadding = 0}); - - final SceneViewModel model; - final double dialogueBottomPadding; - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: model, - builder: (context, child) { - return Stack( - children: [ - if (model.currentBackground != null) - Positioned.fill( - child: CachedNetworkImage( - imageUrl: model.getBackgroundUrl(model.currentBackground!), - fit: BoxFit.cover, - placeholder: (context, url) => - Container(color: FccColors.gray75), - errorWidget: (context, url, error) => Container( - color: FccColors.gray75, - child: const Center( - child: Icon(Icons.broken_image, size: 48), - ), - ), - ), - ), - Positioned.fill( - child: _CharactersContainer(model: model), - ), - if (model.showCaptions && model.currentDialogueText != null) - Positioned( - left: 0, - right: 0, - bottom: dialogueBottomPadding, - child: _DialogueOverlay( - character: model.currentDialogueCharacter ?? '', - text: model.currentDialogueText!, - align: model.currentDialogueAlign ?? 'left', - ), - ), - ], - ); - }, - ); - } -} - -class _DialogueOverlay extends StatelessWidget { - const _DialogueOverlay({ - required this.character, - required this.text, - required this.align, - }); - - final String character; - final String text; - final String align; - - @override - Widget build(BuildContext context) { - final crossAxisAlignment = - align == 'right' ? CrossAxisAlignment.end : CrossAxisAlignment.start; - - return Container( - color: Colors.black.withValues(alpha: 0.7), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: crossAxisAlignment, - children: [ - Text( - character, - style: const TextStyle( - color: FccColors.blue50, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - const SizedBox(height: 2), - Text( - text, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], - ), - ); - } -} - -class _CharactersContainer extends StatelessWidget { - const _CharactersContainer({ - required this.model, - }); - - final SceneViewModel model; - - @override - Widget build(BuildContext context) { - final characters = model.visibleCharacters; - - return LayoutBuilder( - builder: (context, constraints) { - final canvasWidth = constraints.maxWidth; - final canvasHeight = constraints.maxHeight; - - return Stack( - clipBehavior: Clip.hardEdge, - children: [ - for (final character in characters) - _CharacterSlot( - key: ValueKey('slot_${character.characterName}'), - state: character, - model: model, - canvasWidth: canvasWidth, - canvasHeight: canvasHeight, - ), - ], - ); - }, - ); - } -} - -class _CharacterSlot extends StatelessWidget { - const _CharacterSlot({ - super.key, - required this.state, - required this.model, - required this.canvasWidth, - required this.canvasHeight, - }); - - final CharacterState state; - final SceneViewModel model; - final double canvasWidth; - final double canvasHeight; - - @override - Widget build(BuildContext context) { - final scale = state.position.z.toDouble(); - final characterHeight = canvasHeight * scale; - final characterWidth = characterHeight * (2 / 3); - - final xPercent = state.position.x.toDouble() / 100; - final yPercent = state.position.y.toDouble() / 100; - - final leftPos = (xPercent * canvasWidth) - (characterWidth / 2); - final overflowHeight = characterHeight - canvasHeight; - final bottomPos = -(overflowHeight / 2) - (yPercent * canvasHeight); - - return AnimatedPositioned( - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - left: leftPos, - bottom: bottomPos, - child: AnimatedOpacity( - opacity: state.opacity, - duration: const Duration(milliseconds: 500), - child: SizedBox( - width: characterWidth, - height: characterHeight, - child: _CharacterSpriteBody( - characterState: state, - model: model, - ), - ), - ), - ); - } -} - -class _CharacterSpriteBody extends StatelessWidget { - const _CharacterSpriteBody({ - required this.characterState, - required this.model, - }); - - final CharacterState characterState; - final SceneViewModel model; - - Widget _buildLayer(String imageUrl) { - return Positioned.fill( - child: CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.contain, - placeholder: (context, url) => const SizedBox.expand(), - errorWidget: (context, url, error) => const SizedBox.shrink(), - ), - ); - } - - @override - Widget build(BuildContext context) { - final name = characterState.characterName; - final glassesUrl = model.getCharacterGlassesUrl(name); - - return Stack( - children: [ - Positioned.fill( - child: CachedNetworkImage( - imageUrl: model.getCharacterBaseUrl(name), - fit: BoxFit.contain, - placeholder: (context, url) => Container(color: FccColors.gray45), - errorWidget: (context, url, error) => Container( - color: FccColors.gray75, - child: const Center(child: Icon(Icons.person, size: 48)), - ), - ), - ), - _buildLayer(model.getCharacterBrowsUrl(name)), - _buildLayer(model.getCharacterEyesUrl(name, characterState.eyeState)), - if (glassesUrl != null) _buildLayer(glassesUrl), - if (characterState.showMouth) - _buildLayer( - model.getCharacterMouthUrl(name, characterState.mouthType)), - ], - ); - } -} - -class _AudioControls extends StatelessWidget { - const _AudioControls({required this.scene, required this.model}); - - final Scene scene; - final SceneViewModel model; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - initialData: PlaybackState(), - stream: model.audioService.playbackState, - builder: (context, snapshot) { - final playerState = snapshot.data as PlaybackState; - - return StreamBuilder( - initialData: Duration.zero, - stream: model.position.stream, - builder: (context, positionSnapshot) { - if (positionSnapshot.data == null) { - return const CircularProgressIndicator(); - } - - final position = positionSnapshot.data as Duration; - final totalDuration = model.audioService.duration(); - final totalMs = totalDuration?.inMilliseconds ?? 0; - - final hasZeroValue = totalMs == 0 || position.inMilliseconds == 0; - final progress = - hasZeroValue ? 0.0 : position.inMilliseconds / totalMs; - - return Column( - children: [ - LinearProgressIndicator( - backgroundColor: FccColors.gray75, - valueColor: - const AlwaysStoppedAnimation(FccColors.blue50), - value: progress.clamp(0.0, 1.0), - minHeight: 8, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: () { - if (playerState.playing && - playerState.processingState != - AudioProcessingState.completed) { - model.audioService.pause(); - } else { - model.onPlay(); - } - }, - icon: playerState.processingState == - AudioProcessingState.completed - ? const Icon(Icons.replay) - : playerState.playing - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow), - ), - IconButton( - onPressed: () => model.toggleCaptions(), - icon: Icon( - model.showCaptions - ? Icons.closed_caption - : Icons.closed_caption_off, - ), - ), - IconButton( - onPressed: () => _showFullscreenOverlay(context, model), - icon: const Icon(Icons.fullscreen), - ), - ], - ), - ], - ); - }, - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart b/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart deleted file mode 100644 index c38c44fac..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/scene/scene_viewmodel.dart +++ /dev/null @@ -1,565 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:audio_service/audio_service.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/scene_assets_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:freecodecamp/service/learn/scene_assets_service.dart'; -import 'package:stacked/stacked.dart'; - -class CharacterState { - final String characterName; - final SceneCharacterPosition position; - final bool showMouth; - final String mouthType; - final String eyeState; - final double opacity; - - CharacterState({ - required this.characterName, - required this.position, - this.showMouth = false, - this.mouthType = 'smile', - this.eyeState = 'open', - this.opacity = 1.0, - }); - - CharacterState copyWith({ - String? characterName, - SceneCharacterPosition? position, - bool? showMouth, - String? mouthType, - String? eyeState, - double? opacity, - }) { - return CharacterState( - characterName: characterName ?? this.characterName, - position: position ?? this.position, - showMouth: showMouth ?? this.showMouth, - mouthType: mouthType ?? this.mouthType, - eyeState: eyeState ?? this.eyeState, - opacity: opacity ?? this.opacity, - ); - } -} - -class SceneViewModel extends BaseViewModel { - static const String _cdnBase = - 'https://cdn.freecodecamp.org/curriculum/english/animation-assets/images'; - static const int _minMouthInterval = 85; - static const int _maxMouthInterval = 105; - static const int _minBlinkInterval = 2000; - static const int _maxBlinkInterval = 4000; - static const int _blinkDuration = 150; - - final audioService = locator().audioHandler; - final _sceneAssetsService = SceneAssetsService(); - SceneAssets? _sceneAssets; - final StreamController position = - StreamController.broadcast(); - final Map _mouthAnimationTimers = {}; - final Map _blinkTimers = {}; - final Set _appliedCommandIndices = {}; - - Scene? _scene; - String? _currentBackground; - String? get currentBackground => _currentBackground; - - List _availableCharacters = []; - List get visibleCharacters { - final sorted = List.from(_availableCharacters); - sorted.sort((a, b) => b.position.z.compareTo(a.position.z)); - return sorted; - } - - bool _isPlaying = false; - bool _isCompleted = false; - double _audioStartOffset = 0.0; - bool _hasStarted = false; - bool _backgroundsPreloaded = false; - - String? _currentDialogueCharacter; - String? _currentDialogueText; - String? _currentDialogueAlign; - String? get currentDialogueCharacter => _currentDialogueCharacter; - String? get currentDialogueText => _currentDialogueText; - String? get currentDialogueAlign => _currentDialogueAlign; - - bool _showCaptions = false; - bool get showCaptions => _showCaptions; - void toggleCaptions() { - _showCaptions = !_showCaptions; - notifyListeners(); - } - - // Helper methods for DRY code - int _findCharacterIndex(String characterName) { - return _availableCharacters - .indexWhere((c) => c.characterName == characterName); - } - - void _clearTimers(Map timers) { - for (final timer in timers.values) { - timer.cancel(); - } - timers.clear(); - } - - void _clearDialogue() { - _currentDialogueCharacter = null; - _currentDialogueText = null; - _currentDialogueAlign = null; - } - - Future onPlay() async { - if (_hasStarted && !_isCompleted) { - await audioService.play(); - return; - } - - if (_isCompleted) { - await prepareForReplay(); - } - - _hasStarted = true; - _applyInitialCommands(); - notifyListeners(); - - await startAudio(); - } - - Duration searchTimeStamp( - bool forwards, int currentPosition, EnglishScene audio) { - return Duration(milliseconds: currentPosition + (forwards ? 2000 : -2)); - } - - void initPositionListener() { - AudioService.position.listen((event) { - if (position.isClosed) return; - position.add(event); - _updateSceneForTime(event); - }); - - audioService.playbackState.listen((state) { - _isPlaying = state.playing; - - if (_isPlaying) { - _startBlinkAnimations(); - } else { - _stopAllMouthAnimations(); - _stopAllBlinkAnimations(); - } - - if (state.processingState == AudioProcessingState.completed && - !_isCompleted) { - _handleAudioComplete(); - } - }); - } - - void _handleAudioComplete() { - if (_isCompleted) return; - _isCompleted = true; - _clearDialogue(); - _stopAllMouthAnimations(); - _stopAllBlinkAnimations(); - _applyRemainingCommands(); - } - - void _applyRemainingCommands() { - if (_scene == null) return; - - for (int i = 0; i < _scene!.commands.length; i++) { - if (!_appliedCommandIndices.contains(i)) { - final command = _scene!.commands[i]; - _appliedCommandIndices.add(i); - - if (command.background != null && - _currentBackground != command.background) { - _currentBackground = command.background; - } - _updateCharacterState(command); - } - } - notifyListeners(); - } - - Future initScene(Scene scene) async { - _scene = scene; - _currentBackground = scene.setup.background; - _initializeCharactersFromSetup(); - - notifyListeners(); - - // Force another rebuild after the first frame to ensure correct layout constraints - SchedulerBinding.instance.addPostFrameCallback((_) { - notifyListeners(); - }); - - await fetchSceneAssets(); - } - - void preloadBackgrounds(BuildContext context) { - if (_scene == null || _backgroundsPreloaded) return; - _backgroundsPreloaded = true; - - final backgrounds = {_scene!.setup.background}; - for (final command in _scene!.commands) { - if (command.background != null) { - backgrounds.add(command.background!); - } - } - - for (final background in backgrounds) { - final url = getBackgroundUrl(background); - precacheImage(CachedNetworkImageProvider(url), context); - } - } - - void _initializeCharactersFromSetup() { - if (_scene == null) return; - _availableCharacters = _scene!.setup.characters - .map((char) => CharacterState( - characterName: char.character, - position: SceneCharacterPosition( - x: char.position.x, - y: char.position.y, - z: char.position.z, - ), - showMouth: true, - mouthType: 'closed', - opacity: char.opacity?.toDouble() ?? 1.0, - )) - .toList(); - } - - void _applyInitialCommands() { - if (_scene == null) return; - - // Apply all commands that happen before the audio starts - final audioStartTime = - double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; - - for (final command in _scene!.commands) { - final startTime = command.startTime.toDouble(); - // Apply commands that happen before audio starts (characters should be in position) - if (startTime < audioStartTime) { - if (command.background != null) { - _currentBackground = command.background; - } - _updateCharacterState(command); - } - } - } - - Future startAudio() async { - _isCompleted = false; - _appliedCommandIndices.clear(); - - if (_scene != null) { - final startTime = double.tryParse(_scene!.setup.audio.startTime) ?? 0.0; - _audioStartOffset = startTime; - if (startTime > 0) { - await Future.delayed( - Duration(milliseconds: (startTime * 1000).toInt())); - } - } - - await audioService.play(); - } - - Future prepareForReplay() async { - await audioService.stop(); - await audioService.seek(Duration.zero); - _reinitializeCharacters(); - } - - void _updateSceneForTime(Duration currentTime) { - if (_scene == null || !_isPlaying || _isCompleted) return; - - final currentSeconds = - (currentTime.inMilliseconds / 1000) + _audioStartOffset; - bool sceneChanged = false; - final Map charactersSpeaking = {}; - - String? activeDialogueCharacter; - String? activeDialogueText; - String? activeDialogueAlign; - - for (int i = 0; i < _scene!.commands.length; i++) { - final command = _scene!.commands[i]; - final startTime = command.startTime.toDouble(); - final finishTime = command.finishTime?.toDouble(); - - if (command.dialogue != null) { - final isSpeaking = finishTime != null - ? currentSeconds >= startTime && currentSeconds <= finishTime - : currentSeconds >= startTime; - - if (isSpeaking) { - charactersSpeaking[command.character] = true; - activeDialogueCharacter = command.character; - activeDialogueText = command.dialogue!.text; - activeDialogueAlign = command.dialogue!.align; - } - } - - if (currentSeconds >= startTime && !_appliedCommandIndices.contains(i)) { - _appliedCommandIndices.add(i); - - if (command.background != null && - _currentBackground != command.background) { - _currentBackground = command.background; - sceneChanged = true; - } - - sceneChanged |= _updateCharacterState(command); - } - } - - // Only update dialogue when there's a new active dialogue (keep last one visible) - if (activeDialogueText != null && - (_currentDialogueCharacter != activeDialogueCharacter || - _currentDialogueText != activeDialogueText)) { - _currentDialogueCharacter = activeDialogueCharacter; - _currentDialogueText = activeDialogueText; - _currentDialogueAlign = activeDialogueAlign; - sceneChanged = true; - } - - _updateMouthAnimations(charactersSpeaking); - - if (sceneChanged) notifyListeners(); - } - - bool _updateCharacterState(SceneCommand command) { - final characterIndex = _findCharacterIndex(command.character); - - if (characterIndex >= 0) { - final newPosition = command.position; - final newOpacity = command.opacity; - - if (newPosition == null && newOpacity == null) return false; - - _availableCharacters[characterIndex] = - _availableCharacters[characterIndex].copyWith( - position: newPosition, - opacity: newOpacity?.toDouble(), - ); - return true; - } else { - _availableCharacters.add(CharacterState( - characterName: command.character, - position: - command.position ?? const SceneCharacterPosition(x: 0, y: 0, z: 0), - showMouth: true, - mouthType: 'closed', - opacity: command.opacity?.toDouble() ?? 1.0, - )); - return true; - } - } - - void _updateMouthAnimations(Map charactersSpeaking) { - charactersSpeaking.keys - .where((char) => !_mouthAnimationTimers.containsKey(char)) - .forEach(_startMouthAnimation); - - _mouthAnimationTimers.keys - .where((char) => !charactersSpeaking.containsKey(char)) - .toList() - .forEach(_stopMouthAnimationForCharacter); - } - - void _startMouthAnimation(String characterName) { - final random = Random(); - - final characterIndex = _findCharacterIndex(characterName); - if (characterIndex >= 0) { - _availableCharacters[characterIndex] = - _availableCharacters[characterIndex].copyWith( - showMouth: true, - mouthType: 'open', - ); - notifyListeners(); - } - - void scheduleMouthUpdate(String nextMouthType) { - final interval = _minMouthInterval + - random.nextInt(_maxMouthInterval - _minMouthInterval + 1); - - _mouthAnimationTimers[characterName] = Timer( - Duration(milliseconds: interval), - () { - final idx = _findCharacterIndex(characterName); - - if (idx >= 0) { - _availableCharacters[idx] = _availableCharacters[idx].copyWith( - showMouth: true, - mouthType: nextMouthType, - ); - notifyListeners(); - } - - if (_mouthAnimationTimers.containsKey(characterName)) { - scheduleMouthUpdate(nextMouthType == 'open' ? 'closed' : 'open'); - } - }, - ); - } - - scheduleMouthUpdate('closed'); - } - - void _stopMouthAnimationForCharacter(String characterName) { - _mouthAnimationTimers[characterName]?.cancel(); - _mouthAnimationTimers.remove(characterName); - - final characterIndex = _findCharacterIndex(characterName); - if (characterIndex >= 0) { - _availableCharacters[characterIndex] = - _availableCharacters[characterIndex].copyWith( - showMouth: true, - mouthType: 'closed', - ); - notifyListeners(); - } - } - - void _stopAllMouthAnimations() { - _clearTimers(_mouthAnimationTimers); - _updateAllCharacters(showMouth: true, mouthType: 'closed'); - } - - void _startBlinkAnimations() { - for (final character in _availableCharacters) { - _startBlinkAnimation(character.characterName); - } - } - - void _startBlinkAnimation(String characterName) { - if (_blinkTimers.containsKey(characterName)) return; - - final random = Random(); - - void scheduleNextBlink() { - final interval = _minBlinkInterval + - random.nextInt(_maxBlinkInterval - _minBlinkInterval + 1); - - _blinkTimers[characterName] = Timer( - Duration(milliseconds: interval), - () { - final characterIndex = _findCharacterIndex(characterName); - - if (characterIndex >= 0) { - // Close eyes - _availableCharacters[characterIndex] = - _availableCharacters[characterIndex] - .copyWith(eyeState: 'closed'); - notifyListeners(); - - // Open eyes after blink duration - Future.delayed(Duration(milliseconds: _blinkDuration), () { - final idx = _findCharacterIndex(characterName); - if (idx >= 0 && _blinkTimers.containsKey(characterName)) { - _availableCharacters[idx] = - _availableCharacters[idx].copyWith(eyeState: 'open'); - notifyListeners(); - } - }); - } - - if (_blinkTimers.containsKey(characterName)) { - scheduleNextBlink(); - } - }, - ); - } - - scheduleNextBlink(); - } - - void _stopAllBlinkAnimations() { - _clearTimers(_blinkTimers); - } - - void _reinitializeCharacters() { - _clearDialogue(); - _stopAllBlinkAnimations(); - _initializeCharactersFromSetup(); - notifyListeners(); - } - - void _updateAllCharacters({bool? showMouth, String? mouthType}) { - for (int i = 0; i < _availableCharacters.length; i++) { - _availableCharacters[i] = _availableCharacters[i].copyWith( - showMouth: showMouth, - mouthType: mouthType, - ); - } - notifyListeners(); - } - - Future fetchSceneAssets() async { - _sceneAssets = await _sceneAssetsService.fetchSceneAssets(); - } - - CharacterAssets? _getCharacterAssets(String characterName) { - return _sceneAssets?.characterAssets[characterName]; - } - - String _buildFallbackUrl(String characterName, String asset) { - return '$_cdnBase/characters/${characterName.toLowerCase()}/$asset'; - } - - String getBackgroundUrl(String backgroundName) { - final cleanName = backgroundName.endsWith('.png') - ? backgroundName - : '$backgroundName.png'; - if (_sceneAssets != null) { - return '${_sceneAssets!.backgrounds}/$cleanName'; - } - return '$_cdnBase/backgrounds/$cleanName'; - } - - String getCharacterBaseUrl(String characterName) => - _getCharacterAssets(characterName)?.base ?? - _buildFallbackUrl(characterName, 'base.png'); - - String getCharacterBrowsUrl(String characterName) => - _getCharacterAssets(characterName)?.brows ?? - _buildFallbackUrl(characterName, 'brows.png'); - - String getCharacterEyesUrl(String characterName, String eyeState) { - final assets = _getCharacterAssets(characterName); - if (assets != null) { - return eyeState == 'closed' ? assets.eyesClosed : assets.eyesOpen; - } - return _buildFallbackUrl(characterName, - eyeState == 'closed' ? 'eyes-closed.png' : 'eyes-open.png'); - } - - String? getCharacterGlassesUrl(String characterName) => - _getCharacterAssets(characterName)?.glasses; - - String getCharacterMouthUrl(String characterName, String mouthType) { - final assets = _getCharacterAssets(characterName); - if (assets != null) { - return mouthType == 'open' ? assets.mouthOpen : assets.mouthClosed; - } - return _buildFallbackUrl(characterName, - mouthType == 'open' ? 'mouth-open.png' : 'mouth-closed.png'); - } - - void onDispose() { - position.close(); - _clearTimers(_mouthAnimationTimers); - _clearTimers(_blinkTimers); - audioService.stop(); - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart b/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart deleted file mode 100644 index f86ab9b3d..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; - -class Transcript extends StatelessWidget { - const Transcript({ - super.key, - required this.transcript, - this.isCollapsible = true, - }); - - final String transcript; - final bool isCollapsible; - - @override - Widget build(BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - - if (isCollapsible) { - return ExpansionTile( - backgroundColor: Colors.transparent, - collapsedBackgroundColor: Colors.transparent, - title: Text(context.t.tap_to_expand), - shape: const RoundedRectangleBorder( - side: BorderSide.none, - borderRadius: BorderRadius.zero, - ), - collapsedShape: const RoundedRectangleBorder( - side: BorderSide.none, - borderRadius: BorderRadius.zero, - ), - children: [ - ...parser.parse(transcript), - ], - ); - } else { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...parser.parse(transcript), - ], - ); - } - } -} diff --git a/mobile-app/lib/ui/views/learn/widgets/youtube_player_widget.dart b/mobile-app/lib/ui/views/learn/widgets/youtube_player_widget.dart deleted file mode 100644 index 8fbe8d100..000000000 --- a/mobile-app/lib/ui/views/learn/widgets/youtube_player_widget.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:youtube_player_iframe/youtube_player_iframe.dart'; - -class YoutubePlayerWidget extends StatelessWidget { - final String videoId; - const YoutubePlayerWidget({super.key, required this.videoId}); - - @override - Widget build(BuildContext context) { - final controller = YoutubePlayerController.fromVideoId( - videoId: videoId, - autoPlay: false, - params: const YoutubePlayerParams( - showControls: true, - showFullscreenButton: true, - strictRelatedVideos: true, - origin: 'https://www.youtube-nocookie.com', - ), - ); - - controller.setFullScreenListener( - (_) async { - final videoData = await controller.videoData; - final startSeconds = await controller.currentTime; - final currentTime = await FullscreenYoutubePlayer.launch( - context, - videoId: videoData.videoId, - startSeconds: startSeconds, - ); - if (currentTime != null) { - controller.seekTo(seconds: currentTime); - } - }, - ); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: YoutubePlayer( - controller: controller, - enableFullScreenOnVerticalDrag: false, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/login/native_login_view.dart b/mobile-app/lib/ui/views/login/native_login_view.dart deleted file mode 100644 index 737e19ff3..000000000 --- a/mobile-app/lib/ui/views/login/native_login_view.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/login/native_login_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class NativeLoginView extends StatelessWidget { - const NativeLoginView({super.key, this.fromButton = false}); - - final bool fromButton; - - @override - Widget build(BuildContext context) { - ButtonStyle buttonStyle = ElevatedButton.styleFrom( - backgroundColor: Colors.white, - disabledBackgroundColor: Colors.grey, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - side: const BorderSide( - width: 3, - color: Colors.black, - ), - ); - - TextStyle textStyle = const TextStyle( - fontSize: 20, - color: Colors.black, - ); - - ButtonStyle ctaButtonStyle = ElevatedButton.styleFrom( - backgroundColor: FccSemanticColors.backgroundCta, - foregroundColor: FccSemanticColors.foregroundCta, - disabledBackgroundColor: - Color(0xCCFFC300), // backgroundCta with 80% opacity - disabledForegroundColor: FccSemanticColors.foregroundCta, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => NativeLoginViewModel(), - onViewModelReady: (viewModel) => viewModel.init(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: Text(context.t.login), - ), - drawer: fromButton ? null : const DrawerWidgetView(), - body: SingleChildScrollView( - child: Container( - margin: const EdgeInsets.only(top: 16), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Container( - constraints: const BoxConstraints(minHeight: 50), - margin: const EdgeInsets.all(16), - child: ElevatedButton( - style: buttonStyle, - onPressed: () { - model.auth.login(context, 'google-oauth2'); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Image.asset( - 'assets/images/google-logo.png', - height: 25, - width: 25, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_with_google, - style: textStyle, - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: Container( - constraints: const BoxConstraints(minHeight: 50), - margin: const EdgeInsets.all(16), - child: ElevatedButton( - style: buttonStyle, - onPressed: () { - model.auth.login(context, 'github'); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Image.asset( - 'assets/images/github-logo.png', - height: 25, - width: 25, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_with_github, - style: textStyle, - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - Row( - children: [ - Expanded( - child: Container( - margin: const EdgeInsets.all(16), - constraints: const BoxConstraints(minHeight: 50), - child: ElevatedButton( - style: buttonStyle, - onPressed: () { - model.auth.login(context, 'apple'); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Image.asset( - 'assets/images/apple-logo.png', - height: 25, - width: 25, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_with_apple, - style: textStyle, - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - buildDivider(), - Container( - padding: const EdgeInsets.all(16), - child: TextFormField( - enabled: !model.showOTPfield, - controller: model.emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - hintText: context.t.email, - border: const OutlineInputBorder( - borderSide: BorderSide(width: 2, color: Colors.white), - ), - ), - ), - ), - model.showOTPfield - ? Container( - padding: const EdgeInsets.all(16), - child: TextFormField( - autofocus: model.showOTPfield, - controller: model.otpController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - hintText: context.t.email_enter_sign_in_code, - errorText: model.incorrectOTP - ? context.t.email_invalid_code - : null, - errorMaxLines: 5, - border: const OutlineInputBorder( - borderSide: - BorderSide(width: 2, color: Colors.white), - ), - ), - ), - ) - : Container(), - Row( - children: [ - if (model.showOTPfield) - Expanded( - child: Container( - margin: const EdgeInsets.all(16), - child: ElevatedButton( - style: ctaButtonStyle.copyWith( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(vertical: 8), - ), - ), - onPressed: model.otpFieldIsValid - ? () { - model.verifyOTP(context); - } - : null, - child: Text( - context.t.email_submit_code, - style: TextStyle( - fontSize: 20, - ), - textAlign: TextAlign.center, - ), - ), - ), - ) - else - Expanded( - child: Container( - margin: const EdgeInsets.all(16), - constraints: const BoxConstraints(minHeight: 50), - child: ElevatedButton( - style: ctaButtonStyle, - onPressed: model.emailFieldIsValid - ? () { - model.sendOTPtoEmail(); - } - : null, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Align( - child: Text( - context.t.email_submit_code, - style: TextStyle( - fontSize: 20, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ), - ), - ], - ), - buildDivider(), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_data_message, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.87), - ), - ), - ), - ) - ], - ), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - context.t.login_age_message, - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.87), - ), - ), - ), - ) - ], - ), - Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: Colors.white.withValues(alpha: 0.87), - ), - children: _buildLoginTermsSpans(context), - ), - ), - )), - ], - ) - ], - ), - ), - ), - ), - ); - } - - List _buildLoginTermsSpans(BuildContext context) { - final linkStyle = const TextStyle( - decoration: TextDecoration.underline, - ); - final termsOfService = context.t.terms_of_service; - final privacyPolicy = context.t.privacy_policy; - final message = context.t.login_terms(termsOfService, privacyPolicy); - final links = [ - _LoginTermsLink( - label: termsOfService, - url: 'https://www.freecodecamp.org/news/terms-of-service', - ), - _LoginTermsLink( - label: privacyPolicy, - url: 'https://www.freecodecamp.org/news/privacy-policy', - ), - ]; - - final orderedLinks = links - .map((link) => MapEntry(message.indexOf(link.label), link)) - .where((entry) => entry.key >= 0) - .toList() - ..sort((a, b) => a.key.compareTo(b.key)); - - if (orderedLinks.length != links.length) { - return [TextSpan(text: message)]; - } - - final spans = []; - var cursor = 0; - - for (final entry in orderedLinks) { - final linkStart = entry.key; - final link = entry.value; - - if (linkStart > cursor) { - spans.add(TextSpan(text: message.substring(cursor, linkStart))); - } - - spans.add( - TextSpan( - text: link.label, - style: linkStyle, - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl(Uri.parse(link.url)); - }, - ), - ); - cursor = linkStart + link.label.length; - } - - if (cursor < message.length) { - spans.add(TextSpan(text: message.substring(cursor))); - } - - return spans; - } -} - -class _LoginTermsLink { - const _LoginTermsLink({ - required this.label, - required this.url, - }); - - final String label; - final String url; -} diff --git a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart b/mobile-app/lib/ui/views/login/native_login_viewmodel.dart deleted file mode 100644 index 2fc8dc7d3..000000000 --- a/mobile-app/lib/ui/views/login/native_login_viewmodel.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:stacked/stacked.dart'; - -class NativeLoginViewModel extends BaseViewModel { - TextEditingController emailController = TextEditingController(); - TextEditingController otpController = TextEditingController(); - bool showOTPfield = false; - bool incorrectOTP = false; - final Dio _dio = DioService.dio; - - final AuthenticationService auth = locator(); - final DeveloperService developerService = locator(); - - bool _emailFieldIsValid = false; - bool get emailFieldIsValid => _emailFieldIsValid; - - bool _otpFieldIsValid = false; - bool get otpFieldIsValid => _otpFieldIsValid; - - set emailFieldIsValid(bool value) { - _emailFieldIsValid = value; - notifyListeners(); - } - - set otpFieldIsValid(bool value) { - _otpFieldIsValid = value; - notifyListeners(); - } - - void init() async { - bool isEmail(String em) { - String p = - 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,}))$'; - - RegExp regExp = RegExp(p); - - return regExp.hasMatch(em); - } - - emailController.addListener(() { - if (isEmail(emailController.text)) { - emailFieldIsValid = true; - } else if (emailFieldIsValid) { - emailFieldIsValid = false; - } - }); - - otpController.addListener(() { - if (RegExp(r'^[0-9]{6}$').hasMatch(otpController.text)) { - otpFieldIsValid = true; - } else if (emailFieldIsValid) { - otpFieldIsValid = false; - } - }); - } - - void sendOTPtoEmail() async { - showOTPfield = true; - notifyListeners(); - await dotenv.load(); - await _dio.post( - 'https://${dotenv.get('AUTH0_DOMAIN')}/passwordless/start', - data: { - 'client_id': dotenv.get('AUTH0_CLIENT_ID'), - 'connection': 'email', - 'email': emailController.text, - 'send': 'code', - }, - ); - } - - void verifyOTP(BuildContext context) async { - await dotenv.load(); - bool isSuccess = await auth.login( - context, - 'email', - email: emailController.text, - otp: otpController.text, - ); - if (isSuccess) { - incorrectOTP = false; - } else { - incorrectOTP = true; - } - notifyListeners(); - } -} diff --git a/mobile-app/lib/ui/views/news/news-author/news_author_view.dart b/mobile-app/lib/ui/views/news/news-author/news_author_view.dart deleted file mode 100644 index 954ff651b..000000000 --- a/mobile-app/lib/ui/views/news/news-author/news_author_view.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/ui/views/news/news-author/news_author_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/widgets/tutorial_list_widget.dart'; -import 'package:stacked/stacked.dart'; - -class NewsAuthorView extends StatelessWidget { - const NewsAuthorView({ - super.key, - required this.authorSlug, - }); - - final String authorSlug; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsAuthorViewModel(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: Text(context.t.tutorial_author_title), - ), - body: SingleChildScrollView( - child: FutureBuilder( - future: model.fetchAuthor(authorSlug), - builder: (context, snapshot) { - if (snapshot.hasData) { - Author? author = snapshot.data; - - return view(model, context, author); - } - - return const Center(child: CircularProgressIndicator()); - }, - ), - ), - ), - ); - } - - Column view(NewsAuthorViewModel model, BuildContext ctxt, Author? author) { - return Column( - children: [ - Stack( - children: [ - Padding( - padding: const EdgeInsets.only(top: 48), - child: Align( - alignment: Alignment.center, - child: Container( - width: 175, - height: 175, - color: const Color(0xFF0a0a23), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 48.0), - child: profilePicture(author), - ) - ], - ), - Text( - author!.name, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 28, - height: 2, - ), - ), - if (author.location != null) - Text( - author.location as String, - style: const TextStyle(fontSize: 16), - ), - if (author.bio != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - author.bio as String, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - ), - TutorialList( - authorSlug: author.slug, - authorName: author.name, - authorId: author.id, - ), - ], - ); - } - - Align profilePicture(Author? author) { - return Align( - alignment: Alignment.center, - child: Container( - width: 175, - height: 175, - decoration: BoxDecoration( - border: Border.all( - width: 2, - color: Colors.white, - ), - ), - child: author?.profileImage == null - ? Image.asset( - 'assets/images/placeholder-profile-img.png', - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: author!.profileImage as String, - errorWidget: (context, url, error) => Image.asset( - 'assets/images/placeholder-profile-img.png', - width: 45, - height: 45, - fit: BoxFit.cover, - ), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-author/news_author_viewmodel.dart b/mobile-app/lib/ui/views/news/news-author/news_author_viewmodel.dart deleted file mode 100644 index 69f786b66..000000000 --- a/mobile-app/lib/ui/views/news/news-author/news_author_viewmodel.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/api_service.dart'; -import 'package:stacked/stacked.dart'; - -class NewsAuthorViewModel extends BaseViewModel { - final _newsApiService = locator(); - - Future fetchAuthor(String authorSlug) async { - await dotenv.load(fileName: '.env'); - - final authorData = await _newsApiService.getAuthor(authorSlug); - - return Author.toAuthorFromJson(authorData); - } -} diff --git a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart deleted file mode 100644 index bc89cdf76..000000000 --- a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class NewsBookmarkFeedView extends StatelessWidget { - const NewsBookmarkFeedView({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsBookmarkViewModel(), - onViewModelReady: (model) async { - await model.initDB(); - model.hasBookmarkedTutorials(); - model.updateListView(); - }, - builder: (context, model, child) => Scaffold( - backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - body: RefreshIndicator( - backgroundColor: const Color(0xFF0a0a23), - color: Colors.white, - onRefresh: () { - return model.refresh(); - }, - child: model.userHasBookmarkedTutorials - ? populateListViewModel(model) - : Center( - child: Text( - context.t.tutorial_no_bookmarks, - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } -} - -ListView populateListViewModel(NewsBookmarkViewModel model) { - return ListView.separated( - itemCount: model.count, - separatorBuilder: (BuildContext context, int i) => const Divider( - color: Colors.white, - ), - itemBuilder: (context, index) { - var tutorial = model.bookMarkedTutorials[index]; - - return ListTile( - key: Key('bookmark_tutorial_$index'), - title: Text(tutorial.tutorialTitle), - trailing: const Icon(Icons.arrow_forward_ios_sharp), - subtitle: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text(context.t.tutorial_written_by_colon(tutorial.authorName)), - ), - onTap: () { - model.routeToBookmarkedTutorial(tutorial); - }, - contentPadding: const EdgeInsets.all(16), - minVerticalPadding: 8, - ); - }, - ); -} diff --git a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_view.dart b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_view.dart deleted file mode 100644 index 7d3f8dade..000000000 --- a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_view.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart'; -import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/widgets/back_to_top_button.dart'; -import 'package:stacked/stacked.dart'; - -class NewsBookmarkTutorialView extends StatelessWidget { - final BookmarkedTutorial tutorial; - - const NewsBookmarkTutorialView({ - super.key, - required this.tutorial, - }); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsBookmarkViewModel(), - onViewModelReady: (model) async { - await model.initDB(); - model.isTutorialBookmarked(tutorial); - model.goToTopButtonHandler(); - }, - onDispose: (model) => model.updateListView(), - builder: (context, model, child) => Scaffold( - backgroundColor: const Color(0xFF0a0a23), - body: SafeArea( - child: CustomScrollView( - controller: model.scrollController, - slivers: [ - SliverAppBar( - title: Text( - context.t.tutorial_bookmark_title, - ), - ), - SliverAppBar( - pinned: true, - backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - automaticallyImplyLeading: false, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(8), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: ElevatedButton.icon( - onPressed: () { - model.bookmarkAndUnbookmark(tutorial); - }, - icon: model.bookmarked - ? const Icon(Icons.bookmark_added) - : const Icon(Icons.bookmark_add_outlined), - label: Text( - model.bookmarked - ? context.t.tutorial_bookmarked - : context.t.tutorial_bookmark, - ), - ), - ), - ), - ], - ), - ), - ), - lazyLoadHtml(context, tutorial) - ], - ), - ), - floatingActionButton: model.gotoTopButtonVisible - ? BackToTopButton( - onPressed: () => model.goToTop(), - ) - : null, - floatingActionButtonAnimator: null, - ), - ); - } - - SliverList lazyLoadHtml( - BuildContext context, - BookmarkedTutorial tutorial, - ) { - NewsTutorialViewModel localModel = NewsTutorialViewModel(); - var htmlToList = localModel.initLazyLoading( - tutorial.tutorialText, - context, - tutorial, - ); - - return SliverList( - delegate: SliverChildBuilderDelegate(((context, index) { - return Row( - children: [ - Expanded( - child: htmlToList[index], - ), - ], - ); - }), childCount: htmlToList.length), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart deleted file mode 100644 index 8cc20f514..000000000 --- a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart'; -import 'package:freecodecamp/service/news/bookmark_service.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class NewsBookmarkViewModel extends BaseViewModel { - bool _isBookmarked = false; - bool get bookmarked => _isBookmarked; - - bool _gotoTopButtonVisible = false; - bool get gotoTopButtonVisible => _gotoTopButtonVisible; - - bool _userHasBookmarkedTutorials = false; - bool get userHasBookmarkedTutorials => _userHasBookmarkedTutorials; - - late List _tutorials; - List get bookMarkedTutorials => _tutorials; - - int _count = 0; - int get count => _count; - - final _navigationService = locator(); - final _databaseService = locator(); - - ScrollController scrollController = ScrollController(); - - Future initDB() async { - await _databaseService.initialise(); - } - - Future bookmarkAndUnbookmark(dynamic tutorial) async { - if (_isBookmarked) { - _isBookmarked = false; - await _databaseService.removeBookmark(tutorial); - notifyListeners(); - } else { - _isBookmarked = true; - await insertTutorial(tutorial); - notifyListeners(); - } - } - - Future updateListView() async { - _tutorials = await _databaseService.getBookmarks(); - _count = _tutorials.length; - notifyListeners(); - } - - Future refresh() async { - await updateListView(); - await hasBookmarkedTutorials(); - } - - Future goToTop() async { - await scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - - Future goToTopButtonHandler() async { - scrollController.addListener(() { - if (scrollController.offset >= 100) { - if (!_gotoTopButtonVisible) { - _gotoTopButtonVisible = true; - notifyListeners(); - } - } else { - if (_gotoTopButtonVisible) { - _gotoTopButtonVisible = false; - notifyListeners(); - } - } - }); - } - - Future insertTutorial(dynamic tutorial) async { - bool isInDatabase = await _databaseService.isBookmarked(tutorial); - - if (isInDatabase == false) { - await _databaseService.addBookmark(tutorial); - } - } - - Future isTutorialBookmarked(dynamic tutorial) async { - _isBookmarked = await _databaseService.isBookmarked(tutorial); - notifyListeners(); - } - - Future hasBookmarkedTutorials() async { - var isInDatabase = await _databaseService.getBookmarks(); - - if (isInDatabase.isNotEmpty) { - _userHasBookmarkedTutorials = true; - notifyListeners(); - } else { - _userHasBookmarkedTutorials = false; - notifyListeners(); - } - } - - void routeToBookmarkedTutorial(BookmarkedTutorial tutorial) { - _navigationService.navigateTo( - Routes.newsBookmarkTutorialView, - arguments: NewsBookmarkTutorialViewArguments( - tutorial: tutorial, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_widget.dart b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_widget.dart deleted file mode 100644 index 8810f63db..000000000 --- a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_widget.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/news-tutorial/news_tutorial_view.dart'; -import 'package:stacked/stacked.dart'; - -class NewsBookmarkViewWidget extends StatelessWidget { - const NewsBookmarkViewWidget({ - super.key, - required this.tutorial, - }); - - final dynamic tutorial; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsBookmarkViewModel(), - onViewModelReady: (model) async { - await model.initDB(); - model.isTutorialBookmarked(tutorial); - }, - builder: (context, model, child) => BottomButton( - key: const Key('bookmark_btn'), - label: model.bookmarked - ? context.t.tutorial_bookmarked - : context.t.tutorial_bookmark, - icon: model.bookmarked - ? Icons.bookmark_added - : Icons.bookmark_add_outlined, - onPressed: () { - model.bookmarkAndUnbookmark(tutorial); - }, - rightSided: false, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-feed/news_feed_view.dart b/mobile-app/lib/ui/views/news/news-feed/news_feed_view.dart deleted file mode 100644 index e3eedc8ef..000000000 --- a/mobile-app/lib/ui/views/news/news-feed/news_feed_view.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'dart:developer'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/ui/views/news/news-feed/news_feed_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/widgets/tag_widget.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:stacked/stacked.dart'; - -class NewsFeedView extends StatelessWidget { - const NewsFeedView({ - super.key, - this.tagSlug = '', - this.authorId = '', - this.fromAuthor = false, - this.fromTag = false, - this.fromSearch = false, - this.tutorials = const [], - this.subject = '', - }); - - final String subject; - final String tagSlug; - final String authorId; - - final List tutorials; - - final bool fromTag; - final bool fromAuthor; - final bool fromSearch; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsFeedViewModel(), - onViewModelReady: (model) => model.initState(tagSlug, authorId), - builder: (context, model, child) => Scaffold( - appBar: fromTag || fromAuthor || fromSearch - ? AppBar( - title: fromAuthor - ? Text( - context.t.tutorials_from(subject), - ) - : Text( - context.t.tutorials_about(subject), - ), - ) - : null, - backgroundColor: const Color(0xFF0a0a23), - // body: FutureBuilder( - // future: !model.devmode - // ? !fromSearch - // ? model.fetchTutorials(slug, author) - // : model.returnTutorialsFromSearch(tutorials) - // : model.readFromFiles(), - // builder: (context, snapshot) { - // if (snapshot.hasData) { - // return RefreshIndicator( - // backgroundColor: const Color(0xFF0a0a23), - // color: Colors.white, - // child: tutorialThumbnailBuilder(model), - // onRefresh: () { - // return model.refresh(); - // }, - // ); - // } else if (snapshot.hasError) { - // return errorMessage(context); - // } - // return const Center(child: CircularProgressIndicator()); - // }, - // ), - body: RefreshIndicator( - onRefresh: () => Future.sync(() => model.pagingController.refresh()), - backgroundColor: const Color(0xFF0a0a23), - color: Colors.white, - // child: tutorialThumbnailBuilder(model), - child: PagingListener( - controller: model.pagingController, - builder: (context, state, fetchNextPage) => PagedListView.separated( - state: state, - fetchNextPage: fetchNextPage, - separatorBuilder: (context, int i) => const Divider( - color: Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - thickness: 1, - height: 1, - ), - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, tutorial, index) => Container( - key: Key('news-tutorial-$index'), - child: tutorialThumbnailBuilder(tutorial, model), - ), - ), - ), - ), - ), - ), - ); - } - - // Column errorMessage(BuildContext context) { - // return Column( - // mainAxisAlignment: MainAxisAlignment.center, - // crossAxisAlignment: CrossAxisAlignment.stretch, - // children: [ - // Text( - // context.t.tutorial_load_error, - // textAlign: TextAlign.center, - // ), - // Padding( - // padding: const EdgeInsets.all(8.0), - // child: InkWell( - // child: Text( - // context.t.tutorial_read_online, - // textAlign: TextAlign.center, - // style: const TextStyle( - // color: Color.fromRGBO(0x99, 0xc9, 0xff, 1), - // ), - // ), - // onTap: () { - // launchUrl(Uri.parse('https://www.freecodecamp.org/news/')); - // }, - // ), - // ), - // ], - // ); - // } - - Widget tutorialThumbnailBuilder(Tutorial tutorial, NewsFeedViewModel model) { - return InkWell( - key: Key(tutorial.id), - splashColor: Colors.transparent, - onTap: () { - model.navigateTo(tutorial.id, tutorial.slug); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Container( - color: const Color(0xFF2A2A40), - child: tutorial.featureImage == null - ? Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: tutorial.featureImage!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: const Color(0xFF2A2A40), - ), - errorWidget: (context, url, error) { - log('Error loading image: $url - ${tutorial.featureImage} $error'); - return Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ); - }, - ), - ), - ), - ), - const SizedBox(height: 8), - if (tutorial.rawTags.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Wrap( - spacing: 0, - runSpacing: 4, - children: [ - for (int j = 0; j < tutorial.rawTags.length && j < 3; j++) - TagButton( - tagName: tutorial.rawTags[j]['name'], - tagSlug: tutorial.rawTags[j]['slug'] ?? tutorial.rawTags[j]['id'], - compact: true, - key: UniqueKey(), - ), - ], - ), - ), - Text( - tutorial.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.25, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - GestureDetector( - onTap: () => model.navigateToAuthor(tutorial.authorSlug), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - width: 24, - height: 24, - child: tutorial.profileImage == null - ? Image.asset( - 'assets/images/placeholder-profile-img.png', - fit: BoxFit.cover, - ) - : CachedNetworkImage( - imageUrl: tutorial.profileImage!, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: const Color(0xFF2A2A40), - ), - errorWidget: (context, url, error) => Image.asset( - 'assets/images/placeholder-profile-img.png', - fit: BoxFit.cover, - ), - ), - ), - ), - ), - const SizedBox(width: 8), - Flexible( - child: GestureDetector( - onTap: () => model.navigateToAuthor(tutorial.authorSlug), - child: Text( - tutorial.authorName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.8), - ), - ), - ), - ), - Text( - ' • ', - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.5), - ), - ), - Text( - NewsFeedViewModel.parseDate(tutorial.createdAt), - style: TextStyle( - fontSize: 13, - color: Colors.white.withValues(alpha: 0.5), - ), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-feed/news_feed_viewmodel.dart b/mobile-app/lib/ui/views/news/news-feed/news_feed_viewmodel.dart deleted file mode 100644 index 41ed6f330..000000000 --- a/mobile-app/lib/ui/views/news/news-feed/news_feed_viewmodel.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/constants/radio_articles.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/api_service.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class NewsFeedViewModel extends BaseViewModel { - final _navigationService = locator(); - final _newsApiService = locator(); - - String nextPageKey = ''; - - late final PagingController _pagingController; - PagingController get pagingController => _pagingController; - - void initState(String tagSlug, String authorId) { - _pagingController = PagingController( - getNextPageKey: (state) => (state.pages?.isEmpty ?? true) ? '' : nextPageKey, - fetchPage: (pageKey) => - fetchTutorials(pageKey, tagSlug: tagSlug, authorId: authorId), - ); - } - - void navigateTo(String id, String slug) { - _navigationService.navigateTo( - Routes.newsTutorialView, - arguments: NewsTutorialViewArguments(refId: id, slug: slug), - ); - } - - void navigateToAuthor(String authorSlug) { - _navigationService.navigateTo( - Routes.newsAuthorView, - arguments: NewsAuthorViewArguments(authorSlug: authorSlug), - ); - } - - // TODO: Move to utils post-migration - static String parseDate(date) { - Jiffy jiffyDate = Jiffy.parseFromDateTime(DateTime.parse(date)); - String calcTimeSince = Jiffy.parseFromJiffy(jiffyDate).fromNow(); - - return calcTimeSince.toUpperCase(); - } - - // TODO: Add dev mode post-migration - // Future> readFromFiles() async { - // String json = await rootBundle.loadString( - // 'assets/test_data/news_feed.json', - // ); - // var decodedJson = jsonDecode(json)['posts']; - // for (int i = 0; i < decodedJson.length; i++) { - // tutorials.add(Tutorial.fromJson(decodedJson[i])); - // } - // return tutorials; - // } - - Future> fetchTutorials( - String afterCursor, { - String? tagSlug, - String? authorId, - }) async { - await dotenv.load(fileName: '.env'); - - final List tutorials = []; - late final ApiData data; - - if (tagSlug != null && tagSlug != '') { - data = await _newsApiService.getPostsByTag( - tagSlug, - afterCursor: afterCursor, - ); - } else if (authorId != null && authorId != '') { - data = await _newsApiService.getPostsByAuthor( - authorId, - afterCursor: afterCursor, - ); - } else { - data = await _newsApiService.getAllPosts(afterCursor: afterCursor); - } - - final tutorialJson = data.posts; - for (int i = 0; i < tutorialJson.length; i++) { - if (Platform.isIOS && radioArticles.contains(tutorialJson[i]['id'])) { - continue; - } - tutorials.add(Tutorial.fromJson(tutorialJson[i]['node'])); - } - if (data.hasNextPage) { - nextPageKey = data.endCursor; - } else { - _pagingController.value = _pagingController.value.copyWith( - hasNextPage: false, - ); - } - return tutorials; - } - - // TODO: Add search results feed back post-migration - // final List tutorials = []; - // Future> returnTutorialsFromSearch( - // List searchTutorials, - // ) async { - // for (int i = 0; i < searchTutorials.length; i++) { - // tutorials.add(Tutorial.fromSearch(searchTutorials[i])); - // } - // return tutorials; - // } - - @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } -} diff --git a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart b/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart deleted file mode 100644 index 8337d18a9..000000000 --- a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/news-image-viewer/news_image_viewmodel.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stacked/stacked.dart'; - -class NewsImageView extends StatelessWidget { - const NewsImageView({ - super.key, - required this.imgUrl, - required this.isDataUrl, - }); - - final String imgUrl; - final bool isDataUrl; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.nonReactive( - viewModelBuilder: () => NewsImageModel(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - leading: Tooltip( - message: context.t.back, - child: InkWell( - onTap: () { - Navigator.pop(context); - }, - child: const Icon(Icons.arrow_back), - ), - ), - ), - body: PhotoView( - backgroundDecoration: const BoxDecoration( - color: Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - backgroundBlendMode: BlendMode.color, - ), - imageProvider: isDataUrl - ? MemoryImage(base64Decode(imgUrl.split(',').last)) - : NetworkImage(imgUrl) as ImageProvider, - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_viewmodel.dart b/mobile-app/lib/ui/views/news/news-image-viewer/news_image_viewmodel.dart deleted file mode 100644 index 39a91024f..000000000 --- a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_viewmodel.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class NewsImageModel extends BaseViewModel {} diff --git a/mobile-app/lib/ui/views/news/news-search/news_search_view.dart b/mobile-app/lib/ui/views/news/news-search/news_search_view.dart deleted file mode 100644 index 50c1778ea..000000000 --- a/mobile-app/lib/ui/views/news/news-search/news_search_view.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/news-feed/news_feed_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/news-search/news_search_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class NewsSearchView extends StatelessWidget { - const NewsSearchView({super.key}); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => NewsSearchModel(), - onViewModelReady: (model) => model.init(), - onDispose: (model) => model.onDispose(), - builder: (context, model, child) { - // final isSearchable = model.searchbarController.text != '' && - // !model.isLoading && - // model.hasData; - - return Scaffold( - appBar: AppBar( - titleSpacing: 0, - title: TextField( - controller: model.searchbarController, - decoration: InputDecoration( - hintText: context.t.tutorial_search_placeholder, - fillColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - border: InputBorder.none, - filled: true, - ), - onChanged: (value) { - model.setSearchTerm(value); - model.setHasData = false; - model.setIsLoading = true; - }, - // onSubmitted: isSearchable - // ? (value) { - // model.searchSubject(); - // } - // : (value) {}, - ), - // actions: [ - // Container( - // margin: const EdgeInsets.only(left: 8), - // color: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - // child: IconButton( - // onPressed: isSearchable - // ? () { - // model.searchSubject(); - // } - // : null, - // icon: const Icon( - // Icons.search_sharp, - // ), - // ), - // ) - // ], - ), - body: StreamBuilder( - stream: model.algolia.responses, - builder: (context, snapshot) { - if (snapshot.hasError) { - return const Center( - child: Text( - 'There was an error loading tutorials \n please try again', - textAlign: TextAlign.center, - ), - ); - } - - if (snapshot.connectionState == ConnectionState.waiting || - model.isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (!snapshot.hasData) { - return Center( - child: Text( - context.t.tutorial_search_no_results, - ), - ); - } - - final currentData = snapshot.data!.hits; - - return Column( - children: [ - Expanded( - child: currentData.isNotEmpty - ? Scrollbar( - thumbVisibility: true, - trackVisibility: true, - child: ListView.separated( - physics: const ClampingScrollPhysics(), - itemCount: currentData.length, - separatorBuilder: (context, int i) => - const Divider( - color: Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - thickness: 1, - height: 1, - ), - itemBuilder: (context, index) { - final item = currentData[index]; - final String? featureImage = item['featureImage']; - final String title = item['title'] ?? ''; - final String? authorName = item['author']?['name']; - final String? authorImage = item['author']?['profileImage']; - final String? publishedAt = item['publishedAt']; - - return InkWell( - onTap: () => model.navigateToTutorial( - item['objectID'] ?? '', - item['slug'] ?? '', - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: SizedBox( - width: 120, - height: 80, - child: featureImage != null - ? CachedNetworkImage( - imageUrl: featureImage, - fit: BoxFit.cover, - placeholder: (context, url) => - Container( - color: const Color(0xFF2A2A40), - ), - errorWidget: (context, url, error) => - Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ), - ) - : Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - height: 1.3, - ), - ), - const SizedBox(height: 8), - Row( - children: [ - if (authorImage != null) - ClipRRect( - borderRadius: - BorderRadius.circular(12), - child: CachedNetworkImage( - imageUrl: authorImage, - width: 24, - height: 24, - fit: BoxFit.cover, - errorWidget: - (context, url, error) => - Image.asset( - 'assets/images/placeholder-profile-img.png', - width: 24, - height: 24, - ), - ), - ) - else - Image.asset( - 'assets/images/placeholder-profile-img.png', - width: 24, - height: 24, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - authorName ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - color: Colors.white - .withValues(alpha: 0.7), - ), - ), - ), - ], - ), - if (publishedAt != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - NewsFeedViewModel.parseDate( - publishedAt), - style: TextStyle( - fontSize: 12, - color: Colors.white - .withValues(alpha: 0.5), - ), - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ) - : Center( - child: Text( - context.t.tutorial_search_no_results, - ), - ), - ), - ], - ); - }, - ), - ); - }, - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-search/news_search_viewmodel.dart b/mobile-app/lib/ui/views/news/news-search/news_search_viewmodel.dart deleted file mode 100644 index dddd102e6..000000000 --- a/mobile-app/lib/ui/views/news/news-search/news_search_viewmodel.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:io'; - -import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/constants/radio_articles.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class NewsSearchModel extends BaseViewModel { - String _searchTerm = ''; - String get getSearchTerm => _searchTerm; - - bool _hasData = false; - bool get hasData => _hasData; - - bool _isLoading = true; - bool get isLoading => _isLoading; - - List currentResult = []; - - final searchbarController = TextEditingController(); - final _navigationService = locator(); - - final algolia = HitsSearcher( - applicationID: dotenv.get('ALGOLIAAPPID'), - apiKey: dotenv.get('ALGOLIAKEY'), - indexName: 'news', - ); - - set setHasData(value) { - _hasData = value; - notifyListeners(); - } - - set setIsLoading(value) { - _isLoading = value; - notifyListeners(); - } - - void init() { - algolia.query('JavaScript'); - algolia.responses.listen((res) { - // Remove radio articles from search on iOS - if (Platform.isIOS) { - res.hits.removeWhere( - (element) => radioArticles.contains(element['objectID'])); - } - - currentResult = res.hits; - _hasData = res.hits.isNotEmpty; - _isLoading = false; - notifyListeners(); - }); - } - - void onDispose() { - algolia.dispose(); - searchbarController.dispose(); - } - - void setSearchTerm(value) { - _searchTerm = value; - algolia.query( - value.isEmpty ? 'JavaScript' : value, - ); - notifyListeners(); - } - - // TODO: Add search results feed back post-migration - // void searchSubject() { - // _navigationService.navigateTo( - // Routes.newsFeedView, - // arguments: NewsFeedViewArguments( - // fromSearch: true, - // tutorials: currentResult, - // subject: _searchTerm == '' ? 'JavaScript' : _searchTerm, - // ), - // ); - // } - - void navigateToTutorial(String id, String slug) { - _navigationService.navigateTo( - Routes.newsTutorialView, - arguments: NewsTutorialViewArguments( - refId: id, - slug: slug, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_view.dart b/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_view.dart deleted file mode 100644 index ad26407ff..000000000 --- a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_view.dart +++ /dev/null @@ -1,236 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_widget.dart'; -import 'package:freecodecamp/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart'; -import 'package:freecodecamp/ui/views/news/widgets/back_to_top_button.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:stacked/stacked.dart'; - -class NewsTutorialHeader extends StatelessWidget { - const NewsTutorialHeader({super.key, required this.tutorial}); - - final Tutorial tutorial; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: tutorial.featureImage == null - ? Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ) - : Image.network( - tutorial.featureImage!, - fit: BoxFit.cover, - ), - ), - Row( - children: [ - Expanded( - child: Container( - color: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - tutorial.title, - style: const TextStyle( - fontSize: 24, - height: 1.5, - ), - key: const Key('title'), - ), - Text( - context.t.tutorial_written_by( - tutorial.authorName, - ), - style: const TextStyle( - height: 1.5, - ), - ), - Wrap( - children: [ - for (int j = 0; - j < tutorial.tagNames.length && j < 3; - j++) - tutorial.tagNames[j] - ], - ), - ], - ), - ), - ), - ], - ), - ], - ); - } -} - -class NewsTutorialView extends StatelessWidget { - const NewsTutorialView({ - super.key, - required this.refId, - required this.slug, - }); - final String refId; - final String slug; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - onViewModelReady: (model) => model.initState(refId), - onDispose: (model) => model.removeScrollPosition(), - builder: (context, model, child) => Scaffold( - backgroundColor: const Color(0xFF0a0a23), - body: FutureBuilder( - future: model.initState(refId), - builder: (context, snapshot) { - if (snapshot.hasData) { - var tutorial = snapshot.data; - return Column( - children: [ - Expanded( - child: Stack( - children: [ - lazyLoadHtml(tutorial!.text!, context, tutorial, model), - bottomButtons(tutorial, model, context), - ], - ), - ) - ], - ); - } else if (snapshot.hasError) { - throw Exception(snapshot.error); - } - - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - floatingActionButton: model.showToTopButton - ? BackToTopButton( - onPressed: () => model.goToTop(), - ) - : null, - ), - viewModelBuilder: () => NewsTutorialViewModel(), - ); - } - - Widget bottomButtons( - Tutorial tutorial, - NewsTutorialViewModel model, - BuildContext context, - ) { - return Align( - alignment: Alignment.bottomCenter, - child: SizedBox( - height: 75, - width: 300, - child: ListView( - physics: const NeverScrollableScrollPhysics(), - controller: model.bottomButtonController, - children: [ - Row( - children: [ - Container( - height: 150, - ), - NewsBookmarkViewWidget(tutorial: tutorial), - const SizedBox( - height: 35, - child: VerticalDivider( - color: Colors.white, - width: 0, - ), - ), - BottomButton( - label: context.t.share, - icon: Icons.share, - onPressed: () { - SharePlus.instance.share( - ShareParams(text: '${tutorial.title}\n\n${tutorial.url}'), - ); - }, - rightSided: true, - ) - ], - ), - ], - ), - ), - ); - } - - ListView lazyLoadHtml( - String html, - BuildContext context, - Tutorial tutorial, - NewsTutorialViewModel model, - ) { - var htmlToList = model.initLazyLoading(html, context, tutorial); - - return ListView.builder( - shrinkWrap: true, - controller: model.scrollController, - itemCount: htmlToList.length, - physics: const ClampingScrollPhysics(), - itemBuilder: (BuildContext context, int i) { - return Row( - children: [ - Expanded(child: htmlToList[i]), - ], - ); - }, - ); - } -} - -class BottomButton extends StatelessWidget { - const BottomButton({ - super.key, - required this.label, - required this.onPressed, - required this.icon, - required this.rightSided, - }); - - final Function onPressed; - final String label; - final bool rightSided; - final IconData icon; - - @override - Widget build(BuildContext context) { - return Expanded( - child: ElevatedButton.icon( - icon: Icon( - icon, - color: Colors.white, - ), - onPressed: () { - onPressed(); - }, - label: Text(label), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(rightSided ? 0 : 10), - topRight: Radius.circular(rightSided ? 10 : 0), - bottomLeft: Radius.circular(rightSided ? 0 : 10), - bottomRight: Radius.circular(rightSided ? 10 : 0), - ), - ), - backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart b/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart deleted file mode 100644 index 06f33f75c..000000000 --- a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/api_service.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:freecodecamp/ui/views/news/news-tutorial/news_tutorial_view.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked/stacked.dart'; - -class NewsTutorialViewModel extends BaseViewModel { - late Future _tutorialFuture; - final _newsApiService = locator(); - - Future? get tutorialFuture => _tutorialFuture; - - final ScrollController _scrollController = ScrollController(); - ScrollController get scrollController => _scrollController; - - final ScrollController _bottomButtonController = ScrollController(); - ScrollController get bottomButtonController => _bottomButtonController; - - bool _showToTopButton = false; - bool get showToTopButton => _showToTopButton; - - set showToTopButton(bool value) { - _showToTopButton = value; - notifyListeners(); - } - - Future readFromFiles() async { - String json = await rootBundle.loadString( - 'assets/test_data/news_post.json', - ); - - var decodedJson = jsonDecode(json); - - return Tutorial.toPostFromJson(decodedJson); - } - - Future initState(id) async { - handleBottomButtonAnimation(); - handleToTopButton(); - - // if (await _developerService.developmentMode()) { - // return readFromFiles(); - // } else { - // } - return fetchTutorial(id); - } - - Future handleToTopButton() async { - _scrollController.addListener(() { - if (scrollController.offset >= 100) { - if (!showToTopButton) { - showToTopButton = true; - } - } else { - if (showToTopButton) { - showToTopButton = false; - } - } - }); - } - - Future goToTop() async { - _scrollController.animateTo( - 0, - duration: const Duration(milliseconds: 1000), - curve: Curves.easeInOut, - ); - } - - Future handleBottomButtonAnimation() async { - _scrollController.addListener(() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - - if (prefs.getDouble('position') == null) { - await prefs.setDouble('position', _scrollController.offset); - } - - double oldScrollPos = prefs.getDouble('position') as double; - - if (_scrollController.offset <= oldScrollPos) { - _bottomButtonController.animateTo( - _bottomButtonController.position.maxScrollExtent - 50, - duration: const Duration(milliseconds: 1000), - curve: Curves.easeInOut, - ); - } else { - _bottomButtonController.animateTo( - 0, - duration: const Duration(milliseconds: 1000), - curve: Curves.easeInOut, - ); - } - Timer(const Duration(seconds: 2), () async { - if (_scrollController.hasClients) { - await prefs.setDouble('position', _scrollController.offset); - } - }); - }); - } - - Future initBottomButtonAnimation() async { - // if the user has not scrolled yet the bottom buttons will not appear - // this means we need to animate it manually. - Future.delayed( - const Duration(seconds: 1), - () => { - if (_bottomButtonController.hasClients) - { - _bottomButtonController.animateTo( - _bottomButtonController.position.maxScrollExtent - 50, - duration: const Duration(milliseconds: 1000), - curve: Curves.easeInOut, - ) - } - }, - ); - } - - List initLazyLoading(html, BuildContext context, tutorial) { - HTMLParser parser = HTMLParser(context: context); - - List elements = parser.parse(html); - - if (tutorial is Tutorial) { - // insert before the first element - elements.insert( - 0, - Stack( - children: [ - NewsTutorialHeader(tutorial: tutorial), - AppBar( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - leading: Tooltip( - message: context.t.back, - child: InkWell( - onTap: () { - Navigator.pop(context); - }, - child: Container( - margin: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50), - color: Colors.black.withValues(alpha: 0.5), - ), - child: const Icon(Icons.arrow_back), - ), - ), - ), - ) - ], - ), - ); - - // insert after the last element - elements.add(const SizedBox( - height: 100, - )); - - initBottomButtonAnimation(); - } - - return elements; - } - - Future removeScrollPosition() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - _scrollController.dispose(); - _bottomButtonController.dispose(); - - await prefs.remove('position'); - } - - Future fetchTutorial(tutorialId) async { - await dotenv.load(fileName: '.env'); - - dynamic postData = await _newsApiService.getPost(tutorialId); - - return Tutorial.toPostFromJson(postData); - } -} diff --git a/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_view.dart b/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_view.dart deleted file mode 100644 index 83f64bcfb..000000000 --- a/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_view.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_feed_view.dart'; -import 'package:freecodecamp/ui/views/news/news-feed/news_feed_view.dart'; -import 'package:freecodecamp/ui/views/news/news-search/news_search_view.dart'; -import 'package:freecodecamp/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -class NewsViewHandlerView extends StatelessWidget { - const NewsViewHandlerView({super.key}); - - @override - Widget build(BuildContext context) { - var titles = [ - Text(context.t.tutorial_bookmarks_title), - Text(context.t.tutorials), - Text(context.t.tutorial_search_title) - ]; - - const views = [ - NewsBookmarkFeedView(), - NewsFeedView(), - NewsSearchView() - ]; - - return ViewModelBuilder.reactive( - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: titles.elementAt(model.index), - ), - drawer: const DrawerWidgetView(), - body: views.elementAt(model.index), - bottomNavigationBar: BottomNavigationBar( - items: [ - BottomNavigationBarItem( - icon: const Icon( - Icons.bookmark_outline_sharp, - ), - label: context.t.tutorial_nav_bookmarks, - tooltip: context.t.tutorial_nav_bookmarks, - ), - BottomNavigationBarItem( - icon: const Icon( - Icons.article_sharp, - ), - label: context.t.tutorial_nav_tutorials, - tooltip: context.t.tutorial_nav_tutorials, - ), - BottomNavigationBarItem( - icon: const Icon( - Icons.search_sharp, - ), - label: context.t.tutorial_nav_search, - tooltip: context.t.tutorial_nav_search, - ) - ], - currentIndex: model.index, - onTap: model.onTapped, - ), - ), - viewModelBuilder: () => NewsViewHandlerViewModel(), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart b/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart deleted file mode 100644 index 9e2b3fe8c..000000000 --- a/mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:stacked/stacked.dart'; - -class NewsViewHandlerViewModel extends BaseViewModel { - int index = 1; - - void onTapped(int index) { - this.index = index; - notifyListeners(); - } -} diff --git a/mobile-app/lib/ui/views/news/widgets/back_to_top_button.dart b/mobile-app/lib/ui/views/news/widgets/back_to_top_button.dart deleted file mode 100644 index 84d98065f..000000000 --- a/mobile-app/lib/ui/views/news/widgets/back_to_top_button.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -class BackToTopButton extends StatelessWidget { - final VoidCallback onPressed; - - const BackToTopButton({super.key, required this.onPressed}); - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 128), - child: FloatingActionButton( - onPressed: onPressed, - shape: RoundedRectangleBorder( - side: const BorderSide( - width: 1, - color: Colors.white, - ), - borderRadius: BorderRadius.circular(100), - ), - backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - child: const Icon( - Icons.keyboard_arrow_up, - size: 40, - color: Colors.white, - ), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/news/widgets/tag_widget.dart b/mobile-app/lib/ui/views/news/widgets/tag_widget.dart deleted file mode 100644 index 4c7602ea5..000000000 --- a/mobile-app/lib/ui/views/news/widgets/tag_widget.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class TagButton extends StatefulWidget { - const TagButton({ - super.key, - required this.tagName, - required this.tagSlug, - this.compact = false, - }); - - final String tagName; - final String tagSlug; - final bool compact; - - static Color randomColor() { - var randomNum = Random(); - - List colors = [ - const Color.fromRGBO(0xdb, 0xb8, 0xff, 1), - const Color.fromRGBO(0xf1, 0xbe, 0x32, 1), - const Color.fromRGBO(0x99, 0xc9, 0xff, 1), - const Color.fromRGBO(0xac, 0xd1, 0x57, 1) - ]; - - return colors[randomNum.nextInt(4)]; - } - - @override - State createState() => _TagButtonState(); -} - -class _TagButtonState extends State - with AutomaticKeepAliveClientMixin { - final _navigationService = locator(); - final _tagColor = TagButton.randomColor(); - @override - // ignore: must_call_super - Widget build(BuildContext context) { - final isCompact = widget.compact; - - return Padding( - padding: EdgeInsets.fromLTRB(0, isCompact ? 0 : 8, isCompact ? 6 : 8, 0), - child: InkWell( - onTap: () { - _navigationService.navigateTo( - Routes.newsFeedView, - arguments: NewsFeedViewArguments( - tagSlug: widget.tagSlug, - fromTag: true, - subject: widget.tagName, - ), - ); - }, - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * (isCompact ? 0.35 : 0.45)), - decoration: ShapeDecoration( - color: _tagColor, - shape: const StadiumBorder(), - ), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: isCompact ? 2 : 4, - horizontal: isCompact ? 6 : 8, - ), - child: Tooltip( - message: '#${widget.tagName}', - child: Text( - '#${widget.tagName}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: isCompact ? 11 : 16, - color: Colors.black, - ), - ), - ), - ), - ), - ), - ); - } - - @override - bool get wantKeepAlive => true; -} diff --git a/mobile-app/lib/ui/views/news/widgets/tutorial_list_widget.dart b/mobile-app/lib/ui/views/news/widgets/tutorial_list_widget.dart deleted file mode 100644 index e9ffa50ab..000000000 --- a/mobile-app/lib/ui/views/news/widgets/tutorial_list_widget.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/api_service.dart'; -import 'package:freecodecamp/ui/views/news/news-feed/news_feed_viewmodel.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class TutorialList extends StatefulWidget { - // ignore: prefer_const_constructors_in_immutables - TutorialList({ - super.key, - required this.authorSlug, - required this.authorName, - required this.authorId, - }); - - final String authorSlug; - final String authorName; - final String authorId; - final _navigationService = locator(); - final _newsApiService = locator(); - - Future> fetchList() async { - List tutorials = []; - - await dotenv.load(); - - final data = await _newsApiService.getPostsByAuthor(authorId); - final postsData = data.posts; - - for (int i = 0; i < postsData.length; i++) { - tutorials.add(Tutorial.fromJson(postsData[i]['node'])); - } - return tutorials; - } - - void navigateToTutorial(String id, String slug) { - _navigationService.navigateTo( - Routes.newsTutorialView, - arguments: NewsTutorialViewArguments( - refId: id, - slug: slug, - ), - ); - } - - void navigateToFeed() { - _navigationService.navigateTo( - Routes.newsFeedView, - arguments: NewsFeedViewArguments( - fromAuthor: true, - authorId: authorId, - subject: authorName, - ), - ); - } - - @override - State createState() => TutorialListState(); -} - -class TutorialListState extends State { - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: widget.fetchList(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Column( - children: [ - Row( - children: [ - TileLayout( - widget: widget, - snapshot: snapshot, - ), - ], - ), - if (snapshot.data!.length > 5) - Container( - padding: const EdgeInsets.all(4.0), - margin: const EdgeInsets.only(bottom: 48), - child: ListTile( - title: Text( - context.t.tutorial_show_more, - ), - tileColor: const Color(0xFF0a0a23), - trailing: const Icon(Icons.arrow_forward_ios_outlined), - onTap: () { - widget.navigateToFeed(); - }, - ), - ), - ], - ); - } else { - return const CircularProgressIndicator(); - } - }, - ); - } -} - -class TileLayout extends StatelessWidget { - const TileLayout({super.key, required this.widget, required this.snapshot}); - - final TutorialList widget; - final AsyncSnapshot> snapshot; - - @override - Widget build(BuildContext context) { - double imgSize = MediaQuery.of(context).size.width * 0.25; - - return Expanded( - child: ListView.separated( - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (context, index) => const SizedBox( - height: 4, - ), - shrinkWrap: true, - padding: const EdgeInsets.all(4), - itemCount: snapshot.data!.length > 5 ? 5 : snapshot.data?.length ?? 1, - itemBuilder: (context, index) { - Tutorial tutorial = snapshot.data![index]; - return ListTile( - title: Text( - tutorial.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - tileColor: const Color(0xFF0a0a23), - isThreeLine: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 10, - ), - subtitle: Text( - NewsFeedViewModel.parseDate(tutorial.createdAt), - ), - trailing: Container( - constraints: BoxConstraints( - minWidth: imgSize, - maxWidth: imgSize, - ), - child: tutorial.featureImage == null - ? Image.asset( - 'assets/images/freecodecamp-banner.png', - fit: BoxFit.cover, - ) - : Image.network( - tutorial.featureImage!, - fit: BoxFit.cover, - ), - ), - onTap: () { - widget.navigateToTutorial( - tutorial.id, - tutorial.slug, - ); - }, - ); - }, - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/podcast/episode-list/episode_list_view.dart b/mobile-app/lib/ui/views/podcast/episode-list/episode_list_view.dart deleted file mode 100644 index 5d25c3c25..000000000 --- a/mobile-app/lib/ui/views/podcast/episode-list/episode_list_view.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_html/flutter_html.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/podcast/episode-list/episode_list_viewmodel.dart'; -import 'package:freecodecamp/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart'; -import 'package:freecodecamp/ui/views/podcast/widgets/podcast_title_widget.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:stacked/stacked.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class EpisodeListView extends StatelessWidget { - const EpisodeListView({ - super.key, - required this.podcast, - required this.isDownloadView, - }); - - final Podcasts podcast; - final bool isDownloadView; - - // ignore: unused_field - final TextStyle _subTitleStyle = const TextStyle(fontSize: 14); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => EpisodeListViewModel(podcast), - onViewModelReady: (model) => model.initState(isDownloadView), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: Text(podcast.title!), - ), - body: RefreshIndicator( - onRefresh: () => Future.sync(() => isDownloadView - ? model.episodes - : model.pagingController?.refresh()), - backgroundColor: const Color(0xFF0a0a23), - color: Colors.white, - child: Align( - alignment: Alignment.topCenter, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Column( - children: [ - podcastHeader(), - Container( - color: const Color(0xFF0a0a23), - child: Padding( - padding: const EdgeInsets.all(20.0), - child: description(model, context), - ), - ), - ], - ), - ), - model.isBusy - ? const SliverFillRemaining( - child: Center( - child: CircularProgressIndicator(), - ), - ) - : model.hasError - ? SliverFillRemaining( - child: Center( - child: Text('Error: ${model.modelError}'), - ), - ) - : renderEpisodeList(isDownloadView, model) - ], - ), - ), - ), - ), - ); - } - - Widget renderEpisodeList(bool isDownloadView, EpisodeListViewModel model) { - return !isDownloadView - ? PagingListener( - controller: model.pagingController!, - builder: (context, state, fetchNextPage) => - PagedSliverList.separated( - state: state, - fetchNextPage: fetchNextPage, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: ( - BuildContext context, - Episodes episode, - int index, - ) => - PodcastTile( - episode: episode, - podcast: podcast, - isFromDownloadView: isDownloadView, - ), - ), - separatorBuilder: (BuildContext context, int index) => - const Divider( - color: FccColors.gray80, - thickness: 1, - height: 1, - indent: 16, - endIndent: 16, - ), - ), - ) - : SliverToBoxAdapter( - child: FutureBuilder>( - future: model.episodes, - builder: (context, snapshot) { - if (snapshot.hasData) { - return ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: snapshot.data!.length, - itemBuilder: (BuildContext context, int index) { - return PodcastTile( - episode: snapshot.data![index], - podcast: podcast, - isFromDownloadView: isDownloadView, - ); - }, - separatorBuilder: (BuildContext context, int index) { - return const Divider( - color: FccColors.gray80, - thickness: 1, - height: 1, - indent: 16, - endIndent: 16, - ); - }, - ); - } else if (snapshot.hasError) { - return Text('${snapshot.error}'); - } - return const Center( - child: CircularProgressIndicator(), - ); - }, - ), - ); - } - - Column description(EpisodeListViewModel model, BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.podcast_description, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - height: 1.2, - ), - ), - Html( - data: podcast.description!, - onLinkTap: (url, attributes, element) { - launchUrl(Uri.parse(url!)); - }, - style: { - '#': Style( - fontSize: FontSize(16), - color: Colors.white.withValues(alpha: 0.87), - margin: Margins.zero, - maxLines: model.showDescription ? null : 3, - ) - }, - ), - TextButton( - onPressed: () { - model.setShowMoreDescription = !model.showDescription; - }, - style: TextButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - child: Text( - model.showDescription - ? context.t.podcast_show_less - : context.t.podcast_show_more, - style: const TextStyle( - fontSize: 16, - ), - ), - ) - ], - ); - } - - Stack podcastHeader() { - return Stack( - alignment: Alignment.bottomCenter, - children: [ - isDownloadView - ? Image.file( - File( - '${PodcastListViewModel.appDir.path}/images/podcast/${podcast.id}.jpg', - ), - ) - : CachedNetworkImage( - imageUrl: podcast.image!, - fit: BoxFit.cover, - ), - Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.75), - spreadRadius: 1.5, - blurRadius: 5, - ) - ], - ), - child: Text( - '${podcast.title!}\n', - maxLines: 2, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 24, - height: 1.2, - ), - ), - ), - ), - ], - ), - ], - ); - } -} diff --git a/mobile-app/lib/ui/views/podcast/episode-list/episode_list_viewmodel.dart b/mobile-app/lib/ui/views/podcast/episode-list/episode_list_viewmodel.dart deleted file mode 100644 index 391c4823a..000000000 --- a/mobile-app/lib/ui/views/podcast/episode-list/episode_list_viewmodel.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/podcast/podcasts_service.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:stacked/stacked.dart'; - -class EpisodeListViewModel extends BaseViewModel { - EpisodeListViewModel(this.podcast); - - final _databaseService = locator(); - final _developerService = locator(); - final Podcasts podcast; - int epsLength = 0; - final _dio = DioService.dio; - - late Future> _episodes; - Future> get episodes => _episodes; - - bool _showMoreDescription = false; - bool get showDescription => _showMoreDescription; - - set setShowMoreDescription(bool state) { - _showMoreDescription = state; - notifyListeners(); - } - - PagingController? _pagingController; - PagingController? get pagingController => _pagingController; - - void initState(bool isDownloadView) async { - try { - setBusy(true); - await _databaseService.initialise(); - if (isDownloadView) { - _episodes = _databaseService.getEpisodes(podcast); - epsLength = (await episodes).length; - } else { - _pagingController = PagingController( - getNextPageKey: (state) => (state.keys?.last ?? -1) + 1, - fetchPage: (pageKey) => fetchEpisodes(podcast.id, pageKey), - ); - } - } catch (e) { - setError(e); - } finally { - setBusy(false); - notifyListeners(); - } - } - - Future> fetchEpisodes(String podcastId, - [int pageKey = 0]) async { - String baseUrl = (await _developerService.developmentMode()) - ? 'https://api.mobile.freecodecamp.dev/' - : 'https://api.mobile.freecodecamp.org/'; - final res = await _dio.get( - '${baseUrl}podcasts/$podcastId/episodes?page=$pageKey', - ); - final List episodes = res.data['episodes']; - epsLength = res.data['podcast']['numOfEps']; - notifyListeners(); - final List eps = - episodes.map((e) => Episodes.fromAPIJson(e)).toList(); - final prevCount = _pagingController?.items?.length ?? 0; - if (prevCount + 20 >= epsLength) { - _pagingController?.value = _pagingController!.value.copyWith( - hasNextPage: false, - ); - } - return eps; - } - - @override - void dispose() { - _pagingController?.dispose(); - super.dispose(); - } -} diff --git a/mobile-app/lib/ui/views/podcast/episode/episode_view.dart b/mobile-app/lib/ui/views/podcast/episode/episode_view.dart deleted file mode 100644 index 63fd4b42b..000000000 --- a/mobile-app/lib/ui/views/podcast/episode/episode_view.dart +++ /dev/null @@ -1,255 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; -import 'package:freecodecamp/ui/views/podcast/episode/episode_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class EpisodeView extends StatelessWidget { - const EpisodeView({ - super.key, - required this.episode, - required this.podcast, - }); - - final Episodes episode; - final Podcasts podcast; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => EpisodeViewModel(), - onViewModelReady: (model) { - if (!model.audioService.isPlaying( - 'podcast', - episodeId: model.audioService.episodeId, - )) { - model.loadEpisode(episode, podcast); - } - model.hasDownloadedEpisode(episode); - model.initProgressListener(episode); - model.initDownloadListener(episode); - model.initPlaybackListener(); - model.initPlayBackSpeed(); - }, - onDispose: (model) { - model.disposeProgressListener(); - }, - builder: (context, model, child) => Scaffold( - appBar: AppBar(), - body: ListView( - children: [ - Column( - children: [ - Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 10, - spreadRadius: 5, - offset: const Offset(0, 3), - ), - ], - ), - margin: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 8, - ), - height: MediaQuery.of(context).size.height * 0.40, - width: MediaQuery.of(context).size.height * 0.40, - child: CachedNetworkImage(imageUrl: podcast.image!), - ), - Container( - padding: const EdgeInsets.all(8), - child: Text( - episode.title, - textAlign: TextAlign.center, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - ), - Text( - podcast.title!, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - Slider( - onChangeStart: (_) { - model.audioService.pause(); - }, - onChanged: (value) { - model.setSliderValue = value; - model.handleTimeVortex( - episode.duration!.inSeconds, - (episode.duration!.inSeconds * value).toInt(), - ); - }, - onChangeEnd: (value) { - model.setAudioProgress(value, episode); - model.audioService.play(); - }, - value: model.sliderValue, - ), - Padding( - padding: - const EdgeInsets.symmetric(vertical: 0, horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - model.timeElapsed, - style: const TextStyle(fontSize: 16), - ), - Text( - '-${model.timeLeft}', - style: const TextStyle(fontSize: 16), - ), - ], - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - DropdownButtonHideUnderline( - child: DropdownButton( - value: model.playBackSpeed, - dropdownColor: const Color(0xFF0a0a23), - icon: const SizedBox.shrink(), - items: model.speedOptions - .map( - (speed) => DropdownMenuItem( - onTap: () => model.handlePlayBackSpeed(speed), - value: speed, - child: Text( - '${speed}x', - style: const TextStyle( - color: Colors.white, - ), - ), - ), - ) - .toList(), - onChanged: (_) {}, - ), - ), - const SizedBox(width: 8), - IconButton( - iconSize: 45, - icon: const Icon(Icons.replay_10_rounded), - onPressed: () { - model.rewind(episode); - }, - ), - IconButton( - iconSize: 80, - onPressed: () { - model.playOrPause(episode, podcast); - }, - icon: model.isPlaying && - model.audioService.episodeId == episode.id - ? const Icon(Icons.pause) - : const Icon(Icons.play_arrow_rounded), - ), - IconButton( - iconSize: 45, - icon: const Icon( - Icons.forward_30_rounded, - ), - onPressed: () { - model.forward(episode); - }, - ), - IconButton( - iconSize: 30, - onPressed: model.isDownloading - ? null - : () { - if (!model.isDownloaded) { - model.setIsDownloading = true; - } - - model.downloadService.setDownloadId = episode.id; - model.downloadBtnClick(episode, podcast); - }, - icon: (() { - if (model.isDownloaded) { - return Icon( - Icons.download_done, - color: Colors.white, - size: 30, - semanticLabel: context.t.download_complete, - ); - } else if (model.isDownloading && - model.downloadService.downloadId == episode.id) { - return StreamBuilder( - stream: model.downloadService.progress, - builder: (context, snapshot) { - final progress = snapshot.data; - double? val = double.tryParse(progress ?? '0'); - if (progress == '100') { - return Icon( - Icons.download_done, - color: Colors.white, - size: 30, - semanticLabel: context.t.download_complete, - ); - } else if (progress != null && progress != '') { - return Stack( - alignment: Alignment.center, - children: [ - Text( - '$progress%', - style: const TextStyle(fontSize: 16), - ), - CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - value: val != null ? val / 100 : 0, - ) - ], - ); - } else { - return const CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - value: 0, - ); - } - }, - ); - } else { - return Icon( - Icons.arrow_circle_down_outlined, - color: Colors.white, - size: 30, - semanticLabel: context.t.download_episode, - ); - } - })(), - ) - ], - ), - Container( - padding: const EdgeInsets.fromLTRB(8, 16, 8, 8), - child: description(model, context), - ), - ], - ), - ], - ), - ), - ); - } - - Widget description(EpisodeViewModel model, BuildContext context) { - HTMLParser parser = HTMLParser(context: context); - return Column( - children: parser.parse('

    ${episode.description}

    '), - ); - } -} diff --git a/mobile-app/lib/ui/views/podcast/episode/episode_viewmodel.dart b/mobile-app/lib/ui/views/podcast/episode/episode_viewmodel.dart deleted file mode 100644 index 6aaec8617..000000000 --- a/mobile-app/lib/ui/views/podcast/episode/episode_viewmodel.dart +++ /dev/null @@ -1,226 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:freecodecamp/service/podcast/download_service.dart'; -import 'package:freecodecamp/service/podcast/podcasts_service.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:stacked/stacked.dart'; - -class EpisodeViewModel extends BaseViewModel { - final audioService = locator().audioHandler; - final _databaseService = locator(); - final DownloadService downloadService = locator(); - - StreamSubscription? _progressListener; - StreamSubscription? get progressListener => _progressListener; - - double _sliderValue = 0.0; - double get sliderValue => _sliderValue; - - bool _isPlaying = false; - bool get isPlaying => _isPlaying; - - double _playBackSpeed = 1.0; - double get playBackSpeed => _playBackSpeed; - - String _timeElapsed = '--:--'; - String get timeElapsed => _timeElapsed; - - String _timeLeft = '--:--'; - String get timeLeft => _timeLeft; - - bool _isDownloading = false; - bool get isDownloading => _isDownloading; - - bool _isDownloaded = false; - bool get isDownloaded => _isDownloaded; - - final List speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0]; - - set setSliderValue(double value) { - _sliderValue = value; - notifyListeners(); - } - - set setTimeElapsed(String value) { - _timeElapsed = value; - notifyListeners(); - } - - set setTimeLeft(String value) { - _timeLeft = value; - notifyListeners(); - } - - set setProgressListener(StreamSubscription? value) { - _progressListener = value; - notifyListeners(); - } - - set setPlayBackSpeed(double value) { - _playBackSpeed = value; - notifyListeners(); - } - - set setIsDownloading(bool value) { - _isDownloading = value; - notifyListeners(); - } - - set setIsDownloaded(bool value) { - _isDownloaded = value; - notifyListeners(); - } - - set setIsPlaying(bool value) { - _isPlaying = value; - notifyListeners(); - } - - void initProgressListener(Episodes episode) { - if (audioService.episodeId == episode.id || - audioService.episodeId.isEmpty) { - setProgressListener = AudioService.position.listen( - (event) { - int duration = episode.duration!.inSeconds; - double sliderValue = event.inSeconds / duration; - setSliderValue = sliderValue; - handleTimeVortex(duration, event.inSeconds); - }, - ); - } - } - - void handleTimeVortex(int duration, int inSeconds) { - setTimeElapsed = timeDisplay(inSeconds); - setTimeLeft = timeDisplay(duration - inSeconds - 1); - } - - String timeDisplay(int totalSeconds) { - int hours = totalSeconds ~/ 3600; - int minutes = (totalSeconds % 3600) ~/ 60; - int seconds = totalSeconds % 60; - - String twoDigits(int n) => n.toString().padLeft(2, '0'); - - if (hours > 0) { - return '$hours:${twoDigits(minutes)}:${twoDigits(seconds)}'; - } else { - return '$minutes:${twoDigits(seconds)}'; - } - } - - void removeEpisode(Episodes episode, Podcasts podcast) async { - await _databaseService.removeEpisode(episode); - await _databaseService.removePodcast(podcast); - setIsDownloaded = false; - } - - void downloadBtnClick(Episodes episode, Podcasts podcast) async { - downloadService.setDownloadId = episode.id; - Directory appDir = await getApplicationSupportDirectory(); - - if (!isDownloaded && isDownloading) { - downloadService.download(episode, podcast); - } else if (isDownloaded) { - File audioFile = - File('${appDir.path}/episodes/${podcast.id}/${episode.id}.mp3'); - if (audioFile.existsSync()) { - audioFile.deleteSync(); - } - - removeEpisode(episode, podcast); - } - } - - void hasDownloadedEpisode(Episodes episode) async { - setIsDownloaded = await _databaseService.episodeExists(episode); - } - - void initDownloadListener(Episodes episode) { - if (downloadService.isDownloading && - downloadService.downloadId == episode.id) { - setIsDownloading = true; - } - downloadService.downloadingStream.listen( - (event) async { - if (downloadService.downloadId == episode.id) { - if (event == false) { - setIsDownloaded = true; - setIsDownloading = false; - } - } - }, - ); - } - - void initPlaybackListener() { - audioService.playbackState.listen((event) { - if (event.playing && !isPlaying) { - setIsPlaying = true; - } else if (!event.playing && isPlaying) { - setIsPlaying = false; - } - }); - } - - void setAudioProgress(double value, Episodes episode) { - audioService.seek( - Duration(seconds: (value * episode.duration!.inSeconds).toInt()), - ); - setSliderValue = value.toDouble(); - } - - void disposeProgressListener() { - progressListener!.cancel(); - } - - void forward(Episodes episode) async { - int newPos = await audioService.fastForward(); - - double value = newPos / episode.duration!.inSeconds; - - setSliderValue = value < 1 ? value : 1.0; - } - - void rewind(Episodes episode) async { - int newPos = await audioService.rewind(); - - double value = newPos / episode.duration!.inSeconds; - - setSliderValue = value > 0 ? value : 0.0; - } - - void playOrPause(Episodes episode, Podcasts podcast) { - if (audioService.isPlaying('podcast', episodeId: episode.id)) { - audioService.pause(); - } else { - loadEpisode(episode, podcast); - audioService.play(); - } - } - - void handlePlayBackSpeed(speed) { - setPlayBackSpeed = speed; - audioService.setSpeed(speed); - } - - void initPlayBackSpeed() { - setPlayBackSpeed = audioService.getSpeed(); - } - - void loadEpisode(Episodes episode, Podcasts podcast) async { - bool downloaded = await _databaseService.episodeExists(episode); - - if (audioService.episodeId != episode.id) { - audioService.setAudioCtx(EpisodeAudioConfig(episode.id)); - audioService.loadEpisode(episode, downloaded, podcast); - initProgressListener(episode); - } - } -} diff --git a/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_view.dart b/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_view.dart deleted file mode 100644 index 375f6af7e..000000000 --- a/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_view.dart +++ /dev/null @@ -1,219 +0,0 @@ -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/ui/views/podcast/episode-list/episode_list_view.dart'; -import 'package:freecodecamp/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -List views = [ - const PodcastListViewBuilder(isDownloadView: false), - const PodcastListViewBuilder(isDownloadView: true), -]; - -class PodcastListView extends StatelessWidget { - const PodcastListView({super.key}); - - @override - Widget build(BuildContext context) { - List titles = [ - Text(context.t.podcasts_title), - Text(context.t.podcast_download_title), - ]; - - return ViewModelBuilder.reactive( - viewModelBuilder: () => PodcastListViewModel(), - onViewModelReady: (model) async => await model.init(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: titles.elementAt(model.index), - ), - drawer: const DrawerWidgetView(), - bottomNavigationBar: BottomNavigationBar( - currentIndex: model.index, - onTap: model.setIndex, - items: [ - BottomNavigationBarItem( - icon: const Icon( - Icons.grid_view_rounded, - ), - label: context.t.podcasts_browse, - ), - BottomNavigationBarItem( - icon: const Icon( - Icons.arrow_circle_down_sharp, - ), - label: context.t.podcasts_downloads, - ), - ], - ), - body: views.elementAt(model.index), - ), - ); - } -} - -class PodcastListViewBuilder extends StatelessWidget { - const PodcastListViewBuilder({ - super.key, - required this.isDownloadView, - }); - - final bool isDownloadView; - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => PodcastListViewModel(), - builder: (context, model, child) => Scaffold( - backgroundColor: const Color(0xFF2A2A40), - body: RefreshIndicator( - backgroundColor: const Color(0xFF0a0a23), - color: Colors.white, - onRefresh: () { - model.refresh(); - return Future.delayed(const Duration(seconds: 0)); - }, - child: FutureBuilder>( - future: model.fetchPodcasts(isDownloadView), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center( - child: Text( - context.t.podcast_unable_to_load_podcasts, - textAlign: TextAlign.center, - ), - ); - } - if (snapshot.data!.isEmpty && isDownloadView) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.arrow_circle_down_sharp, - color: Colors.white, - size: 50, - ), - const SizedBox( - height: 4, - ), - Text( - context.t.podcast_no_downloads, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } else { - return GridView.count( - crossAxisCount: 2, - childAspectRatio: 1, - children: List.generate( - snapshot.data!.length, - (index) => PodcastTemplate( - podcast: snapshot.data![index], - i: index, - isDownloadView: isDownloadView, - ), - ), - ); - } - }, - ), - ), - ), - ); - } -} - -class PodcastTemplate extends StatelessWidget { - const PodcastTemplate({ - super.key, - required this.podcast, - required this.i, - required this.isDownloadView, - }); - - final Podcasts podcast; - final int i; - final bool isDownloadView; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => EpisodeListView( - podcast: podcast, - isDownloadView: isDownloadView, - ), - settings: RouteSettings( - name: '/podcasts-episode-list/${podcast.title}', - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: Stack( - children: [ - Container( - color: const Color(0xFF0a0a23), - child: isDownloadView - ? Image.file( - File( - '${PodcastListViewModel.appDir.path}/images/podcast/${podcast.id}.jpg', - ), - alignment: Alignment.center, - ) - : CachedNetworkImage( - imageUrl: podcast.image!, - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.75), - spreadRadius: 1.5, - blurRadius: 0.1, - ) - ], - ), - child: Text('${podcast.title!}\n', - maxLines: 2, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - height: 1.2, - overflow: TextOverflow.ellipsis, - )), - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart b/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart deleted file mode 100644 index a8d9213a5..000000000 --- a/mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'dart:io'; - -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/podcast/podcasts_service.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:stacked/stacked.dart'; - -const fccPodcastUrls = [ - // English - 'https://freecodecamp.libsyn.com/rss', - // Spanish - 'https://anchor.fm/s/ff0092f4/podcast/rss', - // Chinese - 'https://anchor.fm/s/ff054de4/podcast/rss', - // Portuguese - 'https://anchor.fm/s/ff026c00/podcast/rss', -]; - -class PodcastListViewModel extends BaseViewModel { - final _databaseService = locator(); - final _developerService = locator(); - static late Directory appDir; - int _index = 0; - final _dio = DioService.dio; - - int get index => _index; - - void setIndex(i) { - _index = i; - notifyListeners(); - } - - Future init() async { - appDir = await getApplicationDocumentsDirectory(); - } - - void refresh() { - notifyListeners(); - } - - Future> fetchPodcasts(bool isDownloadView) async { - String baseUrl = (await _developerService.developmentMode()) - ? 'https://api.mobile.freecodecamp.dev' - : 'https://api.mobile.freecodecamp.org'; - if (isDownloadView) { - await _databaseService.initialise(); - return await _databaseService.getPodcasts(); - } else { - final res = await _dio.get('$baseUrl/podcasts'); - final List podcasts = res.data; - final podcastList = - podcasts.map((podcast) => Podcasts.fromAPIJson(podcast)).toList(); - final fccPodcasts = podcastList - .where((podcast) => fccPodcastUrls.contains(podcast.url)) - .toList(); - final otherPodcasts = podcastList - .where((podcast) => !fccPodcastUrls.contains(podcast.url)) - .toList(); - return [...fccPodcasts, ...otherPodcasts]; - } - } -} diff --git a/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart b/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart deleted file mode 100644 index 341de0529..000000000 --- a/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart +++ /dev/null @@ -1,422 +0,0 @@ -import 'dart:io'; - -import 'package:audio_service/audio_service.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/podcasts/episodes_model.dart'; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; -import 'package:freecodecamp/service/audio/audio_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:freecodecamp/service/podcast/download_service.dart'; -import 'package:freecodecamp/service/podcast/podcasts_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/podcast/episode/episode_view.dart'; -import 'package:html/parser.dart' as html_parser; -import 'package:jiffy/jiffy.dart'; -import 'package:path_provider/path_provider.dart'; - -// ignore: must_be_immutable -class PodcastTile extends StatefulWidget { - PodcastTile({ - super.key, - required this.podcast, - required this.episode, - required this.isFromDownloadView, - }); - - final Podcasts podcast; - final Episodes episode; - - final bool isFromDownloadView; - - final _audioService = locator().audioHandler; - final _databaseService = locator(); - final _downloadService = locator(); - - final dio = DioService.dio; - - bool _downloaded = false; - bool get downloaded => _downloaded; - - bool _playing = false; - bool get playing => _playing; - - bool _loading = false; - bool get loading => _loading; - - bool _isDownloading = false; - bool get isDownloading => _isDownloading; - - @override - State createState() => PodcastTileState(); -} - -class PodcastTileState extends State { - set setIsPlaying(bool state) { - mounted - ? setState(() { - widget._playing = state; - }) - : null; - } - - set setIsDownloaded(bool state) { - setState(() { - widget._downloaded = state; - }); - } - - set setIsLoading(bool state) { - setState(() { - widget._loading = state; - }); - } - - set setIsDownloading(bool state) { - mounted - ? setState(() { - widget._isDownloading = state; - }) - : null; - } - - @override - void initState() { - super.initState(); - - Future.delayed(const Duration(seconds: 0), () async => {init()}); - } - - Future init() async { - Directory appDir = await getApplicationDocumentsDirectory(); - - File podcastImgFile; - Response res; - - widget._downloadService.downloadingStream.listen((event) { - setIsDownloading = event; - }); - - widget._downloadService.progress.listen((event) async { - if (widget._downloadService.downloadId == widget.episode.id && mounted) { - if (event != '') { - setIsDownloading = true; - } - if (event == '100') { - // Download complete, update state and DB - if (!widget.downloaded) { - setIsDownloaded = true; - await widget._databaseService.addEpisode(widget.episode); - } - } - } - }); - - widget._audioService.playbackState.listen((event) { - if (widget._audioService.episodeId == widget.episode.id && mounted) { - setIsPlaying = event.playing; - } else { - setIsPlaying = false; - } - }); - - if (widget._audioService.episodeId == widget.episode.id && mounted) { - setIsPlaying = true; - } - - await widget._databaseService.initialise(); - - setIsDownloaded = - await widget._databaseService.episodeExists(widget.episode); - - if (!widget.isFromDownloadView) { - podcastImgFile = - File('${appDir.path}/images/podcast/${widget.episode.podcastId}.jpg'); - if (!podcastImgFile.existsSync()) { - podcastImgFile.createSync(recursive: true); - res = await widget.dio.get( - widget.podcast.image!, - options: Options( - responseType: ResponseType.bytes, - ), - ); - podcastImgFile.writeAsBytesSync(res.data); - } - } - } - - Future playBtnClick() async { - if (!widget.loading) { - if (!widget.playing) { - widget._audioService.setAudioCtx(EpisodeAudioConfig(widget.episode.id)); - setIsLoading = true; - - await widget._audioService.loadEpisode( - widget.episode, - widget.downloaded, - widget.podcast, - ); - - await widget._audioService.play(); - } else { - widget._audioService.setAudioCtx(const EpisodeAudioConfig('')); - await widget._audioService.pause(); - } - setIsLoading = false; - } - } - - void removeEpisode() async { - await widget._databaseService.removeEpisode(widget.episode); - await widget._databaseService.removePodcast(widget.podcast); - } - - void downloadBtnClick(Episodes episode) async { - widget._downloadService.setDownloadId = episode.id; - Directory appDir = await getApplicationSupportDirectory(); - - if (!widget.downloaded && !widget.isDownloading) { - widget._downloadService.download(widget.episode, widget.podcast); - } else if (widget.downloaded) { - File audioFile = File( - '${appDir.path}/episodes/${widget.podcast.id}/${widget.episode.id}.mp3'); - if (audioFile.existsSync()) { - audioFile.deleteSync(); - } - setIsDownloaded = !widget.downloaded; - removeEpisode(); - } - } - - String getFirstSentenceFromHtml(String? html) { - if (html == null || html.isEmpty) return ''; - final document = html_parser.parse(html); - final text = document.body?.text ?? ''; - final match = RegExp(r'([^.?!]*[.?!])').firstMatch(text); - String sentence = match != null ? match.group(0)!.trim() : text.trim(); - if (sentence.length > 120) { - sentence = '${sentence.substring(0, 120).trimRight()}...'; - } - return sentence; - } - - bool _isEpisodeLoading() { - return (widget._audioService.playbackState.value.processingState == - AudioProcessingState.loading || - widget._audioService.playbackState.value.processingState == - AudioProcessingState.buffering) && - widget._audioService.episodeId == widget.episode.id; - } - - Widget _buildEpisodeTitle() { - return Text( - widget.episode.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - color: FccColors.gray00, - ), - ); - } - - Widget _buildEpisodeDescription() { - final firstSentence = getFirstSentenceFromHtml(widget.episode.description); - if (firstSentence.isEmpty) return const SizedBox(height: 14); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 6), - Text( - firstSentence, - style: const TextStyle( - color: FccColors.gray10, - fontSize: 16, - ), - ), - const SizedBox(height: 14), - ], - ); - } - - Widget _buildEpisodeDate() { - final date = widget.episode.publicationDate; - return Text( - date != null ? Jiffy.parseFromDateTime(date).fromNow().toUpperCase() : '', - style: const TextStyle( - fontSize: 12, - color: FccColors.gray10, - fontWeight: FontWeight.w600, - letterSpacing: 1.1, - ), - ); - } - - Widget _buildPlayButton(bool isPlaying) { - return IconButton( - icon: _isEpisodeLoading() - ? const Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - color: FccColors.gray00, - strokeWidth: 1.8, - ), - ), - ) - : Icon( - widget.playing ? Icons.pause : Icons.play_arrow, - color: FccColors.gray00, - size: 33, - semanticLabel: widget.playing - ? context.t.podcast_pause_episode - : context.t.podcast_play_episode, - ), - onPressed: () { - playBtnClick(); - }, - iconSize: 33, - splashRadius: 16, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 48, minHeight: 48), - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - isPlaying ? FccColors.purple90 : FccColors.gray80, - ), - shape: WidgetStateProperty.all( - const CircleBorder(), - ), - ), - ); - } - - Widget _buildDownloadButton() { - final isCurrentDownload = widget.isDownloading && - widget._downloadService.downloadId == widget.episode.id; - return IconButton( - onPressed: isCurrentDownload - ? null - : () { - downloadBtnClick(widget.episode); - }, - iconSize: 20, - splashRadius: 12, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 45, minHeight: 45), - style: ButtonStyle( - shape: WidgetStateProperty.all( - const CircleBorder(), - ), - side: WidgetStateProperty.all( - const BorderSide( - color: FccColors.gray80, - width: 1, - ), - ), - ), - icon: isCurrentDownload - ? StreamBuilder( - stream: widget._downloadService.progress, - builder: (context, snapshot) { - final progress = snapshot.data; - if (progress == '100') { - return Icon( - Icons.download_done, - color: FccColors.gray10, - size: 20, - semanticLabel: context.t.download_complete, - ); - } else if (progress != null && progress != '') { - return Stack(alignment: Alignment.center, children: [ - Text( - '$progress%', - style: const TextStyle(fontSize: 14), - ), - CircularProgressIndicator( - color: Colors.white, - strokeWidth: 1, - value: double.tryParse(progress) != null - ? double.parse(progress) / 100 - : 0), - ]); - } else { - return const CircularProgressIndicator( - color: Colors.white, - strokeWidth: 1, - value: 0, - ); - } - }) - : widget.downloaded - ? Icon( - Icons.download_done, - color: FccColors.gray10, - size: 20, - semanticLabel: context.t.download_complete, - ) - : Icon( - Icons.download, - color: FccColors.gray10, - size: 20, - semanticLabel: context.t.download_episode, - ), - ); - } - - Widget _buildButtonRow(bool isPlaying) { - return Row( - children: [ - _buildPlayButton(isPlaying), - const SizedBox(width: 12), - _buildDownloadButton(), - ], - ); - } - - Widget _buildPodcastTile(BuildContext context, {bool isPlaying = false}) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildEpisodeDate(), - const SizedBox(height: 6), - _buildEpisodeTitle(), - _buildEpisodeDescription(), - _buildButtonRow(isPlaying), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final bool isPlaying = widget.playing; - return Container( - color: FccColors.gray90, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => EpisodeView( - podcast: widget.podcast, - episode: widget.episode, - ), - ), - ); - }, - child: _buildPodcastTile(context, isPlaying: isPlaying), - ), - ), - ], - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/profile/profile_view.dart b/mobile-app/lib/ui/views/profile/profile_view.dart deleted file mode 100644 index 22c919abc..000000000 --- a/mobile-app/lib/ui/views/profile/profile_view.dart +++ /dev/null @@ -1,684 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_heatmap_calendar/flutter_heatmap_calendar.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/profile/profile_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:stacked/stacked.dart'; -import 'package:url_launcher/url_launcher.dart'; - -Map calculateStreak(FccUserModel user) { - int longest = 0; - int current = 0; - - user.heatMapCal.forEach((date, value) { - if (user.heatMapCal.containsKey(date.subtract(const Duration(days: 1)))) { - current++; - } else { - if (current > longest) { - longest = current; - } - current = 1; - } - }); - - return { - 'longest': longest, - 'current': current, - }; -} - -class ProfileView extends StatelessWidget { - const ProfileView({super.key}); - - BoxBorder borderPicker(FccUserModel user) { - if (user.isDonating && user.yearsTopContributor.isNotEmpty) { - return Border.all(width: 5, color: FccColors.purple50); - } else if (user.yearsTopContributor.isNotEmpty) { - return Border.all(width: 5, color: FccColors.blue50); - } else if (user.isDonating) { - return Border.all(width: 5, color: FccColors.yellow45); - } else { - return Border.all(width: 5, color: FccColors.gray15); - } - } - - Widget buildDivider() => Divider( - color: FccColors.gray75, - thickness: 1.2, - height: 24, - ); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => ProfileViewModel(), - builder: (context, model, child) => Scaffold( - backgroundColor: FccSemanticColors.backgroundPrimary, - appBar: AppBar( - title: Text( - context.t.profile_title, - style: const TextStyle(color: FccSemanticColors.foregroundPrimary), - ), - backgroundColor: FccSemanticColors.backgroundSecondary, - iconTheme: - const IconThemeData(color: FccSemanticColors.foregroundPrimary), - ), - drawer: const DrawerWidgetView(), - body: FutureBuilder( - future: model.auth.userModel, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator(color: FccColors.yellow40)); - } - if (snapshot.hasError) { - return Text('${snapshot.error}', - style: const TextStyle( - color: FccSemanticColors.foregroundDanger)); - } - if (snapshot.hasData) { - FccUserModel user = snapshot.data!; - - final streak = calculateStreak(user); - - return Container( - color: FccSemanticColors.backgroundPrimary, - child: SingleChildScrollView( - child: Column( - children: [ - _buildHeader(user), - _buildAboutCard(context, user), - _buildInfoSection(context, user, streak), - _buildHeatmap(context, user, streak), - _buildPortfolio(user), - _buildCertifications(context, user), - ], - ), - ), - ); - } else { - return Center( - child: Text( - context.t.profile_no_userdata.toString(), - style: const TextStyle( - color: FccSemanticColors.foregroundDanger), - ), - ); - } - }, - ), - ), - ); - } - - Widget _infoRow(IconData icon, String label, String value, Color iconColor) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(icon, color: iconColor, size: 22), - const SizedBox(width: 14), - Expanded( - child: Text( - label, - style: const TextStyle( - color: FccColors.gray15, - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - ), - Text( - value, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - fontSize: 14, - ), - ), - ], - ), - ); - } - - Widget _buildInfoSection( - BuildContext context, - FccUserModel user, - Map streak, - ) { - final infoRows = [ - if (user.location != null && user.location!.trim().isNotEmpty) - _infoRow(Icons.location_on, context.t.profile_location, user.location!, - FccColors.blue50), - if (user.isDonating) - _infoRow(Icons.favorite, context.t.profile_supporter, - context.t.profile_supporter, FccColors.yellow45), - if (user.twitter != null && user.twitter!.trim().isNotEmpty) - _infoRow(Icons.alternate_email, context.t.profile_x, user.twitter!, - FccColors.blue50), - if (user.githubProfile != null && user.githubProfile!.trim().isNotEmpty) - _infoRow(Icons.code, context.t.profile_github, user.githubProfile!, - FccColors.blue50), - if (user.linkedin != null && user.linkedin!.trim().isNotEmpty) - _infoRow(Icons.business, context.t.profile_linkedin, user.linkedin!, - FccColors.blue50), - if (user.website != null && user.website!.trim().isNotEmpty) - _infoRow(Icons.language, context.t.profile_website, user.website!, - FccColors.blue50), - _infoRow(Icons.calendar_month, context.t.profile_joined, - Jiffy.parseFromDateTime(user.joinDate).yMMMM, FccColors.gray15), - _infoRow( - Icons.local_fire_department_sharp, - context.t.profile_points_label, - user.points.toString(), - FccColors.red30), - _infoRow(Icons.emoji_events, context.t.profile_longest_streak_label, - (streak['longest'] ?? 0).toString(), FccColors.orange30), - _infoRow(Icons.bolt, context.t.profile_current_streak_label, - (streak['current'] ?? 0).toString(), FccColors.orange30), - ]; - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Card( - color: FccColors.gray85, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), - child: Column( - children: [ - ...infoRows.expand((row) sync* { - yield row; - yield const Divider( - color: FccColors.gray75, height: 18, thickness: 1); - }).toList() - ..removeLast(), - ], - ), - ), - ), - ); - } - - Widget _buildHeader(FccUserModel user) { - final displayName = - user.name.trim().isEmpty ? '@${user.username}' : user.name; - - return Stack( - children: [ - Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(8, 89, 8, 6), - child: Card( - color: FccColors.gray85, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.only( - top: 60, - bottom: 24, - left: 16, - right: 16, - ), - child: Column( - children: [ - SizedBox(height: 32), - Text( - displayName, - style: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: - user.isDonating ? FccColors.yellow40 : Colors.white, - ), - ), - if (user.name.trim().isNotEmpty) ...[ - SizedBox(height: 4), - Text( - '@${user.username}', - style: TextStyle( - fontSize: 16, - color: - user.isDonating ? FccColors.yellow40 : Colors.white, - fontWeight: FontWeight.w600, - ), - ), - ] - ], - ), - ), - ), - ), - Center( - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: FccColors.blue50, - width: 3, - ), - ), - child: CircleAvatar( - radius: 54, - backgroundColor: FccColors.gray85, - backgroundImage: user.picture == '' - ? const AssetImage( - 'assets/images/placeholder-profile-img.png') - : CachedNetworkImageProvider(user.picture) - as ImageProvider, - ), - ), - if (user.isDonating || user.yearsTopContributor.isNotEmpty) - Positioned( - bottom: 4, - right: 4, - child: Container( - decoration: BoxDecoration( - color: FccColors.yellow45, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - ), - padding: const EdgeInsets.all(4), - child: Icon( - user.isDonating ? Icons.favorite : Icons.emoji_events, - color: FccColors.gray85, - size: 18, - ), - ), - ), - ], - ), - ), - ], - ); - } - - Widget _buildAboutCard(BuildContext context, FccUserModel user) { - if (user.about == null || user.about!.trim().isEmpty) { - return const SizedBox.shrink(); - } - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Card( - color: FccColors.gray85, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.chat_bubble, color: FccColors.gray00, size: 22), - const SizedBox(width: 10), - Text( - context.t.profile_about, - style: const TextStyle( - color: FccColors.gray00, - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - user.about!, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildHeatmap( - BuildContext context, FccUserModel user, Map streak) { - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Card( - color: FccColors.gray85, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - HeatMap( - startDate: Jiffy.now().subtract(months: 3).dateTime, - datasets: user.heatMapCal, - colorsets: const { - 0: FccColors.gray80, - 1: FccColors.gray45, - 4: FccColors.gray15, - 8: FccColors.gray00, - }, - defaultColor: FccColors.gray80, - scrollable: true, - colorMode: ColorMode.color, - showColorTip: false, - onClick: (value) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - backgroundColor: FccColors.gray75, - duration: const Duration(seconds: 2), - content: Text( - context.t.profile_points_on_date( - (user.heatMapCal[value] ?? 0), - Jiffy.parseFromDateTime(value).yMMMd, - ), - style: const TextStyle( - color: FccSemanticColors.foregroundPrimary), - ), - ), - ); - }, - ), - ], - ), - ), - ), - ); - } - - Widget _buildPortfolio(FccUserModel user) { - if (user.portfolio.isEmpty) return const SizedBox.shrink(); - return Container( - width: double.infinity, - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Card( - color: FccColors.gray85, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - child: _buildPortfolioWidget(user), - ), - ), - ); - } - - Widget _buildPortfolioWidget(FccUserModel user) { - return Column( - children: [ - ...user.portfolio.map( - (portfolio) => InkWell( - onTap: () => launchUrl(Uri.parse(portfolio.url!)), - child: Card( - margin: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - color: FccColors.gray85, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (portfolio.image != null && portfolio.image!.isNotEmpty) - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.network( - portfolio.image!, - height: 150, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - const SizedBox(height: 12), - Text( - portfolio.title!, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - if (portfolio.description != null && - portfolio.description!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - portfolio.description!, - style: const TextStyle( - fontSize: 14, - color: FccColors.gray15, - ), - ), - ), - const SizedBox(height: 12), - Row( - children: [ - const Icon(Icons.link, - color: FccColors.blue50, size: 20), - const SizedBox(width: 8), - Expanded( - child: GestureDetector( - onTap: () => launchUrl(Uri.parse(portfolio.url!)), - child: Text( - portfolio.url!, - style: const TextStyle( - fontSize: 14, - color: FccColors.blue50, - decoration: TextDecoration.underline, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ], - ); - } - - Widget _buildTrophyList(List certs) { - return Column( - children: certs.where((cert) => cert['show']).map((cert) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 0), - decoration: BoxDecoration( - color: FccColors.gray85, - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(Icons.emoji_events, color: FccColors.yellow40, size: 26), - const SizedBox(width: 14), - Expanded( - child: Text( - cert['title'], - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 15, - ), - ), - ), - ], - ), - ), - ); - }).toList(), - ); - } - - Widget _buildCertifications(BuildContext context, FccUserModel user) { - final hasModernCert = user.isRespWebDesignCert || - user.is2018DataVisCert || - user.isFrontEndLibsCert || - user.isJsAlgoDataStructCert || - user.isApisMicroservicesCert || - user.isQaCertV7 || - user.isInfosecCertV7 || - user.isFullStackCert || - user.isSciCompPyCertV7 || - user.isDataAnalysisPyCertV7 || - user.isMachineLearningPyCertV7 || - user.isRelationalDatabaseCertV8; - - final hasLegacyCert = user.isFrontEndCert || - user.isBackEndCert || - user.isDataVisCert || - user.isInfosecQaCert; - - final currentCerts = [ - { - 'show': user.isRespWebDesignCert, - 'title': context.t.cert_responsive_web_design, - 'certSlug': 'responsive-web-design', - }, - { - 'show': user.isJsAlgoDataStructCert, - 'title': context.t.cert_javascript_algorithms, - 'certSlug': 'javascript-algorithms-and-data-structures' - }, - { - 'show': user.isFrontEndLibsCert, - 'title': context.t.cert_front_end_libraries, - 'certSlug': 'front-end-development-libraries', - }, - { - 'show': user.is2018DataVisCert, - 'title': context.t.cert_data_visualization, - 'certSlug': 'data-visualization', - }, - { - 'show': user.isApisMicroservicesCert, - 'title': context.t.cert_back_end_development_apis, - 'certSlug': 'back-end-development-and-apis', - }, - { - 'show': user.isQaCertV7, - 'title': context.t.cert_quality_assurance, - 'certSlug': 'quality-assurance-v7', - }, - { - 'show': user.isInfosecCertV7, - 'title': context.t.cert_information_security, - 'certSlug': 'information-security-v7', - }, - { - 'show': user.isSciCompPyCertV7, - 'title': context.t.cert_scientific_computing_python, - 'certSlug': 'scientific-computing-with-python-v7', - }, - { - 'show': user.isDataAnalysisPyCertV7, - 'title': context.t.cert_data_analysis_python, - 'certSlug': 'data-analysis-with-python-v7', - }, - { - 'show': user.isMachineLearningPyCertV7, - 'title': context.t.cert_machine_learning_python, - 'certSlug': 'machine-learning-with-python-v7', - }, - { - 'show': user.isRelationalDatabaseCertV8, - 'title': context.t.cert_relational_database, - 'certSlug': 'relational-database-v8', - }, - { - 'show': user.isCollegeAlgebraPyCertV8, - 'title': context.t.cert_college_algebra_python, - 'certSlug': 'college-algebra-with-python-v8', - }, - { - 'show': user.isFoundationalCSharpCertV8, - 'title': context.t.cert_foundational_csharp, - 'certSlug': 'foundational-c-sharp-with-microsoft', - } - ]; - - final legacyCerts = [ - { - 'show': user.isFrontEndCert, - 'title': context.t.cert_front_end, - 'certSlug': 'legacy-front-end' - }, - { - 'show': user.isBackEndCert, - 'title': context.t.cert_back_end, - 'certSlug': 'legacy-back-end' - }, - { - 'show': user.isDataVisCert, - 'title': context.t.cert_data_visualization, - 'certSlug': 'legacy-data-visualization' - }, - { - 'show': user.isInfosecQaCert, - 'title': context.t.cert_information_security_quality_assurance, - 'certSlug': 'information-security-and-quality-assurance' - }, - { - 'show': user.isFullStackCert, - 'title': context.t.cert_full_stack, - 'certSlug': 'full-stack' - } - ]; - - return Container( - width: double.infinity, - margin: const EdgeInsets.fromLTRB(8, 6, 8, 56), - child: Card( - color: FccColors.gray85, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - child: Column( - children: [ - Padding( - padding: EdgeInsets.only(top: 8, bottom: 8), - child: Text( - context.t.profile_certifications, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 20, - height: 1.25, - fontWeight: FontWeight.w700, - ), - ), - ), - if (!hasModernCert && !hasLegacyCert) - Text( - context.t.profile_no_certifications, - style: TextStyle( - fontSize: 16, - color: Colors.white, - ), - textAlign: TextAlign.center, - ), - if (hasModernCert) _buildTrophyList(currentCerts), - if (hasLegacyCert) _buildTrophyList(legacyCerts), - ], - ), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/profile/profile_viewmodel.dart b/mobile-app/lib/ui/views/profile/profile_viewmodel.dart deleted file mode 100644 index e32f9eac2..000000000 --- a/mobile-app/lib/ui/views/profile/profile_viewmodel.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:stacked/stacked.dart'; - -// import 'dart:developer'; -class ProfileViewModel extends BaseViewModel { - final AuthenticationService auth = locator(); -} diff --git a/mobile-app/lib/ui/views/settings/delete-account/delete_account_view.dart b/mobile-app/lib/ui/views/settings/delete-account/delete_account_view.dart deleted file mode 100644 index 531b0402a..000000000 --- a/mobile-app/lib/ui/views/settings/delete-account/delete_account_view.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/views/settings/delete-account/delete_account_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class DeleteAccountView extends StatelessWidget { - const DeleteAccountView({super.key}); - - @override - Widget build(BuildContext context) { - const paragraphTextStyle = TextStyle( - fontSize: 18, - height: 1.2, - ); - - return ViewModelBuilder.reactive( - viewModelBuilder: () => DeleteAccountViewModel(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: Text( - context.t.settings_delete_account, - ), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - context.t.delete_account_message_one, - style: paragraphTextStyle, - textAlign: TextAlign.center, - ), - const SizedBox( - height: 8, - ), - Text( - context.t.delete_account_message_two, - style: paragraphTextStyle, - textAlign: TextAlign.center, - ), - const SizedBox( - height: 8, - ), - Text( - context.t.delete_account_message_three, - style: paragraphTextStyle, - textAlign: TextAlign.center, - ), - const SizedBox( - height: 8, - ), - InkWell( - onTap: () { - launchUrl( - Uri.parse('mailto:support@freecodecamp.org'), - ); - }, - child: Text( - 'support@freecodecamp.org', - style: paragraphTextStyle.copyWith( - decoration: TextDecoration.underline, - ), - ), - ), - const SizedBox( - height: 12, - ), - buildDivider(), - const SizedBox( - height: 12, - ), - Row( - children: [ - Expanded( - child: TextButton( - onPressed: model.processing - ? null - : () => model.deleteAccount(context), - style: TextButton.styleFrom( - backgroundColor: Colors.red.shade100, - foregroundColor: Colors.red.shade900, - side: BorderSide( - width: 2, - color: Colors.red.shade900, - ), - disabledBackgroundColor: Colors.red.shade50, - disabledForegroundColor: Colors.red.shade700, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - child: Text( - context.t.delete_account_message_four, - textAlign: TextAlign.center, - style: paragraphTextStyle, - ), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/settings/delete-account/delete_account_viewmodel.dart b/mobile-app/lib/ui/views/settings/delete-account/delete_account_viewmodel.dart deleted file mode 100644 index 09590b6d7..000000000 --- a/mobile-app/lib/ui/views/settings/delete-account/delete_account_viewmodel.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:developer'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/enums/dialog_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/dio_service.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class DeleteAccountViewModel extends BaseViewModel { - final _authenticationService = locator(); - final _navigator = locator(); - final _snackbar = locator(); - final _dialogService = locator(); - - final Dio _dio = DioService.dio; - bool processing = false; - - void deleteAccount(BuildContext context) async { - processing = true; - DialogResponse? res = await _dialogService.showCustomDialog( - barrierDismissible: true, - variant: DialogType.deleteAccount, - title: context.t.settings_delete_account, - description: context.t.delete_account_are_you_sure, - mainButtonTitle: context.t.settings_delete_account, - ); - - if (res?.confirmed == true) { - showDialog( - context: context, - barrierDismissible: false, - routeSettings: const RouteSettings( - name: '/delete-account-dialog', - ), - builder: (context) { - return PopScope( - canPop: false, - child: SimpleDialog( - title: Text( - context.t.delete_account_deleting, - ), - contentPadding: const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 24.0), - backgroundColor: const Color(0xFF2A2A40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - children: const [ - Center( - child: CircularProgressIndicator(), - ), - ], - ), - ); - }, - ); - notifyListeners(); - - try { - Response res = await _dio.post( - '${AuthenticationService.baseApiURL}/account/delete', - options: Options( - headers: { - 'CSRF-Token': _authenticationService.csrfToken, - 'Cookie': - 'jwt_access_token=${_authenticationService.jwtAccessToken}; _csrf=${_authenticationService.csrf};', - }, - ), - ); - - if (res.statusCode == 200) { - log('Account deleted'); - await _authenticationService.logout(); - _navigator.clearStackAndShow('/'); - _snackbar.showSnackbar( - title: context.t.delete_success, - message: '', - ); - } else { - log('Account deletion failed'); - _navigator.back(); - _snackbar.showSnackbar( - title: context.t.delete_failed, - message: '', - ); - } - } catch (err) { - _navigator.back(); - _snackbar.showSnackbar( - title: context.t.delete_failed, - message: '', - ); - } - } - - processing = false; - notifyListeners(); - } -} diff --git a/mobile-app/lib/ui/views/settings/settings_view.dart b/mobile-app/lib/ui/views/settings/settings_view.dart deleted file mode 100644 index 2a28d073f..000000000 --- a/mobile-app/lib/ui/views/settings/settings_view.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/ui/views/settings/delete-account/delete_account_view.dart'; -import 'package:freecodecamp/ui/views/settings/settings_viewmodel.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; -import 'package:stacked/stacked.dart'; - -class SettingsView extends StatelessWidget { - const SettingsView({super.key}); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => SettingsViewModel(), - onViewModelReady: (viewModel) => viewModel.init(), - builder: (context, model, child) => Scaffold( - appBar: AppBar( - title: Text( - context.t.settings_title, - ), - ), - drawer: const DrawerWidgetView(), - body: Column( - children: [ - if (AuthenticationService.staticIsloggedIn) ...[ - ListTile( - leading: const Icon(Icons.delete_forever), - title: Text( - context.t.settings_delete_account, - ), - subtitle: Text( - context.t.settings_delete_account_description, - ), - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const DeleteAccountView(), - settings: const RouteSettings(name: '/delete-account'), - ), - ), - ), - buildDivider(), - ], - ListTile( - leading: const Icon(Icons.dataset_linked), - title: Text(context.t.settings_reset_cache), - subtitle: Text( - context.t.settings_reset_cache_description, - ), - onTap: () => model.resetCache(context), - ), - buildDivider(), - ListTile( - leading: const Icon(Icons.privacy_tip), - title: Text( - context.t.settings_privacy_policy, - ), - subtitle: Text( - context.t.settings_privacy_policy_description, - ), - onTap: () => model.openPrivacyPolicy(), - ), - buildDivider(), - // language dropdown selector - if (model.isDev) - ListTile( - leading: const Icon(Icons.language), - title: Text(context.t.settings_language), - subtitle: DropdownButton( - isExpanded: true, - value: model.localeService.currentLocaleName, - dropdownColor: const Color(0xFF0a0a23), - onChanged: (String? newValue) { - model.localeService.changeLocale(newValue!); - }, - items: [...model.localeService.localeNames] - .map>( - (String value) { - return DropdownMenuItem( - value: value, - child: Text( - value.toUpperCase(), - style: const TextStyle(color: Colors.white), - ), - ); - }, - ).toList(), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/views/settings/settings_viewmodel.dart b/mobile-app/lib/ui/views/settings/settings_viewmodel.dart deleted file mode 100644 index 990862dac..000000000 --- a/mobile-app/lib/ui/views/settings/settings_viewmodel.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/enums/dialog_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/widgets/setup_dialog_ui.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class SettingsViewModel extends BaseViewModel { - final DialogService _dialogService = locator(); - final LocaleService localeService = locator(); - final developerService = locator(); - - bool isDev = false; - - void init() async { - setupDialogUi(); - isDev = await developerService.developmentMode(); - notifyListeners(); - } - - void resetCache(BuildContext context) async { - DialogResponse? res = await _dialogService.showCustomDialog( - barrierDismissible: true, - variant: DialogType.buttonForm, - title: context.t.settings_reset_cache, - description: context.t.settings_reset_cache_description, - mainButtonTitle: context.t.settings_reset_cache_confirm, - ); - - if (res?.confirmed == true) { - SharedPreferences prefs = await SharedPreferences.getInstance(); - prefs.clear(); - } - } - - void openPrivacyPolicy() { - launchUrl( - Uri.parse('https://www.freecodecamp.org/news/privacy-policy/'), - customTabsOptions: const CustomTabsOptions( - shareState: CustomTabsShareState.on, - urlBarHidingEnabled: true, - showTitle: true, - browser: CustomTabsBrowserConfiguration( - fallbackCustomTabs: [ - 'org.mozilla.firefox', - 'com.microsoft.emmx', - ], - ), - ), - safariVCOptions: const SafariViewControllerOptions( - barCollapsingEnabled: true, - ), - ); - } -} diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_web_buttton.dart b/mobile-app/lib/ui/widgets/drawer_widget/drawer_web_buttton.dart deleted file mode 100644 index 2f140c5f4..000000000 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_web_buttton.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:firebase_analytics/firebase_analytics.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; - -class CustomTabButton extends StatefulWidget { - const CustomTabButton({ - super.key, - required this.url, - required this.icon, - required this.component, - }); - - final String url; - final String component; - final IconData icon; - - void startCustomTabs(String url) async { - String location; - switch (url) { - case 'https://www.freecodecamp.org/news/privacy-policy/': - location = 'Privacy Policy'; - break; - case 'https://www.freecodecamp.org/donate/': - location = 'Donation'; - break; - default: - location = url; - } - await FirebaseAnalytics.instance.logScreenView( - screenClass: 'Web View - $location', - screenName: 'Web View - $location', - ); - launchUrl( - Uri.parse(url), - customTabsOptions: const CustomTabsOptions( - shareState: CustomTabsShareState.on, - urlBarHidingEnabled: true, - showTitle: true, - browser: CustomTabsBrowserConfiguration( - fallbackCustomTabs: [ - 'org.mozilla.firefox', - 'com.microsoft.emmx', - ], - ), - ), - safariVCOptions: const SafariViewControllerOptions( - barCollapsingEnabled: true, - ), - ); - } - - @override - State createState() => _CustomTabButtonState(); -} - -class _CustomTabButtonState extends State { - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10.0), - child: ListTile( - dense: true, - onTap: () { - widget.startCustomTabs(widget.url); - }, - leading: Icon( - widget.icon, - color: Colors.white, - ), - title: Text( - widget.component, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.white, - letterSpacing: 0.5, - ), - ), - ), - ); - } -} diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart deleted file mode 100644 index 53648c432..000000000 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_tile.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_web_buttton.dart'; -import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart'; -import 'package:stacked/stacked.dart'; - -class DrawerWidgetView extends StatelessWidget { - const DrawerWidgetView({ - super.key, - }); - - @override - Widget build(BuildContext context) { - return ViewModelBuilder.reactive( - viewModelBuilder: () => DrawerWidgetViewModel(), - onViewModelReady: (model) => model.initState(), - builder: (context, model, child) => Drawer( - backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - children: [ - Expanded( - child: ScrollShadow( - color: Colors.black, - child: ListView( - controller: model.scrollController, - children: [ - ListTile( - contentPadding: const EdgeInsets.all(16), - leading: Image.asset( - 'assets/images/placeholder-profile-img.png', - width: 75, - height: 75, - ), - title: model.loggedIn - ? FutureBuilder( - future: model.auth.userModel, - builder: (context, snapshot) { - if (snapshot.hasData) { - FccUserModel user = - snapshot.data as FccUserModel; - return Text( - user.name.isEmpty - ? user.username - : user.name, - ); - } - - return Text( - context.t.anonymous_user, - ); - }) - : Text( - context.t.anonymous_user, - ), - subtitle: Text( - model.loggedIn - ? context.t.coolest_camper - : context.t.login_save_progress, - ), - isThreeLine: true, - onTap: () { - if (model.loggedIn) { - model.routeComponent('PROFILE', context); - } - }, - ), - buildDivider(), - DrawerTile( - key: const Key('daily-challenges'), - component: context.t.quick_action_daily_challenges, - icon: Icons.extension, - route: () { - model.routeComponent('DAILY_CHALLENGES', context); - }, - ), - DrawerTile( - key: const Key('learn'), - component: context.t.learn, - icon: '', - route: () { - model.routeComponent('LEARN', context); - }, - ), - DrawerTile( - key: const Key('news'), - component: context.t.tutorials, - icon: Icons.forum_outlined, - route: () { - model.routeComponent('NEWS', context); - }, - ), - DrawerTile( - key: const Key('podcasts'), - component: context.t.podcasts, - icon: Icons.podcasts_outlined, - route: () { - model.routeComponent('PODCAST', context); - }, - ), - if (!Platform.isIOS) - DrawerTile( - key: const Key('code-radio'), - component: context.t.code_radio, - icon: Icons.radio, - route: () { - model.routeComponent('CODERADIO', context); - }, - ), - buildDivider(), - CustomTabButton( - key: const Key('donate'), - component: context.t.donate, - url: 'https://www.freecodecamp.org/donate/', - icon: Icons.favorite, - ), - DrawerTile( - key: const Key('settings'), - component: context.t.settings, - icon: Icons.settings, - route: () { - model.routeComponent('SETTINGS', context); - }, - ), - buildDivider(), - CustomTabButton( - key: const Key('report-issue'), - component: context.t.report_an_issue, - url: 'https://github.com/freeCodeCamp/mobile', - icon: Icons.bug_report, - ), - DrawerTile( - key: const Key('auth'), - component: - model.loggedIn ? context.t.logout : context.t.login, - icon: model.loggedIn ? Icons.logout : Icons.login, - textColor: model.loggedIn - ? const Color.fromARGB(255, 230, 59, 59) - : null, - route: () { - model.loggedIn - ? model.auth.logout() - : model.routeComponent('LOGIN', context); - }), - ], - ), - ), - ), - const SafeArea( - minimum: EdgeInsets.only(bottom: 16), - child: Text( - 'freeCodeCamp is a donor-supported tax-exempt 501(c)(3) nonprofit organization', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 10, color: Colors.white70), - ), - ) - ], - ), - ), - ); - } -} - -Widget buildDivider() { - return Divider( - color: Colors.white.withValues(alpha: 0.12), - indent: 16, - endIndent: 16, - thickness: 1, - ); -} diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart deleted file mode 100644 index b6e7618cc..000000000 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/developer_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/ui/views/code_radio/code_radio_view.dart'; -import 'package:freecodecamp/ui/views/learn/daily_challenge/daily_challenge_view.dart'; -import 'package:freecodecamp/ui/views/learn/landing/landing_view.dart'; -import 'package:freecodecamp/ui/views/login/native_login_view.dart'; -import 'package:freecodecamp/ui/views/news/news-view-handler/news_view_handler_view.dart'; -import 'package:freecodecamp/ui/views/podcast/podcast-list/podcast_list_view.dart'; -import 'package:freecodecamp/ui/views/profile/profile_view.dart'; -import 'package:freecodecamp/ui/views/settings/settings_view.dart'; -import 'package:stacked/stacked.dart'; -import 'package:stacked_services/stacked_services.dart'; - -class DrawerWidgetViewModel extends BaseViewModel { - final AuthenticationService auth = locator(); - - final SnackbarService snack = locator(); - final LocaleService _localeService = locator(); - - bool _loggedIn = false; - bool get loggedIn => _loggedIn; - - bool _devMode = false; - bool get devmode => _devMode; - - final ScrollController _scrollController = ScrollController(); - ScrollController get scrollController => _scrollController; - - void initState() async { - _loggedIn = AuthenticationService.staticIsloggedIn; - await devMode(); - notifyListeners(); - auth.isLoggedIn.listen((event) { - _loggedIn = event; - notifyListeners(); - }); - } - - static final _developerService = locator(); - - devMode() async { - if (await _developerService.developmentMode()) { - _devMode = true; - notifyListeners(); - } - } - - void snackbar() { - final t = lookupAppLocalizations(_localeService.locale); - snack.showSnackbar( - title: t.coming_soon_web, - message: '', - ); - } - - void loginSnack() { - final t = lookupAppLocalizations(_localeService.locale); - snack.showSnackbar(message: '', title: t.login_coming_soon); - } - - void routeComponent(view, context) async { - switch (view) { - case 'DAILY_CHALLENGES': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const DailyChallengeView(), - settings: const RouteSettings( - name: '/daily-challenge', - ), - ), - ); - break; - case 'LEARN': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const LearnLandingView(), - settings: const RouteSettings( - name: '/learn', - ), - ), - ); - break; - case 'NEWS': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const NewsViewHandlerView(), - settings: const RouteSettings( - name: '/news', - ), - ), - ); - break; - case 'PODCAST': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const PodcastListView(), - settings: const RouteSettings( - name: '/podcasts-list', - ), - ), - ); - break; - case 'CODERADIO': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animatiom2) => - const CodeRadioView(), - settings: const RouteSettings( - name: '/code-radio', - ), - ), - ); - break; - case 'PROFILE': - Navigator.pushReplacement( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const ProfileView(), - settings: const RouteSettings( - name: '/profile', - ), - ), - ); - break; - case 'LOGIN': - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const NativeLoginView(), - settings: const RouteSettings( - name: '/login', - ), - ), - ); - break; - case 'SETTINGS': - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: Duration.zero, - pageBuilder: (context, animation1, animation2) => - const SettingsView(), - settings: const RouteSettings( - name: '/settings', - ), - ), - ); - break; - } - } -} diff --git a/mobile-app/lib/ui/widgets/setup_dialog_ui.dart b/mobile-app/lib/ui/widgets/setup_dialog_ui.dart deleted file mode 100644 index 7c18512c9..000000000 --- a/mobile-app/lib/ui/widgets/setup_dialog_ui.dart +++ /dev/null @@ -1,709 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/enums/dialog_type.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/service/learn/learn_service.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:stacked_services/stacked_services.dart'; - -import 'package:url_launcher/url_launcher.dart'; - -void setupDialogUi() { - final dialogService = locator(); - - final builders = { - DialogType.basic: (BuildContext context, DialogRequest sheetRequest, - Function(DialogResponse) completer) => - _BasicDialog(request: sheetRequest, completer: completer), - DialogType.buttonForm: (BuildContext context, DialogRequest sheetRequest, - Function(DialogResponse) completer) => - _buttonDialog(request: sheetRequest, onDialogTap: completer), - DialogType.askForHelp: (BuildContext context, DialogRequest sheetRequest, - Function(DialogResponse) completer) => - _askForHelpDialog(request: sheetRequest, onDialogTap: completer), - DialogType.askForHelpInput: (BuildContext context, - DialogRequest sheetRequest, Function(DialogResponse) completer) => - _askForHelpInputDialogue(request: sheetRequest, onDialogTap: completer), - DialogType.deleteAccount: (BuildContext context, DialogRequest sheetRequest, - Function(DialogResponse) completer) => - _deleteAccountDialog(request: sheetRequest, onDialogTap: completer), - }; - - dialogService.registerCustomDialogBuilders(builders); -} - -// ignore: camel_case_types -class _buttonDialog extends HookWidget { - final DialogRequest request; - final Function(DialogResponse) onDialogTap; - - const _buttonDialog({required this.request, required this.onDialogTap}); - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: const Color(0xFF0a0a23), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - child: Wrap( - runSpacing: 20, - children: [ - Text( - request.title as String, - style: const TextStyle( - fontSize: 24, - height: 1.5, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - Text( - request.description as String, - style: const TextStyle( - height: 1.5, - fontSize: 16, - color: Colors.white, - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide(width: 2, color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: true)), - }, - child: Text( - request.mainButtonTitle as String, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide(width: 2, color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: false)), - }, - child: Text( - request.secondaryButtonTitle ?? context.t.cancel, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - ), - ), - ], - ), - ) - ], - ), - ); - } -} - -// ignore: camel_case_types -class _deleteAccountDialog extends HookWidget { - final DialogRequest request; - final Function(DialogResponse) onDialogTap; - - const _deleteAccountDialog( - {required this.request, required this.onDialogTap}); - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: const Color(0xFF0a0a23), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - child: Wrap( - runSpacing: 20, - children: [ - Text( - request.title as String, - style: const TextStyle( - fontSize: 24, - height: 1.5, - fontWeight: FontWeight.bold, - ), - ), - Text( - request.description as String, - style: const TextStyle( - height: 1.5, - fontSize: 16, - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - backgroundColor: Colors.red.shade100, - foregroundColor: Colors.red.shade900, - side: BorderSide( - width: 2, - color: Colors.red.shade900, - ), - disabledBackgroundColor: Colors.red.shade50, - disabledForegroundColor: Colors.red.shade700, - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: true)), - }, - child: Text( - request.mainButtonTitle?.toUpperCase() as String, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide(width: 2, color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: false)), - }, - child: Text( - request.secondaryButtonTitle ?? context.t.cancel, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 16), - ), - ), - ), - ], - ), - ) - ], - ), - ); - } -} - -// ignore: camel_case_types, unused_element -class _buttonDialog2 extends HookWidget { - final DialogRequest request; - final Function(DialogResponse) onDialogTap; - - const _buttonDialog2({required this.request, required this.onDialogTap}); - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: const Color(0xFF0a0a23), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(32.0), - child: Row( - children: [ - Text( - request.title as String, - textAlign: TextAlign.start, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(left: 32, right: 32.0), - child: Row( - children: [ - Expanded( - child: Text( - request.description as String, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - Padding( - padding: - const EdgeInsets.only(left: 32, top: 8, right: 32, bottom: 8), - child: SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1), - side: const BorderSide(width: 2, color: Colors.white), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(data: 'confirmed')), - }, - child: Text( - request.mainButtonTitle as String, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _BasicDialog extends StatelessWidget { - final DialogRequest request; - final Function(DialogResponse) completer; - const _BasicDialog({required this.request, required this.completer}); - - @override - Widget build(BuildContext context) { - return Dialog(child: Container()); - } -} - -// ignore: camel_case_types -class _askForHelpInputDialogue extends HookWidget { - final DialogRequest request; - final Function(DialogResponse) onDialogTap; - - const _askForHelpInputDialogue({ - required this.request, - required this.onDialogTap, - }); - - @override - Widget build(BuildContext context) { - final requestData = useState>(request.data ?? {}); - final charCount = - useState(requestData.value['issueDescription']?.length ?? 0); - final textController = useTextEditingController( - text: requestData.value['issueDescription'] ?? '', - ); - - return Dialog( - backgroundColor: FccColors.gray90, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - child: Wrap( - runSpacing: 20, - children: [ - Text( - request.title as String, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: FccColors.gray00, - ), - ), - Text( - request.description as String, - style: const TextStyle( - fontSize: 16, - color: FccColors.gray05, - ), - ), - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: requestData.value['readSearchAskChecked'] ?? - false, - onChanged: (value) { - requestData.value = { - ...requestData.value, - 'readSearchAskChecked': value, - }; - }, - activeColor: FccColors.gray00, - ), - Flexible( - child: RichText( - text: TextSpan( - text: context.t.forum_tried, - style: const TextStyle( - fontSize: 14, - color: FccColors.gray00, - ), - children: [ - TextSpan( - text: context.t.forum_read_search_ask, - style: const TextStyle( - fontSize: 14, - color: FccColors.gray00, - decoration: TextDecoration.underline, - decorationColor: FccColors.gray00, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl(Uri.parse( - '$forumLocation/t/how-to-get-help-when-you-are-stuck-coding/19514')); - }, - ), - TextSpan( - text: context.t.forum_method, - style: - const TextStyle(color: FccColors.gray00), - ), - ], - ), - ), - ), - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: requestData.value['similarQuestionsChecked'] ?? - false, - onChanged: (value) { - requestData.value = { - ...requestData.value, - 'similarQuestionsChecked': value, - }; - }, - activeColor: FccColors.gray00, - ), - Flexible( - child: RichText( - text: TextSpan( - text: context.t.forum_searched_for, - style: const TextStyle( - fontSize: 14, - color: FccColors.gray00, - ), - children: [ - TextSpan( - text: context.t.forum_similar_questions, - style: const TextStyle( - fontSize: 14, - color: FccColors.gray00, - decoration: TextDecoration.underline, - decorationColor: FccColors.gray00, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - final query = Uri.encodeComponent( - '${requestData.value['blockName']} - ${requestData.value['challengeName']} in:title'); - launchUrl(Uri.parse( - '$forumLocation/search?q=$query')); - }, - ), - ], - ), - ), - ), - ], - ), - ], - ), - if (charCount.value < 50) - Text( - context.t.forum_enter_more_chars( - (50 - charCount.value).toInt(), - ), - style: const TextStyle( - fontSize: 14, - color: FccColors.gray00, - fontWeight: FontWeight.bold, - ), - ), - TextField( - controller: textController, - onChanged: (value) { - requestData.value['issueDescription'] = value; - requestData.value = Map.from(requestData.value); - charCount.value = value.length; - }, - decoration: InputDecoration( - hintText: context.t.forum_issue_hint, - border: const OutlineInputBorder(), - ), - maxLines: 5, - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.gray75, - foregroundColor: FccColors.gray00, - side: const BorderSide(width: 2, color: FccColors.gray00), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: - requestData.value['readSearchAskChecked'] == true && - requestData.value['similarQuestionsChecked'] == - true && - charCount.value >= 50 - ? () => { - onDialogTap( - DialogResponse( - confirmed: true, - data: textController.text, - ), - ), - } - : null, - child: Text( - request.mainButtonTitle as String, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.gray75, - foregroundColor: FccColors.gray00, - side: const BorderSide(width: 2, color: FccColors.gray00), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: false)), - }, - child: Text( - request.secondaryButtonTitle ?? context.t.cancel, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ) - ], - ), - ); - } -} - -// ignore: camel_case_types -class _askForHelpDialog extends HookWidget { - final DialogRequest request; - final Function(DialogResponse) onDialogTap; - - const _askForHelpDialog({required this.request, required this.onDialogTap}); - - @override - Widget build(BuildContext context) { - final requestData = useState>(request.data ?? {}); - - return Dialog( - backgroundColor: FccColors.gray90, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - child: Wrap( - runSpacing: 20, - children: [ - Text( - request.title as String, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: FccColors.gray00, - ), - ), - RichText( - text: TextSpan( - text: context.t.forum_help_dialog_prefix, - style: const TextStyle( - fontSize: 16, - color: FccColors.gray05, - ), - children: [ - TextSpan( - text: context.t.forum_read_search_ask, - style: const TextStyle( - fontSize: 16, - color: FccColors.gray00, - decoration: TextDecoration.underline, - decorationColor: FccColors.gray00, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl(Uri.parse( - '$forumLocation/t/how-to-get-help-when-you-are-stuck-coding/19514')); - }, - ), - TextSpan( - text: context.t.forum_help_dialog_suffix, - style: const TextStyle(color: FccColors.gray05), - ), - ], - ), - ), - RichText( - text: TextSpan( - text: context.t.forum_before_post, - style: const TextStyle( - fontSize: 16, - color: FccColors.gray05, - ), - children: [ - TextSpan( - text: context.t.forum_check_answered, - style: const TextStyle( - fontSize: 16, - color: FccColors.gray00, - decoration: TextDecoration.underline, - decorationColor: FccColors.gray00, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - final query = Uri.encodeComponent( - '${requestData.value['blockName']} - ${requestData.value['challengeName']} in:title'); - launchUrl( - Uri.parse('$forumLocation/search?q=$query')); - }, - ), - const TextSpan( - text: '.', - style: TextStyle(color: FccColors.gray05), - ), - ], - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.gray75, - foregroundColor: FccColors.gray00, - side: const BorderSide(width: 2, color: FccColors.gray00), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: true)), - }, - child: Text( - request.mainButtonTitle as String, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - SizedBox( - height: 50, - width: MediaQuery.of(context).size.width, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: FccColors.gray75, - foregroundColor: FccColors.gray00, - side: const BorderSide(width: 2, color: FccColors.gray00), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(0), - ), - ), - onPressed: () => { - onDialogTap(DialogResponse(confirmed: false)), - }, - child: Text( - request.secondaryButtonTitle ?? context.t.cancel, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ) - ], - ), - ); - } -} diff --git a/mobile-app/lib/utils/custom_tab.dart b/mobile-app/lib/utils/custom_tab.dart new file mode 100644 index 000000000..681a92b94 --- /dev/null +++ b/mobile-app/lib/utils/custom_tab.dart @@ -0,0 +1,18 @@ +import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; + +Future openInCustomTab(String url) { + return launchUrl( + Uri.parse(url), + customTabsOptions: const CustomTabsOptions( + shareState: CustomTabsShareState.on, + urlBarHidingEnabled: true, + showTitle: true, + browser: CustomTabsBrowserConfiguration( + fallbackCustomTabs: ['org.mozilla.firefox', 'com.microsoft.emmx'], + ), + ), + safariVCOptions: const SafariViewControllerOptions( + barCollapsingEnabled: true, + ), + ); +} diff --git a/mobile-app/lib/utils/date_utils.dart b/mobile-app/lib/utils/date_utils.dart new file mode 100644 index 000000000..9ecd4e9b2 --- /dev/null +++ b/mobile-app/lib/utils/date_utils.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart'; +import 'package:jiffy/jiffy.dart'; + +String parseDate(String date) { + try { + final jiffyDate = Jiffy.parseFromDateTime(DateTime.parse(date)); + return Jiffy.parseFromJiffy(jiffyDate).fromNow().toUpperCase(); + } catch (e) { + debugPrint('Error parsing date: $e'); + return ''; + } +} diff --git a/mobile-app/lib/utils/helpers.dart b/mobile-app/lib/utils/helpers.dart deleted file mode 100644 index a8f066679..000000000 --- a/mobile-app/lib/utils/helpers.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:io'; - -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -Future filesToMarkdown(Challenge challenge, String editorText) async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - final challengeFiles = challenge.files; - final bool moreThanOneFile = challengeFiles.length > 1; - String markdownStr = '\n'; - - for (var challengeFile in challengeFiles) { - final fileName = moreThanOneFile - ? '/* file: ${challengeFile.name}.${challengeFile.ext} */\n' - : ''; - final fileType = challengeFile.ext.name; - final fileContent = prefs.getString( - '${challenge.id}.${challengeFile.name}.${challengeFile.ext.value}') ?? - challengeFile.contents; - markdownStr += '```$fileType\n$fileName$fileContent\n```\n\n'; - } - - return markdownStr; -} - -Future getDeviceInfo(BuildContext context) async { - final deviceInfoPlugin = DeviceInfoPlugin(); - - if (Platform.isAndroid) { - final deviceInfo = await deviceInfoPlugin.androidInfo; - return '${deviceInfo.model} - Android ${deviceInfo.version.release} - Android SDK ${deviceInfo.version.sdkInt}'; - } else if (Platform.isIOS) { - final deviceInfo = await deviceInfoPlugin.iosInfo; - return '${deviceInfo.model} - ${deviceInfo.systemName}${deviceInfo.systemVersion}'; - } else { - return context.t.unrecognized_device; - } -} diff --git a/mobile-app/lib/utils/riverpod.dart b/mobile-app/lib/utils/riverpod.dart new file mode 100644 index 000000000..aa9bfb0f7 --- /dev/null +++ b/mobile-app/lib/utils/riverpod.dart @@ -0,0 +1 @@ +Duration? noRetry(int retryCount, Object error) => null; diff --git a/mobile-app/lib/utils/upgrade_controller.dart b/mobile-app/lib/utils/upgrade_controller.dart deleted file mode 100644 index 06a4d0a08..000000000 --- a/mobile-app/lib/utils/upgrade_controller.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; -import 'package:upgrader/upgrader.dart'; - -var upgraderController = Upgrader( - minAppVersion: RemoteConfigService.remoteConfig.getString('min_app_version'), -); diff --git a/mobile-app/lib/widgets/dialog/confirm_dialog.dart b/mobile-app/lib/widgets/dialog/confirm_dialog.dart new file mode 100644 index 000000000..3bcd9089a --- /dev/null +++ b/mobile-app/lib/widgets/dialog/confirm_dialog.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:freecodecamp/fcc_theme.dart'; + +final _buttonStyle = ElevatedButton.styleFrom( + backgroundColor: FccColors.gray75, + foregroundColor: FccColors.gray00, + side: const BorderSide(width: 2, color: FccColors.gray00), + shape: const RoundedRectangleBorder(), + minimumSize: const Size.fromHeight(50), +); + +final _destructiveStyle = ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade100, + foregroundColor: Colors.red.shade900, + side: BorderSide(width: 2, color: Colors.red.shade900), + shape: const RoundedRectangleBorder(), + minimumSize: const Size.fromHeight(50), +); + +// NOTE: Resolves to true only when the confirm button is tapped. +Future showConfirmDialog( + BuildContext context, { + required String title, + required String description, + required String confirmLabel, + bool destructive = false, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => Dialog( + backgroundColor: FccColors.gray90, + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + runSpacing: 20, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 24, + height: 1.5, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + description, + style: const TextStyle( + fontSize: 16, + height: 1.5, + color: Colors.white, + ), + ), + ElevatedButton( + style: destructive ? _destructiveStyle : _buttonStyle, + onPressed: () => Navigator.pop(context, true), + child: Text( + destructive ? confirmLabel.toUpperCase() : confirmLabel, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: destructive ? FontWeight.bold : null, + ), + ), + ), + ElevatedButton( + style: _buttonStyle, + onPressed: () => Navigator.pop(context, false), + child: const Text( + 'Cancel', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ), + ); + return confirmed ?? false; +} diff --git a/mobile-app/lib/widgets/drawer/drawer.dart b/mobile-app/lib/widgets/drawer/drawer.dart new file mode 100644 index 000000000..0a255501b --- /dev/null +++ b/mobile-app/lib/widgets/drawer/drawer.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/routing/auth.dart'; +import 'package:freecodecamp/routing/code_radio.dart'; +import 'package:freecodecamp/routing/learn.dart'; +import 'package:freecodecamp/routing/news.dart'; +import 'package:freecodecamp/routing/podcasts.dart'; +import 'package:freecodecamp/routing/settings.dart'; +import 'package:freecodecamp/services/auth_service.dart'; +import 'package:freecodecamp/utils/custom_tab.dart'; +import 'package:freecodecamp/widgets/drawer/drawer_tile.dart'; +import 'package:freecodecamp/widgets/drawer/scroll_shadow.dart'; +import 'package:freecodecamp/widgets/section_divider.dart'; +import 'package:go_router/go_router.dart'; + +class DrawerWidget extends ConsumerWidget { + const DrawerWidget({super.key}); + + void _login(BuildContext context) { + Scaffold.of(context).closeDrawer(); + const LoginRoute().push(context); + } + + // NOTE: Only Profile needs a signed-in user, so only it is left behind. + void _signOut(BuildContext context, WidgetRef ref) { + if (GoRouterState.of(context).uri.path == profilePath) { + const LearnLandingRoute().go(context); + } + unawaited(ref.read(authProvider.notifier).signOut()); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + + return Drawer( + backgroundColor: FccColors.gray80, + child: Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: ScrollShadow( + child: ListView( + children: [ + ListTile( + contentPadding: const EdgeInsets.all(16), + leading: Image.asset( + 'assets/images/placeholder-profile-img.png', + width: 75, + height: 75, + ), + title: Text(switch (auth.value) { + _ when auth.isLoading => 'Loading your account…', + _ when auth.hasError => "Couldn't load your account", + null => 'Anonymous User', + final user when user.name.isEmpty => user.username, + final user => user.name, + }), + subtitle: auth.isLoading + ? null + : Text(switch (auth) { + _ when auth.hasError => 'Tap to retry', + _ when auth.isLoggedIn => 'Our coolest Camper', + _ => 'Sign in to save your progress', + }), + onTap: switch (auth) { + _ when auth.isLoading => null, + _ when auth.hasError => () => unawaited( + ref.read(authProvider.notifier).refresh(), + ), + _ when auth.isLoggedIn => () => const ProfileRoute().go( + context, + ), + _ => () => _login(context), + }, + ), + sectionDivider, + DrawerTile( + component: 'Daily Challenges', + icon: Icons.extension, + route: () => + const LearnDailyChallengeArchiveRoute().go(context), + ), + DrawerTile( + component: 'Learn', + route: () => const LearnLandingRoute().go(context), + ), + DrawerTile( + component: 'Tutorials', + icon: Icons.forum_outlined, + route: () => const NewsFeedRoute().go(context), + ), + DrawerTile( + component: 'Podcast', + icon: Icons.podcasts, + route: () => const PodcastListRoute().go(context), + ), + if (!Platform.isIOS) + DrawerTile( + component: 'Code Radio', + icon: Icons.radio, + route: () => const CodeRadioRoute().go(context), + ), + sectionDivider, + DrawerTile( + component: 'Donate', + icon: Icons.favorite, + route: () => openInCustomTab( + 'https://www.freecodecamp.org/donate/', + ), + ), + DrawerTile( + component: 'Settings', + icon: Icons.settings, + route: () => const SettingsRoute().go(context), + ), + sectionDivider, + DrawerTile( + component: 'Report an issue', + icon: Icons.bug_report, + route: () => openInCustomTab( + 'https://github.com/freeCodeCamp/mobile', + ), + ), + if (!auth.isLoading && !auth.hasError) + DrawerTile( + component: auth.isLoggedIn ? 'Log Out' : 'Log In', + icon: auth.isLoggedIn ? Icons.logout : Icons.login, + textColor: auth.isLoggedIn + ? FccColors.red80 + : Colors.white, + route: () => auth.isLoggedIn + ? _signOut(context, ref) + : _login(context), + ), + ], + ), + ), + ), + ), + const SafeArea( + minimum: EdgeInsets.only(bottom: 16), + child: Text( + 'freeCodeCamp is a donor-supported tax-exempt 501(c)(3) nonprofit organization', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10, color: Colors.white70), + ), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_tile.dart b/mobile-app/lib/widgets/drawer/drawer_tile.dart similarity index 53% rename from mobile-app/lib/ui/widgets/drawer_widget/drawer_tile.dart rename to mobile-app/lib/widgets/drawer/drawer_tile.dart index d47b13de2..409339497 100644 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_tile.dart +++ b/mobile-app/lib/widgets/drawer/drawer_tile.dart @@ -1,34 +1,27 @@ import 'package:flutter/material.dart'; -class DrawerTile extends StatefulWidget { +class DrawerTile extends StatelessWidget { const DrawerTile({ super.key, this.textColor = Colors.white, required this.component, - required this.icon, required this.route, + this.icon, }); final String component; - final dynamic icon; + final IconData? icon; final Function route; final Color? textColor; - @override - State createState() => _DrawerTileState(); -} -class _DrawerTileState extends State { @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(10.0), child: ListTile( dense: true, - leading: widget.icon != '' - ? Icon( - widget.icon, - color: widget.textColor, - ) + leading: icon != null + ? Icon(icon, color: textColor) : Image.asset( 'assets/images/logo.png', width: 30, @@ -36,16 +29,15 @@ class _DrawerTileState extends State { color: Colors.white, ), title: Text( - widget.component, + component, style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: widget.textColor, - letterSpacing: 0.5), + fontSize: 16, + fontWeight: FontWeight.w400, + color: textColor, + letterSpacing: 0.5, + ), ), - onTap: () { - widget.route(); - }, + onTap: () => route(), ), ); } diff --git a/mobile-app/lib/widgets/drawer/scroll_shadow.dart b/mobile-app/lib/widgets/drawer/scroll_shadow.dart new file mode 100644 index 000000000..180d2ff06 --- /dev/null +++ b/mobile-app/lib/widgets/drawer/scroll_shadow.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +const _color = Colors.black; +const _size = 6.0; + +// NOTE: Fades a shadow in at the top/bottom edge while there is more to scroll that way. +class ScrollShadow extends StatefulWidget { + const ScrollShadow({super.key, required this.child}); + + final Widget child; + + @override + State createState() => _ScrollShadowState(); +} + +class _ScrollShadowState extends State { + bool _showTop = false; + bool _showBottom = false; + + bool _onMetrics(ScrollMetrics metrics) { + final showTop = metrics.pixels > metrics.minScrollExtent; + final showBottom = metrics.pixels < metrics.maxScrollExtent; + + if (showTop != _showTop || showBottom != _showBottom) { + setState(() { + _showTop = showTop; + _showBottom = showBottom; + }); + } + return false; + } + + Widget _shadow({required bool visible, required bool top}) { + return Positioned( + top: top ? 0 : null, + bottom: top ? null : 0, + left: 0, + right: 0, + child: IgnorePointer( + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: const Duration(milliseconds: 250), + curve: visible ? Curves.easeIn : Curves.easeOut, + child: Container( + height: _size, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: top ? Alignment.topCenter : Alignment.bottomCenter, + end: top ? Alignment.bottomCenter : Alignment.topCenter, + colors: [_color, _color.withAlpha(0)], + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + NotificationListener( + onNotification: (notification) => _onMetrics(notification.metrics), + child: NotificationListener( + onNotification: (notification) => _onMetrics(notification.metrics), + child: widget.child, + ), + ), + _shadow(visible: _showTop, top: true), + _shadow(visible: _showBottom, top: false), + ], + ); + } +} diff --git a/mobile-app/lib/widgets/error_retry.dart b/mobile-app/lib/widgets/error_retry.dart new file mode 100644 index 000000000..1a1a3b488 --- /dev/null +++ b/mobile-app/lib/widgets/error_retry.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class ErrorRetry extends StatelessWidget { + const ErrorRetry({super.key, required this.message, this.onRetry}); + + final String message; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(message, textAlign: TextAlign.center), + if (onRetry != null) ...[ + const SizedBox(height: 12), + TextButton(onPressed: onRetry, child: const Text('Retry')), + ], + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart b/mobile-app/lib/widgets/html_handler/html_handler.dart similarity index 72% rename from mobile-app/lib/ui/views/news/html_handler/html_handler.dart rename to mobile-app/lib/widgets/html_handler/html_handler.dart index 28f508a5b..5f4892bbd 100644 --- a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart +++ b/mobile-app/lib/widgets/html_handler/html_handler.dart @@ -6,10 +6,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html_table/flutter_html_table.dart'; -import 'package:freecodecamp/extensions/i18n_extension.dart'; -import 'package:freecodecamp/ui/theme/fcc_theme.dart'; -import 'package:freecodecamp/ui/views/news/news-image-viewer/news_image_view.dart'; -import 'package:html/dom.dart' as dom; +import 'package:freecodecamp/fcc_theme.dart'; +import 'package:freecodecamp/routing/news.dart'; import 'package:html/parser.dart' as parser; import 'package:phone_ide/phone_ide.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -48,26 +46,16 @@ final Map defaultStyles = { fontFamily: 'Lato', padding: HtmlPaddings.only(left: 4, right: 4), ), - 'strong': Style( - fontWeight: FontWeight.bold, - ), + 'strong': Style(fontWeight: FontWeight.bold), 'a': Style( color: Colors.white, textDecoration: TextDecoration.underline, textDecorationColor: Colors.white, ), - 'p': Style( - fontSize: FontSize(20), - margin: Margins.only(top: 8, bottom: 8), - ), - 'li': Style( - margin: Margins.only(top: 8), - lineHeight: const LineHeight(1.5), - ), + 'p': Style(fontSize: FontSize(20), margin: Margins.only(top: 8, bottom: 8)), + 'li': Style(margin: Margins.only(top: 8), lineHeight: const LineHeight(1.5)), 'td': Style( - border: const Border( - bottom: BorderSide(color: Colors.grey), - ), + border: const Border(bottom: BorderSide(color: Colors.grey)), padding: HtmlPaddings.all(12), backgroundColor: Colors.white, color: Colors.black, @@ -75,20 +63,12 @@ final Map defaultStyles = { ), 'th': Style( padding: HtmlPaddings.all(12), - backgroundColor: const Color.fromRGBO(0xdf, 0xdf, 0xe2, 1), + backgroundColor: FccColors.gray10, color: Colors.black, ), - 'th strong': Style( - color: Colors.black, - fontSize: FontSize.medium, - ), - 'figure': Style( - margin: Margins.zero, - textAlign: TextAlign.center, - ), - 'figcaption': Style( - fontSize: FontSize.medium, - ), + 'th strong': Style(color: Colors.black, fontSize: FontSize.medium), + 'figure': Style(margin: Margins.zero, textAlign: TextAlign.center), + 'figcaption': Style(fontSize: FontSize.medium), 'code': Style( fontFamily: 'Hack', backgroundColor: FccColors.gray75, @@ -100,14 +80,11 @@ final Map defaultStyles = { // parsed into spans with this class. 'span.highlighted-text': Style( backgroundColor: FccSemanticColors.backgroundTertiary, - ) + ), }; class HTMLParser { - const HTMLParser({ - Key? key, - required this.context, - }); + const HTMLParser({Key? key, required this.context}); final BuildContext context; @@ -116,7 +93,7 @@ class HTMLParser { bool isSelectable = true, Map customStyles = const {}, }) { - dom.Document result = parser.parse(html); + final result = parser.parse(html); List elements = []; @@ -133,25 +110,8 @@ class HTMLParser { return elements; } - void goToImageView( - String imgUrl, - BuildContext context, - bool isDataUrl, - ) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => NewsImageView( - imgUrl: imgUrl, - isDataUrl: isDataUrl, - ), - settings: const RouteSettings(name: '/news-image'), - ), - ); - } - Widget _parseHTMLWidget( - child, [ + String child, [ bool isSelectable = true, Map customStyles = const {}, ]) { @@ -183,17 +143,19 @@ class HTMLParser { tagsToExtend: {'pre'}, builder: (child) { var codeElement = child.element!.children.isNotEmpty - ? child.element!.children.first // code element + ? child + .element! + .children + .first // code element : child.element!; // pre element String? currentClass; - bool codeLanguageIsPresent(List classNames) { + bool codeLanguageIsPresent(List classNames) { RegExp regExp = RegExp(r'\blang(uage)?\b'); for (String className in classNames) { if (className.contains(regExp)) { currentClass = className; - return true; } } @@ -201,7 +163,7 @@ class HTMLParser { return false; } - List classes = codeElement.classes.toList(); + List classes = codeElement.classes.toList(); return Editor( options: EditorOptions( @@ -228,15 +190,15 @@ class HTMLParser { YoutubePlayerController controller = YoutubePlayerController.fromVideoId( - videoId: videoId!, - autoPlay: false, - params: const YoutubePlayerParams( - showControls: true, - showFullscreenButton: true, - strictRelatedVideos: true, - origin: 'https://www.youtube-nocookie.com', - ), - ); + videoId: videoId!, + autoPlay: false, + params: const YoutubePlayerParams( + showControls: true, + showFullscreenButton: true, + strictRelatedVideos: true, + origin: 'https://www.youtube-nocookie.com', + ), + ); return YoutubePlayer( enableFullScreenOnVerticalDrag: false, @@ -258,7 +220,7 @@ class HTMLParser { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - context.t.copied_to_clipboard(parsed), + 'Copied to clipboard: $parsed', style: const TextStyle(fontSize: 20), ), duration: const Duration(seconds: 1), @@ -281,18 +243,14 @@ class HTMLParser { tagsToExtend: {'img'}, builder: (child) { var imgUrl = child.attributes['src'] ?? ''; - bool isDataUrl = Uri.parse(imgUrl).scheme == 'data'; + var isDataUrl = Uri.parse(imgUrl).scheme == 'data'; return Padding( padding: const EdgeInsets.all(8.0), child: InkWell( - onTap: () => goToImageView(imgUrl, context, isDataUrl), + onTap: () => NewsImageRoute($extra: imgUrl).push(context), child: isDataUrl - ? Image.memory( - base64Decode(imgUrl.split(',').last), - ) - : CachedNetworkImage( - imageUrl: imgUrl, - ), + ? Image.memory(base64Decode(imgUrl.split(',').last)) + : CachedNetworkImage(imageUrl: imgUrl), ), ); }, @@ -301,26 +259,19 @@ class HTMLParser { tagsToExtend: {'blockquote'}, builder: (node) { HTMLParser innerParser = HTMLParser(context: context); - List blockQuoteWidgets = innerParser.parse( - node.innerHtml, - ); + List blockQuoteWidgets = innerParser.parse(node.innerHtml); return Padding( padding: const EdgeInsets.all(8.0), child: Container( decoration: const BoxDecoration( border: Border( - left: BorderSide( - color: Color.fromRGBO(0x99, 0xc9, 0xff, 1), - width: 2, - ), + left: BorderSide(color: FccColors.blue30, width: 2), ), ), child: Padding( padding: const EdgeInsets.all(8.0), - child: Column( - children: [...blockQuoteWidgets], - ), + child: Column(children: [...blockQuoteWidgets]), ), ), ); diff --git a/mobile-app/lib/widgets/section_divider.dart b/mobile-app/lib/widgets/section_divider.dart new file mode 100644 index 000000000..d46e25a89 --- /dev/null +++ b/mobile-app/lib/widgets/section_divider.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +const sectionDivider = Divider( + color: Color(0x1FFFFFFF), + indent: 16, + endIndent: 16, + thickness: 1, +); diff --git a/mobile-app/pubspec.lock b/mobile-app/pubspec.lock index d630828b9..e006a9997 100644 --- a/mobile-app/pubspec.lock +++ b/mobile-app/pubspec.lock @@ -5,74 +5,82 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "99.0.0" _flutterfire_internals: dependency: transitive description: name: _flutterfire_internals - sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" + sha256: f6966125633a34f82d9be2ee895b755cff3554087b1d00a9eb884e5947f4bca9 url: "https://pub.dev" source: hosted - version: "1.3.75" + version: "1.3.77" algolia_client_core: dependency: transitive description: name: algolia_client_core - sha256: "89bdc20563dfb9d315e98b598266b915781b754f61dd6e75919a4dfe78907660" + sha256: c254e51e30e9b6609a689c1b2a32b185243edd5efb348e624787ebfac8d54da3 url: "https://pub.dev" source: hosted - version: "1.31.3" + version: "1.52.0" algolia_client_insights: dependency: transitive description: name: algolia_client_insights - sha256: b7dd05081258577090790f213d43906e8ea81deb26e00808acd86e3b9e22ee7e + sha256: bbd670cbe476a7e928e33b233f5fccf134c6fa638ef1984f8a405a48bc3e5b51 url: "https://pub.dev" source: hosted - version: "1.31.3" + version: "1.52.0" algolia_client_search: dependency: transitive description: name: algolia_client_search - sha256: f1238bb2e64d534d5c96fca93e1de10c3f5189e2eb1b6e919321af030830091c + sha256: "97872b4c487e204b0ee7d58229e82f4f79f4123ec42d21aaa7c0ce590fcf1248" url: "https://pub.dev" source: hosted - version: "1.31.3" + version: "1.49.2" algolia_helper_flutter: dependency: "direct main" description: name: algolia_helper_flutter - sha256: "95e99a9e9cc6dbb89a9d00b2966354735bcc6ac2b371311311cc83653e36a028" + sha256: "4add0a73f476bbac3204ae1ffbb5e66f73cdaf6ae6496038256d9c56d970a7d9" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.6.0" algolia_insights: dependency: transitive description: name: algolia_insights - sha256: "9aa33a9c2e3406b3851db80bbc862b8f6ddc693a96465b445546cbd03b33d533" + sha256: "2d94aecfee207e8c0cb403ec9cdcf98baacdfa89054d5f2a26fbca87f358e0b4" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.4" algoliasearch: dependency: transitive description: name: algoliasearch - sha256: b48f862c87f02b53c1008f35a5aeaed8a0da8d0eb43e025a7430ecb3018df142 + sha256: "76e64c5582f30c63f9771c3888d81b3a514222765e3b88ceec3f808e677f8431" url: "https://pub.dev" source: hosted - version: "1.31.3" + version: "1.49.2" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "12.1.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: "445b77e2054fa3e8c8a8ef1b5e9e6b23bb8028fffd34b5e60eaef315b7750674" + url: "https://pub.dev" + source: hosted + version: "0.3.3" ansicolor: dependency: transitive description: @@ -85,10 +93,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.5.2" args: dependency: transitive description: @@ -101,10 +109,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" audio_service: dependency: "direct main" description: @@ -141,18 +149,18 @@ packages: dependency: "direct main" description: name: auth0_flutter - sha256: "08dc67473867a484ab018fd229fe0f9033678fbf87d07e742c8a7afe942413c7" + sha256: "3f0cc91376005557afdaef7c1fb2b62a4faaa6c96e525224439480b76394ec4d" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.2" auth0_flutter_platform_interface: dependency: transitive description: name: auth0_flutter_platform_interface - sha256: "30aee40410a1e219f8a0b1acccab7c17b738119032cc78e7d51d897f04ab051f" + sha256: "4bcac858ec60603c5e97b48d118f85051951a9bc0d72efc71d1190b267687427" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.2" boolean_selector: dependency: transitive description: @@ -165,50 +173,34 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "45d14a0fb23e018d8287c32fc98d726ce466b231928ed9b9200f29bd3ccd39ae" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.7" build_config: dependency: transitive description: name: build_config - sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.3.2" build_daemon: dependency: transitive description: name: build_daemon - sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" url: "https://pub.dev" source: hosted - version: "4.0.4" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" + version: "4.1.5" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + sha256: "5367e521935b102bdf1e735d2aab461e36b2edca6517662d088dd04cc39f8d16" url: "https://pub.dev" source: hosted - version: "2.4.13" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 - url: "https://pub.dev" - source: hosted - version: "7.3.2" + version: "2.15.1" built_collection: dependency: transitive description: @@ -221,10 +213,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.12.7" cached_network_image: dependency: "direct main" description: @@ -261,10 +253,18 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -281,14 +281,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.1" collection: dependency: "direct main" description: @@ -305,22 +313,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+4" crypto: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: @@ -329,62 +345,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: a4c1ccfee44c7e75ed80484071a5c142a385345e658fd8bd7c4b5c97e7198f98 url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "3.1.8" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.dev" - source: hosted - version: "0.7.11" - dependency_validator: - dependency: "direct dev" - description: - name: dependency_validator - sha256: f727a5627aa405965fab4aef4f468e50a9b632ba0737fd2f98c932fec6d712b9 - url: "https://pub.dev" - source: hosted - version: "3.2.3" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" - url: "https://pub.dev" - source: hosted - version: "13.2.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "0.7.15" dio: dependency: "direct main" description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" url: "https://pub.dev" source: hosted - version: "5.9.2" + version: "5.11.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.1" fake_async: dependency: transitive description: @@ -421,90 +421,90 @@ packages: dependency: "direct main" description: name: firebase_analytics - sha256: "47b68927bc4abd6d7b4e2ddfe7c64fe087cca5f96d0c2b8d9d6deb6120c10ad6" + sha256: b82d2cabf4905a465ac95937037cbab4f2ca80f559cb04abd3da3f2abb9960bb url: "https://pub.dev" source: hosted - version: "12.4.5" + version: "12.6.0" firebase_analytics_platform_interface: dependency: transitive description: name: firebase_analytics_platform_interface - sha256: "36ce5feb5a996381e6411792eaa00e89178e75943e54ac8f14a8296b6e9a3e88" + sha256: "5c3ab4b681c837afd71f0abf77c7485f51c7b645d4ae77b96f9ed54779ff4126" url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.0.7" firebase_analytics_web: dependency: transitive description: name: firebase_analytics_web - sha256: c12358983c927093cd40d42feb0cb05c8594d3a9b56ab5ad303bec2b8838d252 + sha256: "09eb4d0ce4c9a16efbc277ac709a90eb197d5512592215a73c6a302ab1ab6c3a" url: "https://pub.dev" source: hosted - version: "0.6.1+11" + version: "0.6.1+13" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9" + sha256: "5fbbaa2aeb08a52feee559f92a62f5a6b04c9bdff81c8f608636771cf0d7b680" url: "https://pub.dev" source: hosted - version: "4.12.1" + version: "4.15.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938 + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "8.1.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555 + sha256: "8ed84f5001dbd7a0390e2818d46bc858ceebb970edededdfc8d6552c76fc2f6e" url: "https://pub.dev" source: hosted - version: "3.9.1" + version: "3.12.0" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: "9855d3e2286c3e4439cf7d48c740bdf282ac165f29c7551dcdaf121925c59a28" + sha256: f37fe0df162f08bd549114b784037c73a093c64d22625632f97dbbddf18669b9 url: "https://pub.dev" source: hosted - version: "5.2.6" + version: "5.4.0" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: aab14de92555ccf8c8616fc1b649272aef04dadb07f986e166e1d36d158f7bd1 + sha256: "8270dcdb1800db3e5f888ce30a2d5e8f92c379c461f022297c83b2c794aa1792" url: "https://pub.dev" source: hosted - version: "3.8.26" + version: "3.9.0" firebase_remote_config: dependency: "direct main" description: name: firebase_remote_config - sha256: d642fe803397a142199d1d1f45683021d822af59f989a78ba8f050d9ebede382 + sha256: fa518445638fa3903e359ac544bd3243e72ada025728c8dbfa4b28ae939e8b38 url: "https://pub.dev" source: hosted - version: "6.5.5" + version: "6.7.0" firebase_remote_config_platform_interface: dependency: transitive description: name: firebase_remote_config_platform_interface - sha256: "04ccfae1e90e218096f5251da42c5aa520b1ef4858b0dcb5f0c9eec0dee58715" + sha256: dc5a9713031f2ca744cb266d96a58609e929db19f3d91a1dd6bb8f8a0d77acad url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.7" firebase_remote_config_web: dependency: transitive description: name: firebase_remote_config_web - sha256: a81e9495eabde81f3ae93fe5d132384522925d6f2cce869510ebef7fe43b1c50 + sha256: "500abf695c370c76a486ad7480a6df7caf55d2d35ccc5c66b9fc9c1c47f2bc14" url: "https://pub.dev" source: hosted - version: "1.10.12" + version: "1.10.14" fixnum: dependency: transitive description: @@ -522,10 +522,10 @@ packages: dependency: transitive description: name: flutter_cache_manager - sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + sha256: "1de7849213b4c73c85aca7e0ac687a9a5d82ccdb594366b9dcc26cb6a2189cd2" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "3.4.2" flutter_custom_tabs: dependency: "direct main" description: @@ -570,10 +570,10 @@ packages: dependency: "direct main" description: name: flutter_dotenv - sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "6.0.1" flutter_driver: dependency: "direct dev" description: flutter @@ -588,21 +588,13 @@ packages: source: hosted version: "1.0.5" flutter_highlight: - dependency: "direct main" + dependency: transitive description: name: flutter_highlight sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" url: "https://pub.dev" source: hosted version: "0.7.0" - flutter_hooks: - dependency: "direct main" - description: - name: flutter_hooks - sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d - url: "https://pub.dev" - source: hosted - version: "0.21.2" flutter_html: dependency: "direct main" description: @@ -711,18 +703,18 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" flutter_local_notifications: dependency: "direct main" description: name: flutter_local_notifications - sha256: a0d7141f14cabcee42967470a858dfc99dd6cfb70d3cab404bacfcafa9e84e70 + sha256: "1447ba911c60f2ba3f25dae1af151ec187162566b0f57e37771bf0b400f013ad" url: "https://pub.dev" source: hosted - version: "22.0.1" + version: "22.3.0" flutter_local_notifications_linux: dependency: transitive description: @@ -735,10 +727,10 @@ packages: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814 + sha256: "43c3761d916c9bd3d5c7ebbc44d82f4990329840c0c5d62ad5260cc1b5d399bd" url: "https://pub.dev" source: hosted - version: "12.0.0" + version: "12.2.0" flutter_local_notifications_web: dependency: transitive description: @@ -755,59 +747,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" flutter_native_splash: dependency: "direct dev" description: name: flutter_native_splash - sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff" url: "https://pub.dev" source: hosted - version: "2.4.6" - flutter_scroll_shadow: + version: "2.4.8" + flutter_riverpod: dependency: "direct main" description: - name: flutter_scroll_shadow - sha256: "9ec6a566831a7251b4ffbee0a3691fbe02d3dd16a9d26c96838c02958f27d456" + name: flutter_riverpod + sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" url: "https://pub.dev" source: hosted - version: "1.2.6" + version: "3.3.2" flutter_secure_storage: dependency: "direct main" description: name: flutter_secure_storage - sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8 url: "https://pub.dev" source: hosted - version: "10.3.1" + version: "11.2.0" flutter_secure_storage_darwin: dependency: transitive description: name: flutter_secure_storage_darwin - sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + sha256: a031ceac9b070e62ef183cd7f7d1a8798ebde402d5049f0fe6eb3a51bd797a21 url: "https://pub.dev" source: hosted - version: "0.3.2" + version: "0.4.3" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + sha256: "64951127f001f546891c86f414972bf49ef2c4c8a4c92c65f5790cc0c9100045" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.1" flutter_secure_storage_web: dependency: transitive description: @@ -824,22 +811,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" - flutter_staggered_grid_view: - dependency: transitive - description: - name: flutter_staggered_grid_view - sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" - url: "https://pub.dev" - source: hosted - version: "0.7.0" flutter_svg: dependency: "direct main" description: name: flutter_svg - sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -850,22 +829,22 @@ packages: description: flutter source: sdk version: "0.0.0" - focus_detector: - dependency: "direct main" + freezed: + dependency: "direct dev" description: - name: focus_detector - sha256: "05e32d9dd378cd54f1a3f9ce813c05156f28eb83f8e68f5bf1a37e9cdb21af1c" + name: freezed + sha256: "8599ba37236328ff6f97269ecce875d251a367eb2929c9bbf9b811b2ce56c74e" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.2.6-dev.1" freezed_annotation: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation - sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "3.1.0" frontend_server_client: dependency: transitive description: @@ -879,38 +858,38 @@ packages: description: flutter source: sdk version: "0.0.0" - get: + glob: dependency: transitive description: - name: get - sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "4.7.2" - get_it: - dependency: transitive + version: "2.1.3" + go_router: + dependency: "direct main" description: - name: get_it - sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea url: "https://pub.dev" source: hosted - version: "7.7.0" - glob: - dependency: transitive + version: "17.5.0" + go_router_builder: + dependency: "direct dev" description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + name: go_router_builder + sha256: "53253ae5bd3d3bfc38c5488ca844f6b054c4b905683667db4544e7ab995fddcc" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "4.4.0" gql: dependency: transitive description: name: gql - sha256: "650e79ed60c21579ca3bd17ebae8a8c8d22cde267b03a19bf3b35996baaa843a" + sha256: "67c32325eb55c15f526f0f5e7d8b38a463dbff2ec3c2e046be4a1a95f0dc93d1" url: "https://pub.dev" source: hosted - version: "1.0.1-alpha+1730759315362" + version: "1.0.1" gql_dedupe_link: dependency: transitive description: @@ -923,10 +902,10 @@ packages: dependency: transitive description: name: gql_error_link - sha256: "93901458f3c050e33386dedb0ca7173e08cebd7078e4e0deca4bf23ab7a71f63" + sha256: dd0f3fbfbcec848ea050507470cdb5d3dc47d29544ae11044a1c883cbe159ccc url: "https://pub.dev" source: hosted - version: "1.0.0+1" + version: "1.0.1" gql_exec: dependency: transitive description: @@ -939,34 +918,34 @@ packages: dependency: transitive description: name: gql_http_link - sha256: ef6ad24d31beb5a30113e9b919eec20876903cc4b0ee0d31550047aaaba7d5dd + sha256: "07635e85a4f313836904961904417fd27844fe8f68f77b410a4e6b81d8e9202e" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.0" gql_link: dependency: transitive description: name: gql_link - sha256: c2b0adb2f6a60c2599b9128fb095316db5feb99ce444c86fb141a6964acedfa4 + sha256: "0730276ce3a6a0ced073194ff923a8d99b3c78e442cbf096eb54fd0c3fa9f974" url: "https://pub.dev" source: hosted - version: "1.0.1-alpha+1730759315378" + version: "1.1.0" gql_transform_link: dependency: transitive description: name: gql_transform_link - sha256: "0645fdd874ca1be695fd327271fdfb24c0cd6fa40774a64b946062f321a59709" + sha256: b3bb06a6991bc5c9d877e2757455f80e2c14dc684b8327bedae4f4ee67afae8b url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" graphql: dependency: "direct main" description: name: graphql - sha256: b67471ddb954fd538c5930f989c5072e038a9fc910afc85a8a926a7f9b8c56d3 + sha256: a7cb0b5e8719546bf8d4edf5f57c3690ddf0fcce379c0d9d2287fdab73481090 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.2.4" graphs: dependency: transitive description: @@ -991,14 +970,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" + hive_ce: + dependency: transitive + description: + name: hive_ce + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" + url: "https://pub.dev" + source: hosted + version: "2.19.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" html: dependency: "direct main" description: name: html - sha256: "9475be233c437f0e3637af55e7702cbbe5c23a68bd56e8a5fa2d426297b7c6c8" + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" url: "https://pub.dev" source: hosted - version: "0.15.5+1" + version: "0.15.6" http: dependency: transitive description: @@ -1027,18 +1022,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" url: "https://pub.dev" source: hosted - version: "4.5.4" - infinite_scroll_pagination: - dependency: "direct main" - description: - name: infinite_scroll_pagination - sha256: ff5232d9066f664d31eee1b7dd0dedf79c71e7d8874567a7af5a51630e38b234 - url: "https://pub.dev" - source: hosted - version: "5.0.0" + version: "4.12.0" integration_test: dependency: "direct dev" description: flutter @@ -1048,10 +1035,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: @@ -1060,30 +1047,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + isolate_channel: + dependency: transitive + description: + name: isolate_channel + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 + url: "https://pub.dev" + source: hosted + version: "0.6.1" jiffy: dependency: "direct main" description: name: jiffy - sha256: "1c1b86459969ff9f32dc5b0ffe392f1e08181e66396cf9dd8fa7c90552a691af" + sha256: cabced5ddea4612f5e0c498df065ee8a1bd810f7ed757db910e95d2aba80161d url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.4.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" url: "https://pub.dev" source: hosted - version: "0.6.7" + version: "0.7.2" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: e45aefa0324f08c683caafbb94b72837aa6193c61822799c916e45f4a263113d url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "6.14.1" just_audio: dependency: "direct main" description: @@ -1104,10 +1131,10 @@ packages: dependency: transitive description: name: just_audio_web - sha256: "8c7e779892e180cbc9ffb5a3c52f6e90e1cbbf4a63694cc450972a7edbd2bb6d" + sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663" url: "https://pub.dev" source: hosted - version: "0.4.15" + version: "0.4.16" leak_tracker: dependency: transitive description: @@ -1136,10 +1163,10 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "6.1.0" list_counter: dependency: transitive description: @@ -1148,14 +1175,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - logger: - dependency: transitive - description: - name: logger - sha256: "7ad7215c15420a102ec687bb320a7312afd449bac63bfb1c60d9787c27b9767f" - url: "https://pub.dev" - source: hosted - version: "1.4.0" logging: dependency: transitive description: @@ -1197,29 +1216,37 @@ packages: source: hosted version: "2.0.0" mockito: - dependency: "direct dev" + dependency: transitive description: name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422 url: "https://pub.dev" source: hosted - version: "5.4.4" - nested: + version: "5.6.4" + node_preamble: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "2.0.2" normalize: dependency: transitive description: name: normalize - sha256: f78bf0552b9640c76369253f0b8fdabad4f3fbfc06bdae9359e71bee9a5b071b + sha256: "703f0af9e6f43a5a71536e977b945238bc89f1a941347e7ba467865a20cc1a9f" url: "https://pub.dev" source: hosted - version: "0.9.1" + version: "0.10.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" octo_image: dependency: transitive description: @@ -1245,13 +1272,13 @@ packages: source: hosted version: "2.2.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" url: "https://pub.dev" source: hosted - version: "10.2.1" + version: "10.2.2" package_info_plus_platform_interface: dependency: transitive description: @@ -1280,42 +1307,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -1328,10 +1355,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "7.0.2" phone_ide: dependency: "direct main" description: @@ -1368,18 +1395,18 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" posix: dependency: transitive description: name: posix - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.2" + version: "6.5.2" pretty_dio_logger: dependency: "direct main" description: @@ -1392,18 +1419,10 @@ packages: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" - url: "https://pub.dev" - source: hosted - version: "5.0.3" - provider: - dependency: transitive - description: - name: provider - sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "5.0.6" pub_semver: dependency: transitive description: @@ -1420,54 +1439,54 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - quick_actions: - dependency: "direct main" + quiver: + dependency: transitive description: - name: quick_actions - sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798" + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 url: "https://pub.dev" source: hosted - version: "1.1.0" - quick_actions_android: + version: "3.2.2" + record_use: dependency: transitive description: - name: quick_actions_android - sha256: "8d88e90a236ffe92f1026ecc3b85acfefedffccb0c1be1c94c1c0298e8483ad8" + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "1.0.21" - quick_actions_ios: + version: "0.6.0" + riverpod: dependency: transitive description: - name: quick_actions_ios - sha256: "837b7e6b5973784d3da56b8c959b446b215914f20405d88cd7d22a2fb94e4e4c" + name: riverpod + sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" url: "https://pub.dev" source: hosted - version: "1.2.0" - quick_actions_platform_interface: + version: "3.3.2" + riverpod_analyzer_utils: dependency: transitive description: - name: quick_actions_platform_interface - sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4" + name: riverpod_analyzer_utils + sha256: "3e275138862ccc22ed61444a1f9a840f753094c367f28f4123f50289cd204d68" url: "https://pub.dev" source: hosted - version: "1.1.0" - quiver: - dependency: transitive + version: "1.0.0-dev.10" + riverpod_annotation: + dependency: "direct main" description: - name: quiver - sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + name: riverpod_annotation + sha256: "674dbb26e2db3d9253166faf4758c796af14146b8fbcf5e7102bc8a04cd359b8" url: "https://pub.dev" source: hosted - version: "3.2.2" - recase: - dependency: transitive + version: "4.0.3" + riverpod_generator: + dependency: "direct dev" description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + name: riverpod_generator + sha256: "54d790c3fee1ae281c448801bfdbfaa9fd961a9d3998494e0fcd9ee32184d7eb" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.0.4" rxdart: dependency: transitive description: @@ -1556,91 +1575,115 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.0" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - sliver_tools: + source_gen: dependency: transitive description: - name: sliver_tools - sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 url: "https://pub.dev" source: hosted - version: "0.2.12" - source_gen: + version: "4.2.4" + source_helper: dependency: transitive description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + name: source_helper + sha256: "5e6f216fdf6376c9f3852381ae037499797a3385377d388b011dac98d303c67c" url: "https://pub.dev" source: hosted - version: "1.5.0" - source_span: + version: "1.3.13" + source_map_stack_trace: dependency: transitive description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b url: "https://pub.dev" source: hosted - version: "1.10.1" - sprintf: + version: "2.1.2" + source_maps: dependency: transitive description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" sqflite: - dependency: "direct main" + dependency: transitive description: name: sqflite - sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" sqflite_android: dependency: transitive description: name: sqflite_android - sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b" + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.11" sqflite_darwin: dependency: transitive description: name: sqflite_darwin - sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3+1" sqflite_platform_interface: dependency: transitive description: name: sqflite_platform_interface - sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" stack_trace: dependency: transitive description: @@ -1649,38 +1692,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" - stacked: - dependency: "direct main" - description: - name: stacked - sha256: ed19ecdc2dcc682b9be9c7e34646e603c0f770437a914b15c7d2d13391c92a09 - url: "https://pub.dev" - source: hosted - version: "3.4.3" - stacked_generator: - dependency: "direct dev" - description: - name: stacked_generator - sha256: eaa6447e3fd4d4010b746629b5518364d7fa7f6453ffb6416ad449fd352d1181 - url: "https://pub.dev" - source: hosted - version: "1.6.1" - stacked_services: - dependency: "direct main" - description: - name: stacked_services - sha256: a70c5c86f89a9258139ff174bf045a527099ea8552f8497ebb0b755469841bb4 - url: "https://pub.dev" - source: hosted - version: "1.6.0" - stacked_shared: + state_notifier: dependency: transitive description: - name: stacked_shared - sha256: "26e11dcfe23df81d565d0180eb5bcf4742efed066ba3328623b458f21a82b346" + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.0.0" stream_channel: dependency: transitive description: @@ -1717,10 +1736,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6" + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -1729,6 +1748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + url: "https://pub.dev" + source: hosted + version: "1.31.0" test_api: dependency: transitive description: @@ -1737,6 +1764,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + test_core: + dependency: transitive + description: + name: test_core + sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + url: "https://pub.dev" + source: hosted + version: "0.6.17" timezone: dependency: "direct main" description: @@ -1745,14 +1780,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" - timing: - dependency: transitive - description: - name: timing - sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" - source: hosted - version: "1.0.2" typed_data: dependency: transitive description: @@ -1761,30 +1788,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - ua_client_hints: - dependency: "direct main" - description: - name: ua_client_hints - sha256: e6e302bb7c21d6a90023db61e0ec413f0810ce341bb7e2ab447ff5d61ec3d87e - url: "https://pub.dev" - source: hosted - version: "1.7.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" upgrader: dependency: "direct main" description: name: upgrader - sha256: "6282524d4b664e48e24736ce343eedd115dcc1ce79198fc0bd46f14692e1f05e" + sha256: "5a117075c5d22844136a89a60cc0272602cb8a5d0ce371b15379ddf68a415588" url: "https://pub.dev" source: hosted - version: "13.5.0" + version: "13.7.0" url_launcher: dependency: "direct main" description: @@ -1797,18 +1816,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.32" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1821,10 +1840,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1853,18 +1872,18 @@ packages: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1877,10 +1896,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.1.17" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1897,30 +1916,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - visibility_detector: - dependency: transitive - description: - name: visibility_detector - sha256: ec932527913f32f65aa01d3a393504240b9e9021ecc77123f017755605e48832 - url: "https://pub.dev" - source: hosted - version: "0.2.2" vm_service: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "15.2.0" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" web: dependency: transitive description: @@ -1933,10 +1944,10 @@ packages: dependency: transitive description: name: web_socket - sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" web_socket_channel: dependency: "direct main" description: @@ -1953,54 +1964,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" webview_flutter: dependency: transitive description: name: webview_flutter - sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 url: "https://pub.dev" source: hosted - version: "4.13.0" + version: "4.14.1" webview_flutter_android: dependency: transitive description: name: webview_flutter_android - sha256: f560f57d0f529c1dcdaf4edc3a3217b099560622f9f4a10b6bdbb566553c61ea + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.14.0" webview_flutter_platform_interface: dependency: transitive description: name: webview_flutter_platform_interface - sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" url: "https://pub.dev" source: hosted - version: "2.14.0" + version: "2.15.1" webview_flutter_wkwebview: dependency: transitive description: name: webview_flutter_wkwebview - sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d url: "https://pub.dev" source: hosted - version: "3.23.5" + version: "3.26.0" win32: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "6.3.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" - url: "https://pub.dev" - source: hosted - version: "3.0.3" + version: "6.4.0" xdg_directories: dependency: transitive description: @@ -2013,10 +2024,10 @@ packages: dependency: transitive description: name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.6.1" yaml: dependency: transitive description: @@ -2029,18 +2040,18 @@ packages: dependency: "direct main" description: name: youtube_player_iframe - sha256: "6690da91591d14b32a6884eb6ae270ea4cc946a748d577d89d18cde565be689f" + sha256: "8488d3fd3ce10286356a3e40036cc9ab296c19f399a3141acb70dc18a5231dab" url: "https://pub.dev" source: hosted - version: "5.2.2" + version: "6.0.2" youtube_player_iframe_web: dependency: transitive description: name: youtube_player_iframe_web - sha256: "333901d008634f2ea67ef27aba8d597567e4ff45f393290b948a739654ab6dca" + sha256: "405fccd8b7ae467835fc713bc86a3182470106098eec9aad0c178aec5395efa8" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" sdks: - dart: ">=3.12.0 <4.0.0" + dart: ">=3.12.2 <4.0.0" flutter: ">=3.44.0" diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index a4c3b13d1..f0f5a2c1d 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -3,72 +3,67 @@ description: freecodecamp.org app. publish_to: none version: 7.5.6+71506 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ^3.12.2 dependencies: - algolia_helper_flutter: 1.2.1 + algolia_helper_flutter: 1.6.0 audio_service: 0.18.19 - auth0_flutter: 2.5.0 + auth0_flutter: 2.6.2 cached_network_image: 3.4.1 collection: 1.19.1 - device_info_plus: 13.2.0 - dio: 5.9.2 - firebase_analytics: 12.4.5 - firebase_core: 4.12.1 - firebase_crashlytics: 5.2.6 - firebase_remote_config: 6.5.5 + cupertino_icons: 1.0.9 + dio: 5.11.0 + firebase_analytics: 12.6.0 + firebase_core: 4.15.0 + firebase_crashlytics: 5.4.0 + firebase_remote_config: 6.7.0 flutter: sdk: flutter flutter_custom_tabs: 2.6.0 - flutter_dotenv: 5.2.1 + flutter_dotenv: 6.0.1 flutter_heatmap_calendar: 1.0.5 - flutter_highlight: 0.7.0 - flutter_hooks: 0.21.2 flutter_html: 3.0.0 flutter_html_table: 3.0.0 flutter_inappwebview: 6.2.0-beta.3 - flutter_local_notifications: 22.0.1 - flutter_localizations: - sdk: flutter - flutter_scroll_shadow: 1.2.6 - flutter_secure_storage: 10.3.1 - flutter_svg: 2.1.0 - focus_detector: 2.0.1 - graphql: 5.2.0 - html: 0.15.5+1 - infinite_scroll_pagination: 5.0.0 - jiffy: 6.3.2 + flutter_local_notifications: 22.3.0 + flutter_riverpod: 3.3.2 + flutter_secure_storage: 11.2.0 + flutter_svg: 2.3.0 + freezed_annotation: 3.1.0 + go_router: 17.5.0 + graphql: 5.2.4 + html: 0.15.6 + jiffy: 6.4.5 + json_annotation: 4.12.0 just_audio: 0.10.6 + package_info_plus: 10.2.2 path: 1.9.1 - path_provider: 2.1.5 + path_provider: 2.1.6 phone_ide: 2.0.1 photo_view: 0.15.0 pretty_dio_logger: 1.4.0 - quick_actions: 1.1.0 + riverpod_annotation: 4.0.3 share_plus: 13.3.0 - shared_preferences: 2.5.5 # TODO: update deprecated code - sqflite: 2.4.2 - stacked: 3.4.3 - stacked_services: 1.6.0 + shared_preferences: 2.5.5 timezone: 0.11.1 - ua_client_hints: 1.7.0 - upgrader: 13.5.0 + upgrader: 13.7.0 url_launcher: 6.3.2 web_socket_channel: 3.0.3 - youtube_player_iframe: 5.2.2 + youtube_player_iframe: 6.0.2 dev_dependencies: - build_runner: 2.4.13 - dependency_validator: 3.2.3 + build_runner: 2.15.1 flutter_driver: sdk: flutter flutter_launcher_icons: 0.14.4 - flutter_lints: 5.0.0 - flutter_native_splash: 2.4.6 + flutter_lints: 6.0.0 + flutter_native_splash: 2.4.8 flutter_test: sdk: flutter + freezed: 3.2.6-dev.1 + go_router_builder: 4.4.0 integration_test: sdk: flutter - mockito: 5.4.4 # NOTE: 5.4.6+ is blocked by stacked_generator - stacked_generator: 1.6.1 + json_serializable: 6.14.1 + riverpod_generator: 4.0.4 flutter: fonts: @@ -79,14 +74,9 @@ flutter: fonts: - asset: assets/fonts/Hack-Regular.ttf uses-material-design: true - generate: true assets: - .env - assets/images/ - - assets/sql/ - - assets/test_data/news_post.json - - assets/test_data/news_feed.json - - assets/database/bookmarked-article.db - assets/learn/ - assets/test_runner/dist/ - assets/test_runner/babel/ diff --git a/mobile-app/sample.env b/mobile-app/sample.env index e2f4dd1af..6f7e27219 100644 --- a/mobile-app/sample.env +++ b/mobile-app/sample.env @@ -1,17 +1,19 @@ - -###NEWS### - -HASHNODE_PUBLICATION_ID= - +# --------------------- +# News +# --------------------- ALGOLIAAPPID= ALGOLIAKEY= -##AUTH## +HASHNODE_PUBLICATION_ID= +# --------------------- +# News +# --------------------- AUTH0_DOMAIN= AUTH0_CLIENT_ID= -##MISC## - -DEVELOPMENTMODE = TRUE -SHOWALLSB = FALSE +# --------------------- +# Miscellaneous +# --------------------- +DEVELOPMENT_MODE=TRUE +SHOW_ALL_SB=FALSE diff --git a/mobile-app/test/helpers/test_helpers.dart b/mobile-app/test/helpers/test_helpers.dart deleted file mode 100644 index d51560318..000000000 --- a/mobile-app/test/helpers/test_helpers.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:freecodecamp/service/news/bookmark_service.dart'; -import 'package:mockito/annotations.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:stacked_services/stacked_services.dart'; -// @stacked-import - -import 'test_helpers.mocks.dart'; - -@GenerateMocks([], customMocks: [ - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), - MockSpec( - onMissingStub: OnMissingStub.returnDefault), - MockSpec(onMissingStub: OnMissingStub.returnDefault), -// @stacked-mock-spec -]) -void registerServices() { - getAndRegisterNavigationService(); - getAndRegisterDialogService(); - getAndRegisterNewsBookmarkService(); - getAndRegisterAuthenticationService(); - getAndRegisterDailyChallengeService(); - getAndRegisterLocaleService(); -// @stacked-mock-register -} - -MockNavigationService getAndRegisterNavigationService() { - _removeRegistrationIfExists(); - final service = MockNavigationService(); - locator.registerSingleton(service); - return service; -} - -MockDialogService getAndRegisterDialogService() { - _removeRegistrationIfExists(); - final service = MockDialogService(); - locator.registerSingleton(service); - return service; -} - -BookmarksDatabaseService getAndRegisterNewsBookmarkService() { - _removeRegistrationIfExists(); - final service = BookmarksDatabaseService(); - locator.registerSingleton(service); - return service; -} - -MockAuthenticationService getAndRegisterAuthenticationService() { - _removeRegistrationIfExists(); - final service = MockAuthenticationService(); - locator.registerSingleton(service); - return service; -} - -MockDailyChallengeService getAndRegisterDailyChallengeService() { - _removeRegistrationIfExists(); - final service = MockDailyChallengeService(); - locator.registerSingleton(service); - return service; -} - -LocaleService getAndRegisterLocaleService() { - _removeRegistrationIfExists(); - final service = LocaleService(); - locator.registerSingleton(service); - return service; -} -// @stacked-mock-create - -void _removeRegistrationIfExists() { - if (locator.isRegistered()) { - locator.unregister(); - } -} diff --git a/mobile-app/test/helpers/test_helpers.mocks.dart b/mobile-app/test/helpers/test_helpers.mocks.dart deleted file mode 100644 index a47d65912..000000000 --- a/mobile-app/test/helpers/test_helpers.mocks.dart +++ /dev/null @@ -1,1493 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in freecodecamp/test/helpers/test_helpers.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; -import 'dart:ui' as _i11; - -import 'package:auth0_flutter/auth0_flutter.dart' as _i4; -import 'package:dio/dio.dart' as _i13; -import 'package:flutter/material.dart' as _i10; -import 'package:flutter_local_notifications/src/flutter_local_notifications_plugin.dart' - as _i16; -import 'package:flutter_local_notifications/src/initialization_settings.dart' - as _i17; -import 'package:flutter_local_notifications/src/notification_details.dart' - as _i19; -import 'package:flutter_local_notifications/src/platform_specifics/android/schedule_mode.dart' - as _i21; -import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart' - as _i18; -import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i3; -import 'package:freecodecamp/models/learn/challenge_model.dart' as _i8; -import 'package:freecodecamp/models/learn/curriculum_model.dart' as _i15; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart' as _i7; -import 'package:freecodecamp/models/main/user_model.dart' as _i6; -import 'package:freecodecamp/service/authentication/authentication_service.dart' - as _i12; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart' - as _i14; -import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i9; -import 'package:shared_preferences/shared_preferences.dart' as _i22; -import 'package:stacked_services/stacked_services.dart' as _i2; -import 'package:timezone/timezone.dart' as _i20; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeSnackbarService_0 extends _i1.SmartFake - implements _i2.SnackbarService { - _FakeSnackbarService_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeFlutterSecureStorage_1 extends _i1.SmartFake - implements _i3.FlutterSecureStorage { - _FakeFlutterSecureStorage_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeAuth0_2 extends _i1.SmartFake implements _i4.Auth0 { - _FakeAuth0_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeStreamController_3 extends _i1.SmartFake - implements _i5.StreamController { - _FakeStreamController_3( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeFccUserModel_4 extends _i1.SmartFake implements _i6.FccUserModel { - _FakeFccUserModel_4( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeDailyChallenge_5 extends _i1.SmartFake - implements _i7.DailyChallenge { - _FakeDailyChallenge_5( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeChallenge_6 extends _i1.SmartFake implements _i8.Challenge { - _FakeChallenge_6( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [NavigationService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockNavigationService extends _i1.Mock implements _i2.NavigationService { - @override - String get previousRoute => (super.noSuchMethod( - Invocation.getter(#previousRoute), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#previousRoute), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#previousRoute), - ), - ) as String); - - @override - String get currentRoute => (super.noSuchMethod( - Invocation.getter(#currentRoute), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#currentRoute), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#currentRoute), - ), - ) as String); - - @override - _i10.GlobalKey<_i10.NavigatorState>? nestedNavigationKey(int? index) => - (super.noSuchMethod( - Invocation.method( - #nestedNavigationKey, - [index], - ), - returnValueForMissingStub: null, - ) as _i10.GlobalKey<_i10.NavigatorState>?); - - @override - void config({ - bool? enableLog, - bool? defaultPopGesture, - bool? defaultOpaqueRoute, - Duration? defaultDurationTransition, - bool? defaultGlobalState, - _i2.Transition? defaultTransitionStyle, - String? defaultTransition, - }) => - super.noSuchMethod( - Invocation.method( - #config, - [], - { - #enableLog: enableLog, - #defaultPopGesture: defaultPopGesture, - #defaultOpaqueRoute: defaultOpaqueRoute, - #defaultDurationTransition: defaultDurationTransition, - #defaultGlobalState: defaultGlobalState, - #defaultTransitionStyle: defaultTransitionStyle, - #defaultTransition: defaultTransition, - }, - ), - returnValueForMissingStub: null, - ); - - @override - _i5.Future? navigateWithTransition( - _i10.Widget? page, { - bool? opaque, - String? transition = r'', - Duration? duration, - bool? popGesture, - int? id, - _i10.Curve? curve, - bool? fullscreenDialog = false, - bool? preventDuplicates = true, - _i2.Transition? transitionClass, - _i2.Transition? transitionStyle, - String? routeName, - }) => - (super.noSuchMethod( - Invocation.method( - #navigateWithTransition, - [page], - { - #opaque: opaque, - #transition: transition, - #duration: duration, - #popGesture: popGesture, - #id: id, - #curve: curve, - #fullscreenDialog: fullscreenDialog, - #preventDuplicates: preventDuplicates, - #transitionClass: transitionClass, - #transitionStyle: transitionStyle, - #routeName: routeName, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? replaceWithTransition( - _i10.Widget? page, { - bool? opaque, - String? transition = r'', - Duration? duration, - bool? popGesture, - int? id, - _i10.Curve? curve, - bool? fullscreenDialog = false, - bool? preventDuplicates = true, - _i2.Transition? transitionClass, - _i2.Transition? transitionStyle, - String? routeName, - }) => - (super.noSuchMethod( - Invocation.method( - #replaceWithTransition, - [page], - { - #opaque: opaque, - #transition: transition, - #duration: duration, - #popGesture: popGesture, - #id: id, - #curve: curve, - #fullscreenDialog: fullscreenDialog, - #preventDuplicates: preventDuplicates, - #transitionClass: transitionClass, - #transitionStyle: transitionStyle, - #routeName: routeName, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - bool back({ - dynamic result, - int? id, - }) => - (super.noSuchMethod( - Invocation.method( - #back, - [], - { - #result: result, - #id: id, - }, - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - void popUntil( - _i10.RoutePredicate? predicate, { - int? id, - }) => - super.noSuchMethod( - Invocation.method( - #popUntil, - [predicate], - {#id: id}, - ), - returnValueForMissingStub: null, - ); - - @override - void popRepeated(int? popTimes) => super.noSuchMethod( - Invocation.method( - #popRepeated, - [popTimes], - ), - returnValueForMissingStub: null, - ); - - @override - _i5.Future? navigateTo( - String? routeName, { - dynamic arguments, - int? id, - bool? preventDuplicates = true, - Map? parameters, - _i10.RouteTransitionsBuilder? transition, - }) => - (super.noSuchMethod( - Invocation.method( - #navigateTo, - [routeName], - { - #arguments: arguments, - #id: id, - #preventDuplicates: preventDuplicates, - #parameters: parameters, - #transition: transition, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? navigateToView( - _i10.Widget? view, { - dynamic arguments, - int? id, - bool? opaque, - _i10.Curve? curve, - Duration? duration, - bool? fullscreenDialog = false, - bool? popGesture, - bool? preventDuplicates = true, - _i2.Transition? transition, - _i2.Transition? transitionStyle, - }) => - (super.noSuchMethod( - Invocation.method( - #navigateToView, - [view], - { - #arguments: arguments, - #id: id, - #opaque: opaque, - #curve: curve, - #duration: duration, - #fullscreenDialog: fullscreenDialog, - #popGesture: popGesture, - #preventDuplicates: preventDuplicates, - #transition: transition, - #transitionStyle: transitionStyle, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? replaceWith( - String? routeName, { - dynamic arguments, - int? id, - bool? preventDuplicates = true, - Map? parameters, - _i10.RouteTransitionsBuilder? transition, - }) => - (super.noSuchMethod( - Invocation.method( - #replaceWith, - [routeName], - { - #arguments: arguments, - #id: id, - #preventDuplicates: preventDuplicates, - #parameters: parameters, - #transition: transition, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? clearStackAndShow( - String? routeName, { - dynamic arguments, - int? id, - Map? parameters, - }) => - (super.noSuchMethod( - Invocation.method( - #clearStackAndShow, - [routeName], - { - #arguments: arguments, - #id: id, - #parameters: parameters, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? clearStackAndShowView( - _i10.Widget? view, { - dynamic arguments, - int? id, - }) => - (super.noSuchMethod( - Invocation.method( - #clearStackAndShowView, - [view], - { - #arguments: arguments, - #id: id, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? clearTillFirstAndShow( - String? routeName, { - dynamic arguments, - int? id, - bool? preventDuplicates = true, - Map? parameters, - }) => - (super.noSuchMethod( - Invocation.method( - #clearTillFirstAndShow, - [routeName], - { - #arguments: arguments, - #id: id, - #preventDuplicates: preventDuplicates, - #parameters: parameters, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? clearTillFirstAndShowView( - _i10.Widget? view, { - dynamic arguments, - int? id, - }) => - (super.noSuchMethod( - Invocation.method( - #clearTillFirstAndShowView, - [view], - { - #arguments: arguments, - #id: id, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); - - @override - _i5.Future? pushNamedAndRemoveUntil( - String? routeName, { - _i10.RoutePredicate? predicate, - dynamic arguments, - int? id, - }) => - (super.noSuchMethod( - Invocation.method( - #pushNamedAndRemoveUntil, - [routeName], - { - #predicate: predicate, - #arguments: arguments, - #id: id, - }, - ), - returnValueForMissingStub: null, - ) as _i5.Future?); -} - -/// A class which mocks [DialogService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDialogService extends _i1.Mock implements _i2.DialogService { - @override - void registerCustomDialogBuilders( - Map? builders) => - super.noSuchMethod( - Invocation.method( - #registerCustomDialogBuilders, - [builders], - ), - returnValueForMissingStub: null, - ); - - @override - void registerCustomDialogBuilder({ - required dynamic variant, - required _i10.Widget Function( - _i10.BuildContext, - _i2.DialogRequest, - dynamic Function(_i2.DialogResponse), - )? builder, - }) => - super.noSuchMethod( - Invocation.method( - #registerCustomDialogBuilder, - [], - { - #variant: variant, - #builder: builder, - }, - ), - returnValueForMissingStub: null, - ); - - @override - _i5.Future<_i2.DialogResponse?> showDialog({ - String? title, - String? description, - String? cancelTitle, - _i11.Color? cancelTitleColor, - String? buttonTitle = r'Ok', - _i11.Color? buttonTitleColor, - bool? barrierDismissible = false, - _i10.RouteSettings? routeSettings, - _i10.GlobalKey<_i10.NavigatorState>? navigatorKey, - _i2.DialogPlatform? dialogPlatform, - }) => - (super.noSuchMethod( - Invocation.method( - #showDialog, - [], - { - #title: title, - #description: description, - #cancelTitle: cancelTitle, - #cancelTitleColor: cancelTitleColor, - #buttonTitle: buttonTitle, - #buttonTitleColor: buttonTitleColor, - #barrierDismissible: barrierDismissible, - #routeSettings: routeSettings, - #navigatorKey: navigatorKey, - #dialogPlatform: dialogPlatform, - }, - ), - returnValue: _i5.Future<_i2.DialogResponse?>.value(), - returnValueForMissingStub: - _i5.Future<_i2.DialogResponse?>.value(), - ) as _i5.Future<_i2.DialogResponse?>); - - @override - _i5.Future<_i2.DialogResponse?> showCustomDialog({ - dynamic variant, - String? title, - String? description, - bool? hasImage = false, - String? imageUrl, - bool? showIconInMainButton = false, - String? mainButtonTitle, - bool? showIconInSecondaryButton = false, - String? secondaryButtonTitle, - bool? showIconInAdditionalButton = false, - String? additionalButtonTitle, - bool? takesInput = false, - _i11.Color? barrierColor = const _i11.Color(2315255808), - bool? barrierDismissible = false, - String? barrierLabel = r'', - bool? useSafeArea = true, - _i10.RouteSettings? routeSettings, - _i10.GlobalKey<_i10.NavigatorState>? navigatorKey, - _i10.RouteTransitionsBuilder? transitionBuilder, - dynamic customData, - R? data, - }) => - (super.noSuchMethod( - Invocation.method( - #showCustomDialog, - [], - { - #variant: variant, - #title: title, - #description: description, - #hasImage: hasImage, - #imageUrl: imageUrl, - #showIconInMainButton: showIconInMainButton, - #mainButtonTitle: mainButtonTitle, - #showIconInSecondaryButton: showIconInSecondaryButton, - #secondaryButtonTitle: secondaryButtonTitle, - #showIconInAdditionalButton: showIconInAdditionalButton, - #additionalButtonTitle: additionalButtonTitle, - #takesInput: takesInput, - #barrierColor: barrierColor, - #barrierDismissible: barrierDismissible, - #barrierLabel: barrierLabel, - #useSafeArea: useSafeArea, - #routeSettings: routeSettings, - #navigatorKey: navigatorKey, - #transitionBuilder: transitionBuilder, - #customData: customData, - #data: data, - }, - ), - returnValue: _i5.Future<_i2.DialogResponse?>.value(), - returnValueForMissingStub: _i5.Future<_i2.DialogResponse?>.value(), - ) as _i5.Future<_i2.DialogResponse?>); - - @override - _i5.Future<_i2.DialogResponse?> showConfirmationDialog({ - String? title, - String? description, - String? cancelTitle = r'Cancel', - _i11.Color? cancelTitleColor, - String? confirmationTitle = r'Ok', - _i11.Color? confirmationTitleColor, - bool? barrierDismissible = false, - _i10.RouteSettings? routeSettings, - _i2.DialogPlatform? dialogPlatform, - }) => - (super.noSuchMethod( - Invocation.method( - #showConfirmationDialog, - [], - { - #title: title, - #description: description, - #cancelTitle: cancelTitle, - #cancelTitleColor: cancelTitleColor, - #confirmationTitle: confirmationTitle, - #confirmationTitleColor: confirmationTitleColor, - #barrierDismissible: barrierDismissible, - #routeSettings: routeSettings, - #dialogPlatform: dialogPlatform, - }, - ), - returnValue: _i5.Future<_i2.DialogResponse?>.value(), - returnValueForMissingStub: - _i5.Future<_i2.DialogResponse?>.value(), - ) as _i5.Future<_i2.DialogResponse?>); - - @override - void completeDialog(_i2.DialogResponse? response) => - super.noSuchMethod( - Invocation.method( - #completeDialog, - [response], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [AuthenticationService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAuthenticationService extends _i1.Mock - implements _i12.AuthenticationService { - @override - _i2.SnackbarService get snackbar => (super.noSuchMethod( - Invocation.getter(#snackbar), - returnValue: _FakeSnackbarService_0( - this, - Invocation.getter(#snackbar), - ), - returnValueForMissingStub: _FakeSnackbarService_0( - this, - Invocation.getter(#snackbar), - ), - ) as _i2.SnackbarService); - - @override - set snackbar(_i2.SnackbarService? _snackbar) => super.noSuchMethod( - Invocation.setter( - #snackbar, - _snackbar, - ), - returnValueForMissingStub: null, - ); - - @override - _i3.FlutterSecureStorage get store => (super.noSuchMethod( - Invocation.getter(#store), - returnValue: _FakeFlutterSecureStorage_1( - this, - Invocation.getter(#store), - ), - returnValueForMissingStub: _FakeFlutterSecureStorage_1( - this, - Invocation.getter(#store), - ), - ) as _i3.FlutterSecureStorage); - - @override - _i4.Auth0 get auth0 => (super.noSuchMethod( - Invocation.getter(#auth0), - returnValue: _FakeAuth0_2( - this, - Invocation.getter(#auth0), - ), - returnValueForMissingStub: _FakeAuth0_2( - this, - Invocation.getter(#auth0), - ), - ) as _i4.Auth0); - - @override - set auth0(_i4.Auth0? _auth0) => super.noSuchMethod( - Invocation.setter( - #auth0, - _auth0, - ), - returnValueForMissingStub: null, - ); - - @override - set userModel(_i5.Future<_i6.FccUserModel>? _userModel) => super.noSuchMethod( - Invocation.setter( - #userModel, - _userModel, - ), - returnValueForMissingStub: null, - ); - - @override - bool get isDevMode => (super.noSuchMethod( - Invocation.getter(#isDevMode), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - set isDevMode(bool? _isDevMode) => super.noSuchMethod( - Invocation.setter( - #isDevMode, - _isDevMode, - ), - returnValueForMissingStub: null, - ); - - @override - _i5.StreamController get progress => (super.noSuchMethod( - Invocation.getter(#progress), - returnValue: _FakeStreamController_3( - this, - Invocation.getter(#progress), - ), - returnValueForMissingStub: _FakeStreamController_3( - this, - Invocation.getter(#progress), - ), - ) as _i5.StreamController); - - @override - set progress(_i5.StreamController? _progress) => super.noSuchMethod( - Invocation.setter( - #progress, - _progress, - ), - returnValueForMissingStub: null, - ); - - @override - String get csrf => (super.noSuchMethod( - Invocation.getter(#csrf), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#csrf), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#csrf), - ), - ) as String); - - @override - String get csrfToken => (super.noSuchMethod( - Invocation.getter(#csrfToken), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#csrfToken), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#csrfToken), - ), - ) as String); - - @override - String get jwtAccessToken => (super.noSuchMethod( - Invocation.getter(#jwtAccessToken), - returnValue: _i9.dummyValue( - this, - Invocation.getter(#jwtAccessToken), - ), - returnValueForMissingStub: _i9.dummyValue( - this, - Invocation.getter(#jwtAccessToken), - ), - ) as String); - - @override - _i5.Stream get isLoggedIn => (super.noSuchMethod( - Invocation.getter(#isLoggedIn), - returnValue: _i5.Stream.empty(), - returnValueForMissingStub: _i5.Stream.empty(), - ) as _i5.Stream); - - @override - _i5.Future hasRequiredTokens() => (super.noSuchMethod( - Invocation.method( - #hasRequiredTokens, - [], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future writeTokensToStorage() => (super.noSuchMethod( - Invocation.method( - #writeTokensToStorage, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future setRequiredTokens() => (super.noSuchMethod( - Invocation.method( - #setRequiredTokens, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - void extractCookies(_i13.Response? res) => super.noSuchMethod( - Invocation.method( - #extractCookies, - [res], - ), - returnValueForMissingStub: null, - ); - - @override - _i5.Future setCurrentClientMode() => (super.noSuchMethod( - Invocation.method( - #setCurrentClientMode, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future init() => (super.noSuchMethod( - Invocation.method( - #init, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future<_i6.FccUserModel> parseUserModel(Map? data) => - (super.noSuchMethod( - Invocation.method( - #parseUserModel, - [data], - ), - returnValue: _i5.Future<_i6.FccUserModel>.value(_FakeFccUserModel_4( - this, - Invocation.method( - #parseUserModel, - [data], - ), - )), - returnValueForMissingStub: - _i5.Future<_i6.FccUserModel>.value(_FakeFccUserModel_4( - this, - Invocation.method( - #parseUserModel, - [data], - ), - )), - ) as _i5.Future<_i6.FccUserModel>); - - @override - _i5.Future login( - _i10.BuildContext? context, - String? connectionType, { - String? email, - String? otp, - }) => - (super.noSuchMethod( - Invocation.method( - #login, - [ - context, - connectionType, - ], - { - #email: email, - #otp: otp, - }, - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future logout() => (super.noSuchMethod( - Invocation.method( - #logout, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future fetchUser() => (super.noSuchMethod( - Invocation.method( - #fetchUser, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - void routeToLogin([bool? fromButton = false]) => super.noSuchMethod( - Invocation.method( - #routeToLogin, - [fromButton], - ), - returnValueForMissingStub: null, - ); -} - -/// A class which mocks [DailyChallengeService]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockDailyChallengeService extends _i1.Mock - implements _i14.DailyChallengeService { - @override - _i5.Future> fetchAllDailyChallenges() => - (super.noSuchMethod( - Invocation.method( - #fetchAllDailyChallenges, - [], - ), - returnValue: _i5.Future>.value( - <_i7.DailyChallengeOverview>[]), - returnValueForMissingStub: - _i5.Future>.value( - <_i7.DailyChallengeOverview>[]), - ) as _i5.Future>); - - @override - _i5.Future<_i7.DailyChallenge> fetchChallengeByDate(String? date) => - (super.noSuchMethod( - Invocation.method( - #fetchChallengeByDate, - [date], - ), - returnValue: _i5.Future<_i7.DailyChallenge>.value(_FakeDailyChallenge_5( - this, - Invocation.method( - #fetchChallengeByDate, - [date], - ), - )), - returnValueForMissingStub: - _i5.Future<_i7.DailyChallenge>.value(_FakeDailyChallenge_5( - this, - Invocation.method( - #fetchChallengeByDate, - [date], - ), - )), - ) as _i5.Future<_i7.DailyChallenge>); - - @override - _i5.Future<_i7.DailyChallenge> fetchTodayChallenge() => (super.noSuchMethod( - Invocation.method( - #fetchTodayChallenge, - [], - ), - returnValue: _i5.Future<_i7.DailyChallenge>.value(_FakeDailyChallenge_5( - this, - Invocation.method( - #fetchTodayChallenge, - [], - ), - )), - returnValueForMissingStub: - _i5.Future<_i7.DailyChallenge>.value(_FakeDailyChallenge_5( - this, - Invocation.method( - #fetchTodayChallenge, - [], - ), - )), - ) as _i5.Future<_i7.DailyChallenge>); - - @override - _i5.Future postChallengeCompleted({ - required String? challengeId, - required _i7.DailyChallengeLanguage? language, - }) => - (super.noSuchMethod( - Invocation.method( - #postChallengeCompleted, - [], - { - #challengeId: challengeId, - #language: language, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future<_i8.Challenge> getDailyChallenge( - String? date, - _i15.Block? block, { - _i7.DailyChallengeLanguage? language, - }) => - (super.noSuchMethod( - Invocation.method( - #getDailyChallenge, - [ - date, - block, - ], - {#language: language}, - ), - returnValue: _i5.Future<_i8.Challenge>.value(_FakeChallenge_6( - this, - Invocation.method( - #getDailyChallenge, - [ - date, - block, - ], - {#language: language}, - ), - )), - returnValueForMissingStub: - _i5.Future<_i8.Challenge>.value(_FakeChallenge_6( - this, - Invocation.method( - #getDailyChallenge, - [ - date, - block, - ], - {#language: language}, - ), - )), - ) as _i5.Future<_i8.Challenge>); -} - -/// A class which mocks [FlutterLocalNotificationsPlugin]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockFlutterLocalNotificationsPlugin extends _i1.Mock - implements _i16.FlutterLocalNotificationsPlugin { - @override - _i5.Future initialize({ - required _i17.InitializationSettings? settings, - _i18.DidReceiveNotificationResponseCallback? - onDidReceiveNotificationResponse, - _i18.DidReceiveBackgroundNotificationResponseCallback? - onDidReceiveBackgroundNotificationResponse, - }) => - (super.noSuchMethod( - Invocation.method( - #initialize, - [], - { - #settings: settings, - #onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, - #onDidReceiveBackgroundNotificationResponse: - onDidReceiveBackgroundNotificationResponse, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future<_i18.NotificationAppLaunchDetails?> - getNotificationAppLaunchDetails() => (super.noSuchMethod( - Invocation.method( - #getNotificationAppLaunchDetails, - [], - ), - returnValue: _i5.Future<_i18.NotificationAppLaunchDetails?>.value(), - returnValueForMissingStub: - _i5.Future<_i18.NotificationAppLaunchDetails?>.value(), - ) as _i5.Future<_i18.NotificationAppLaunchDetails?>); - - @override - _i5.Future show({ - required int? id, - String? title, - String? body, - _i19.NotificationDetails? notificationDetails, - String? payload, - }) => - (super.noSuchMethod( - Invocation.method( - #show, - [], - { - #id: id, - #title: title, - #body: body, - #notificationDetails: notificationDetails, - #payload: payload, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future cancel({ - required int? id, - String? tag, - }) => - (super.noSuchMethod( - Invocation.method( - #cancel, - [], - { - #id: id, - #tag: tag, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future cancelAll() => (super.noSuchMethod( - Invocation.method( - #cancelAll, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future cancelAllPendingNotifications() => (super.noSuchMethod( - Invocation.method( - #cancelAllPendingNotifications, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future zonedSchedule({ - required int? id, - required _i20.TZDateTime? scheduledDate, - required _i19.NotificationDetails? notificationDetails, - required _i21.AndroidScheduleMode? androidScheduleMode, - String? title, - String? body, - String? payload, - _i18.DateTimeComponents? matchDateTimeComponents, - }) => - (super.noSuchMethod( - Invocation.method( - #zonedSchedule, - [], - { - #id: id, - #scheduledDate: scheduledDate, - #notificationDetails: notificationDetails, - #androidScheduleMode: androidScheduleMode, - #title: title, - #body: body, - #payload: payload, - #matchDateTimeComponents: matchDateTimeComponents, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future periodicallyShow({ - required int? id, - required _i18.RepeatInterval? repeatInterval, - required _i19.NotificationDetails? notificationDetails, - required _i21.AndroidScheduleMode? androidScheduleMode, - String? title, - String? body, - String? payload, - }) => - (super.noSuchMethod( - Invocation.method( - #periodicallyShow, - [], - { - #id: id, - #repeatInterval: repeatInterval, - #notificationDetails: notificationDetails, - #androidScheduleMode: androidScheduleMode, - #title: title, - #body: body, - #payload: payload, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future periodicallyShowWithDuration({ - required int? id, - required Duration? repeatDurationInterval, - required _i19.NotificationDetails? notificationDetails, - String? title, - String? body, - _i21.AndroidScheduleMode? androidScheduleMode = - _i21.AndroidScheduleMode.exact, - String? payload, - }) => - (super.noSuchMethod( - Invocation.method( - #periodicallyShowWithDuration, - [], - { - #id: id, - #repeatDurationInterval: repeatDurationInterval, - #notificationDetails: notificationDetails, - #title: title, - #body: body, - #androidScheduleMode: androidScheduleMode, - #payload: payload, - }, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - - @override - _i5.Future> - pendingNotificationRequests() => (super.noSuchMethod( - Invocation.method( - #pendingNotificationRequests, - [], - ), - returnValue: - _i5.Future>.value( - <_i18.PendingNotificationRequest>[]), - returnValueForMissingStub: - _i5.Future>.value( - <_i18.PendingNotificationRequest>[]), - ) as _i5.Future>); - - @override - _i5.Future> getActiveNotifications() => - (super.noSuchMethod( - Invocation.method( - #getActiveNotifications, - [], - ), - returnValue: _i5.Future>.value( - <_i18.ActiveNotification>[]), - returnValueForMissingStub: - _i5.Future>.value( - <_i18.ActiveNotification>[]), - ) as _i5.Future>); -} - -/// A class which mocks [SharedPreferences]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockSharedPreferences extends _i1.Mock implements _i22.SharedPreferences { - @override - Set getKeys() => (super.noSuchMethod( - Invocation.method( - #getKeys, - [], - ), - returnValue: {}, - returnValueForMissingStub: {}, - ) as Set); - - @override - Object? get(String? key) => (super.noSuchMethod( - Invocation.method( - #get, - [key], - ), - returnValueForMissingStub: null, - ) as Object?); - - @override - bool? getBool(String? key) => (super.noSuchMethod( - Invocation.method( - #getBool, - [key], - ), - returnValueForMissingStub: null, - ) as bool?); - - @override - int? getInt(String? key) => (super.noSuchMethod( - Invocation.method( - #getInt, - [key], - ), - returnValueForMissingStub: null, - ) as int?); - - @override - double? getDouble(String? key) => (super.noSuchMethod( - Invocation.method( - #getDouble, - [key], - ), - returnValueForMissingStub: null, - ) as double?); - - @override - String? getString(String? key) => (super.noSuchMethod( - Invocation.method( - #getString, - [key], - ), - returnValueForMissingStub: null, - ) as String?); - - @override - bool containsKey(String? key) => (super.noSuchMethod( - Invocation.method( - #containsKey, - [key], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - - @override - List? getStringList(String? key) => (super.noSuchMethod( - Invocation.method( - #getStringList, - [key], - ), - returnValueForMissingStub: null, - ) as List?); - - @override - _i5.Future setBool( - String? key, - bool? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setBool, - [ - key, - value, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future setInt( - String? key, - int? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setInt, - [ - key, - value, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future setDouble( - String? key, - double? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setDouble, - [ - key, - value, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future setString( - String? key, - String? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setString, - [ - key, - value, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future setStringList( - String? key, - List? value, - ) => - (super.noSuchMethod( - Invocation.method( - #setStringList, - [ - key, - value, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future remove(String? key) => (super.noSuchMethod( - Invocation.method( - #remove, - [key], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future commit() => (super.noSuchMethod( - Invocation.method( - #commit, - [], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future clear() => (super.noSuchMethod( - Invocation.method( - #clear, - [], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - - @override - _i5.Future reload() => (super.noSuchMethod( - Invocation.method( - #reload, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); -} diff --git a/mobile-app/test/services/firebase/remote_config_service_test.dart b/mobile-app/test/services/firebase/remote_config_service_test.dart deleted file mode 100644 index a1f0b7c81..000000000 --- a/mobile-app/test/services/firebase/remote_config_service_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/service/firebase/remote_config_service.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('RemoteConfigService activation overrides', () { - test('reads superblock override from activation_overrides', () { - final service = RemoteConfigService.withConfigReader( - (_) => '{"javascript-algorithms-and-data-structures": false}', - ); - - expect( - service.getActivationOverride( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - ), - isFalse, - ); - - expect( - service.isActive( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - fallbackValue: true, - ), - isFalse, - ); - }); - - test('reads block override from superblock/block scoped key', () { - final service = RemoteConfigService.withConfigReader( - (_) => - '{"javascript-algorithms-and-data-structures/basic-javascript": false}', - ); - - expect( - service.getActivationOverride( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - blockDashedName: 'basic-javascript', - ), - isFalse, - ); - - expect( - service.isActive( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - blockDashedName: 'basic-javascript', - fallbackValue: true, - ), - isFalse, - ); - }); - - test('returns fallback when override is missing', () { - final service = RemoteConfigService.withConfigReader((_) => '{}'); - - expect( - service.isActive( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - fallbackValue: true, - ), - isTrue, - ); - - expect( - service.isActive( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - blockDashedName: 'basic-javascript', - fallbackValue: false, - ), - isFalse, - ); - }); - - test('ignores invalid override config payloads', () { - final invalidShapeService = RemoteConfigService.withConfigReader( - (_) => '["not-a-map"]', - ); - - expect( - invalidShapeService.getActivationOverride( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - ), - isNull, - ); - - final nonBooleanValueService = RemoteConfigService.withConfigReader( - (_) => '{"javascript-algorithms-and-data-structures": "false"}', - ); - - expect( - nonBooleanValueService.getActivationOverride( - superBlockDashedName: 'javascript-algorithms-and-data-structures', - ), - isNull, - ); - }); - }); -} diff --git a/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart b/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart deleted file mode 100644 index 58fb67a21..000000000 --- a/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart +++ /dev/null @@ -1,711 +0,0 @@ -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/enums/theme_type.dart'; -import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/models/main/profile_ui_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/service/locale_service.dart'; -import 'package:mockito/mockito.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:timezone/data/latest.dart' as tz; -import 'package:timezone/timezone.dart' as tz; -import '../../helpers/test_helpers.mocks.dart'; - -class TestDailyChallengeNotificationService - extends DailyChallengeNotificationService { - bool notificationsEnabled = true; - bool systemNotificationsEnabled = true; - - TestDailyChallengeNotificationService({ - required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin, - required DailyChallengeService dailyChallengeService, - required AuthenticationService authenticationService, - required LocaleService localeService, - }) : super( - flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, - dailyChallengeService: dailyChallengeService, - authenticationService: authenticationService, - localeService: localeService, - ); - - @override - Future areNotificationsEnabled() async => notificationsEnabled; - - @override - Future areSystemNotificationsEnabled() async => - systemNotificationsEnabled; -} - -class MockAndroidFlutterLocalNotificationsPlugin extends Mock - implements AndroidFlutterLocalNotificationsPlugin {} - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - late MockFlutterLocalNotificationsPlugin mockNotifications; - late MockDailyChallengeService mockDailyChallengeService; - late MockAuthenticationService mockAuthenticationService; - late LocaleService localeService; - late tz.Location tzLocation; - late DailyChallengeNotificationService service; - late SharedPreferences prefs; - late MockAndroidFlutterLocalNotificationsPlugin mockAndroidNotifications; - late TestDailyChallengeNotificationService notificationService; - - setUp(() async { - tz.initializeTimeZones(); - tzLocation = tz.getLocation('America/Chicago'); - mockNotifications = MockFlutterLocalNotificationsPlugin(); - mockDailyChallengeService = MockDailyChallengeService(); - mockAuthenticationService = MockAuthenticationService(); - localeService = LocaleService(); - service = DailyChallengeNotificationService( - flutterLocalNotificationsPlugin: mockNotifications, - dailyChallengeService: mockDailyChallengeService, - authenticationService: mockAuthenticationService, - localeService: localeService, - ); - mockAndroidNotifications = MockAndroidFlutterLocalNotificationsPlugin(); - - when(mockNotifications.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>()) - .thenReturn(mockAndroidNotifications); - - SharedPreferences.setMockInitialValues({}); - prefs = await SharedPreferences.getInstance(); - - notificationService = TestDailyChallengeNotificationService( - flutterLocalNotificationsPlugin: mockNotifications, - dailyChallengeService: mockDailyChallengeService, - authenticationService: mockAuthenticationService, - localeService: localeService, - ); - }); - - group('findNextValidNotificationTime', () { - test('should return 9 AM today if the current time is before 9 AM', () { - final scheduleDate = DateTime(2025, 8, 9); // 2025-08-09 - final now = DateTime(2025, 8, 9, 8, 0, 0); // 2025-08-09 08:00:00 - final result = service - .findNextValidNotificationTime(scheduleDate, tzLocation, now: now); - - expect(result.year, 2025); - expect(result.month, 8); - expect(result.day, 9); - expect(result.hour, 9); - }); - - test('should return 9 AM tomorrow if the current time is after 9 PM', () { - final scheduleDate = DateTime(2025, 8, 9); // 2025-08-09 - final now = DateTime(2025, 8, 9, 22, 0, 0); // 2025-08-09 22:00:00 - final result = service - .findNextValidNotificationTime(scheduleDate, tzLocation, now: now); - - expect(result.year, 2025); - expect(result.month, 8); - expect(result.day, 10); - expect(result.hour, 9); - }); - - test( - 'should return 9 AM tomorrow if the current time is between 9 AM and 9 PM', - () { - final scheduleDate = DateTime(2025, 8, 9); // 2025-08-09 - final now = DateTime(2025, 8, 9, 15, 0, 0); // 2025-08-09 15:00:00 - final result = service - .findNextValidNotificationTime(scheduleDate, tzLocation, now: now); - - expect(result.year, 2025); - expect(result.month, 8); - expect(result.day, 10); - expect(result.hour, 9); - }); - - test( - 'should default to 9 AM on the scheduleDate if scheduleDate is not today', - () { - final scheduleDate = DateTime(2025, 8, 11); // 2025-08-11 - final now = DateTime(2025, 8, 8, 12, 0, 0); // 2025-08-08 12:00:00 - final result = service - .findNextValidNotificationTime(scheduleDate, tzLocation, now: now); - - expect(result.year, 2025); - expect(result.month, 8); - expect(result.day, 11); - expect(result.hour, 9); - }); - }); - - group('hasNotificationWindowExpired', () { - test('should return true if more than 7 days have passed since start date', - () async { - final now = DateTime(2025, 8, 10); - final startDate = now.subtract(const Duration(days: 8)); - - await prefs.setString( - 'notification_schedule_start_date', startDate.toIso8601String()); - - final result = await service.hasNotificationWindowExpired(prefs, now); - - expect(result, isTrue); - }); - - test('should return false if less than 7 days have passed since start date', - () async { - final now = DateTime(2025, 8, 10); - final startDate = now.subtract(const Duration(days: 3)); - - await prefs.setString( - 'notification_schedule_start_date', startDate.toIso8601String()); - - final result = await service.hasNotificationWindowExpired(prefs, now); - - expect(result, isFalse); - }); - - test('should return false if no start date is set', () async { - await prefs.remove('notification_schedule_start_date'); - - final now = DateTime(2025, 8, 10); - final result = await service.hasNotificationWindowExpired(prefs, now); - - expect(result, isFalse); - }); - }); - - group('determineSchedulingStartDate', () { - test('should return tomorrow if today challenge is completed', () async { - final now = DateTime(2025, 8, 10); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser( - completedDailyCodingChallenges: [ - CompletedDailyChallenge( - id: 'challenge1', - completedDate: now, - languages: const ['javascript']) - ], - )); - - final result = await service.determineSchedulingStartDate(prefs, now); - final tomorrow = now.add(const Duration(days: 1)); - - expect(result.year, tomorrow.year); - expect(result.month, tomorrow.month); - expect(result.day, tomorrow.day); - }); - - test( - 'should return cached date if today challenge is not completed and cache exists', - () async { - final now = DateTime(2025, 8, 10); - final cachedDate = now.subtract(const Duration(days: 2)); - - await prefs.setString( - 'notification_schedule_start_date', cachedDate.toIso8601String()); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser()); - - final result = await service.determineSchedulingStartDate(prefs, now); - - expect(result.year, cachedDate.year); - expect(result.month, cachedDate.month); - expect(result.day, cachedDate.day); - }); - - test('should return today if today not completed and no cache', () async { - final now = DateTime(2025, 8, 10); - - await prefs.remove('notification_schedule_start_date'); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser()); - - final result = await service.determineSchedulingStartDate(prefs, now); - - expect(result.year, now.year); - expect(result.month, now.month); - expect(result.day, now.day); - }); - }); - - group('checkIfTodayChallengeCompleted', () { - test('should return true if today challenge is completed', () async { - final now = DateTime(2025, 8, 10); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser( - completedDailyCodingChallenges: [ - CompletedDailyChallenge( - id: 'challenge1', - completedDate: now, - languages: const ['javascript']) - ], - )); - - final result = await service.checkIfTodayChallengeCompleted(); - expect(result, isTrue); - }); - - test('should return false if today challenge is not completed', () async { - final now = DateTime(2025, 8, 10); - final yesterday = now.subtract(const Duration(days: 1)); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge2', - challengeNumber: 2, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser( - completedDailyCodingChallenges: [ - CompletedDailyChallenge( - id: 'challenge1', - completedDate: yesterday, - languages: const ['javascript'], - ) - ], - )); - - final result = await service.checkIfTodayChallengeCompleted(); - expect(result, isFalse); - }); - - test('should return false if userModel is null', () async { - final now = DateTime(2025, 8, 10); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel).thenReturn(null); - - final result = await service.checkIfTodayChallengeCompleted(); - expect(result, isFalse); - }); - - test('should return false if fetchTodayChallenge throws', () async { - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenThrow(Exception('error')); - - final result = await service.checkIfTodayChallengeCompleted(); - expect(result, isFalse); - }); - }); - - group('scheduleNotificationsForPeriod', () { - test('should schedule 7 notifications if the start date is in the future', - () async { - final now = DateTime(2025, 8, 1); - final startSchedulingFrom = DateTime(2025, 8, 2); - - await service.scheduleNotificationsForPeriod(startSchedulingFrom, now); - - verify(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )).called(7); - }); - - test( - 'should schedule fewer notifications if start date is not in the future', - () async { - // `startSchedulingFrom` is reset every time the user opens the app - // and it can be in the past if the user hasn't opened the app for several days. - // In this case, the function schedules notifications for the upcoming days. - final now = DateTime(2025, 8, 2); - final startSchedulingFrom = DateTime(2025, 7, 31); - - await service.scheduleNotificationsForPeriod(startSchedulingFrom, now); - - verify(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )).called(5); - }); - - test( - 'should schedule no notifications if start date + 7 are all in the past', - () async { - final now = DateTime(2025, 8, 10); - final startSchedulingFrom = DateTime(2025, 8, 1); - - await service.scheduleNotificationsForPeriod(startSchedulingFrom, now); - - verifyNever(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )); - }); - }); - - group('scheduleDailyChallengeNotification', () { - test('should not schedule if notifications are disabled in prefs', - () async { - notificationService.notificationsEnabled = false; - notificationService.systemNotificationsEnabled = true; - - await prefs.setBool('daily_challenge_notifications_enabled', false); - await notificationService.scheduleDailyChallengeNotification(); - - verifyNever(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )); - - expect(prefs.getString('notification_schedule_start_date'), isNull); - }); - - test('should not schedule if system notifications are disabled', () async { - notificationService.notificationsEnabled = true; - notificationService.systemNotificationsEnabled = false; - - await prefs.setBool('daily_challenge_notifications_enabled', true); - await notificationService.scheduleDailyChallengeNotification(); - - verifyNever(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )); - - expect(prefs.getString('notification_schedule_start_date'), isNull); - }); - - test('should not schedule if notification window expired', () async { - await prefs.setBool('daily_challenge_notifications_enabled', true); - - final now = DateTime.now(); - final expiredDate = now.subtract(const Duration(days: 8)); - - await prefs.setString( - 'notification_schedule_start_date', expiredDate.toIso8601String()); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser()); - - await notificationService.scheduleDailyChallengeNotification(); - - verifyNever(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )); - - final startDateStr = prefs.getString('notification_schedule_start_date'); - final startDate = DateTime.parse(startDateStr!); - - // notification_schedule_start_date isn't updated in this case - expect(startDate.year, equals(expiredDate.year)); - expect(startDate.month, equals(expiredDate.month)); - expect(startDate.day, equals(expiredDate.day)); - }); - - test( - 'should schedule notifications and update cache if enabled, notification window not expired, and today\'s challenge not completed', - () async { - await prefs.setBool('daily_challenge_notifications_enabled', true); - - final now = DateTime.now(); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser()); - - await notificationService.scheduleDailyChallengeNotification(); - - verify(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )).called(equals(7)); - - final startDateStr = prefs.getString('notification_schedule_start_date'); - final startDate = DateTime.parse(startDateStr!); - - expect(startDate.year, equals(now.year)); - expect(startDate.month, equals(now.month)); - expect(startDate.day, equals(now.day)); - }); - - test( - 'should schedule notifications and update cache if enabled, notification window not expired, and today\'s challenge completed', - () async { - await prefs.setBool('daily_challenge_notifications_enabled', true); - - final now = DateTime.now(); - - when(mockDailyChallengeService.fetchTodayChallenge()) - .thenAnswer((_) async => DailyChallenge( - id: 'challenge1', - challengeNumber: 1, - date: now, - title: 'Challenge', - description: 'desc', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - )); - - when(mockAuthenticationService.userModel) - .thenAnswer((_) async => createMockUser( - completedDailyCodingChallenges: [ - CompletedDailyChallenge( - id: 'challenge1', - completedDate: now, - languages: const ['javascript'], - ) - ], - )); - - await notificationService.scheduleDailyChallengeNotification(); - - verify(mockNotifications.zonedSchedule( - id: anyNamed('id'), - title: anyNamed('title'), - body: anyNamed('body'), - scheduledDate: anyNamed('scheduledDate'), - notificationDetails: anyNamed('notificationDetails'), - androidScheduleMode: anyNamed('androidScheduleMode'), - payload: anyNamed('payload'), - )).called(equals(7)); - - final tomorrow = DateTime(now.year, now.month, now.day + 1); - - final startDateStr = prefs.getString('notification_schedule_start_date'); - final startDate = DateTime.parse(startDateStr!); - - expect(startDate.year, equals(tomorrow.year)); - expect(startDate.month, equals(tomorrow.month)); - expect(startDate.day, equals(tomorrow.day)); - }); - }); -} - -FccUserModel createMockUser({ - List? completedDailyCodingChallenges, -}) { - return FccUserModel( - id: 'user1', - email: 'test@example.com', - username: 'testuser', - name: 'Test User', - picture: '', - currentChallengeId: '', - emailVerified: true, - isEmailVerified: true, - sendQuincyEmail: null, - isCheater: false, - isDonating: false, - isHonest: true, - isFrontEndCert: false, - isDataVisCert: false, - isBackEndCert: false, - isFullStackCert: false, - isRespWebDesignCert: false, - is2018DataVisCert: false, - isFrontEndLibsCert: false, - isJsAlgoDataStructCert: false, - isApisMicroservicesCert: false, - isInfosecQaCert: false, - isQaCertV7: false, - isInfosecCertV7: false, - isSciCompPyCertV7: false, - isDataAnalysisPyCertV7: false, - isMachineLearningPyCertV7: false, - isRelationalDatabaseCertV8: false, - isCollegeAlgebraPyCertV8: false, - isFoundationalCSharpCertV8: false, - joinDate: DateTime(2025, 7, 1), - points: 0, - calendar: {}, - heatMapCal: {}, - completedChallenges: [], - completedDailyCodingChallenges: - completedDailyCodingChallenges ?? [], - savedChallenges: const [], - portfolio: const [], - yearsTopContributor: [], - theme: Themes.defaultTheme, - profileUI: ProfileUI( - isLocked: false, - showAbout: false, - showCerts: false, - showDonation: false, - showHeatMap: false, - showLocation: false, - showName: false, - showPoints: false, - showPortfolio: false, - showTimeLine: false, - ), - ); -} diff --git a/mobile-app/test/services/news/bookmark_service_test.dart b/mobile-app/test/services/news/bookmark_service_test.dart deleted file mode 100644 index 4c24d7381..000000000 --- a/mobile-app/test/services/news/bookmark_service_test.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart'; -import 'package:freecodecamp/models/news/tutorial_model.dart'; -import 'package:freecodecamp/service/news/bookmark_service.dart'; -import 'package:path/path.dart' as path; - -import '../../helpers/test_helpers.dart'; - -final Tutorial testTutorial = Tutorial( - id: '61fad67af2ed6b06db5ab66f', - slug: 'hello-world', - featureImage: - 'https://www.freecodecamp.org/news/content/images/2022/01/python-game.png', - title: - 'Python Game Development - How to Make a Turtle Racing Game with PyCharm', - profileImage: - 'https://www.freecodecamp.org/news/content/images/2022/02/profile.jpg', - authorId: '', - authorName: 'Shahan Chowdhury', - authorSlug: 'shahan', - text: - '

    This is heading 1

    This is heading 2

    This is heading 3

    ', -); - -final BookmarkedTutorial testBookmarkTutorial = BookmarkedTutorial( - id: '61fad67af2ed6b06db5ab66f', - tutorialTitle: - 'Python Game Development - How to Make a Turtle Racing Game with PyCharm', - tutorialText: - '

    This is heading 1

    This is heading 2

    This is heading 3

    ', - authorName: 'Shahan Chowdhury', -); - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - Directory? tempDir; - - group('News Bookmark Service Test -', () { - setUp(() { - registerServices(); - tempDir = Directory.systemTemp.createTempSync('fcc_bookmarks_test_'); - if (locator.isRegistered()) { - locator.unregister(); - } - locator.registerSingleton( - BookmarksDatabaseService(storageDirectoryOverride: tempDir), - ); - }); - - tearDown(() { - locator.reset(); - try { - tempDir?.deleteSync(recursive: true); - } catch (_) {} - }); - - test('init service', () async { - final service = locator(); - await service.initialise(); - expect(service, isA()); - - final file = File( - path.join(tempDir!.path, 'storage', 'bookmarked-articles.json'), - ); - expect(await file.exists(), true); - }); - - test('get all bookmarks', () async { - final service = locator(); - await service.initialise(); - final bookmarks = await service.getBookmarks(); - expect(bookmarks, isA>()); - expect(bookmarks.length, 0); - }); - - test('add bookmark', () async { - final service = locator(); - await service.initialise(); - await service.addBookmark(testTutorial); - final bookmarks = await service.getBookmarks(); - expect(bookmarks, isA>()); - expect(bookmarks.length, 1); - expect(bookmarks[0].id, testBookmarkTutorial.id); - expect(bookmarks[0].tutorialTitle, testBookmarkTutorial.tutorialTitle); - expect(bookmarks[0].authorName, testBookmarkTutorial.authorName); - expect(bookmarks[0].tutorialText, testBookmarkTutorial.tutorialText); - }); - - test('check if bookmark exists', () async { - final service = locator(); - await service.initialise(); - await service.addBookmark(testTutorial); - expect(await service.isBookmarked(testBookmarkTutorial), true); - }); - - test('delete bookmark', () async { - final service = locator(); - await service.initialise(); - await service.addBookmark(testTutorial); - await service.removeBookmark(testBookmarkTutorial); - final bookmarks = await service.getBookmarks(); - expect(bookmarks, isA>()); - expect(bookmarks.length, 0); - }); - }); -} diff --git a/mobile-app/test/services/storage/json_file_store_test.dart b/mobile-app/test/services/storage/json_file_store_test.dart deleted file mode 100644 index 965bf72cf..000000000 --- a/mobile-app/test/services/storage/json_file_store_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/service/storage/json_file_store.dart'; -import 'package:path/path.dart' as path; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('JsonFileStore', () { - late Directory tempDir; - late File storeFile; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('fcc_json_store_test_'); - storeFile = File(path.join(tempDir.path, 'storage', 'store.json')); - }); - - tearDown(() { - try { - tempDir.deleteSync(recursive: true); - } catch (_) {} - }); - - test('ensureExists creates file with default value', () async { - final store = JsonFileStore( - file: storeFile, - defaultValue: 7, - fromJson: (json) => json['value'], - toJson: (value) => {'value': value}, - ); - - expect(await storeFile.exists(), isFalse); - await store.ensureExists(); - expect(await storeFile.exists(), isTrue); - - final readBack = await store.read(); - expect(readBack, 7); - }); - - test('write/read round trips typed value', () async { - final store = JsonFileStore>( - file: storeFile, - defaultValue: [], - fromJson: (json) { - final raw = json['items']; - if (raw is! List) return []; - return raw.map((e) => e.toString()).toList(); - }, - toJson: (value) => {'items': value}, - ); - - await store.write(const ['a', 'b']); - final readBack = await store.read(); - expect(readBack, ['a', 'b']); - }); - - test('read returns default when file contains invalid JSON', () async { - final store = JsonFileStore( - file: storeFile, - defaultValue: 'default', - fromJson: (json) => json['value'], - toJson: (value) => {'value': value}, - ); - - await store.ensureExists(); - await storeFile.writeAsString('{not json', flush: true); - - final readBack = await store.read(); - expect(readBack, 'default'); - }); - - test('updateAndWrite persists updater result', () async { - final store = JsonFileStore( - file: storeFile, - defaultValue: 0, - fromJson: (json) => json['value'], - toJson: (value) => {'value': value}, - ); - - await store.updateAndWrite((current) async => current + 1); - await store.updateAndWrite((current) async => current + 2); - - final readBack = await store.read(); - expect(readBack, 3); - }); - - test('updateAndWrite serializes concurrent updates', () async { - final store = JsonFileStore( - file: storeFile, - defaultValue: 0, - fromJson: (json) => json['value'], - toJson: (value) => {'value': value}, - ); - - await Future.wait( - List.generate( - 25, - (_) => store.updateAndWrite((current) async => current + 1), - ), - ); - - final readBack = await store.read(); - expect(readBack, 25); - }); - - test('does not leave .tmp file after write', () async { - final store = JsonFileStore( - file: storeFile, - defaultValue: 0, - fromJson: (json) => json['value'], - toJson: (value) => {'value': value}, - ); - - await store.write(1); - - final tmpFile = File('${storeFile.path}.tmp'); - expect(await tmpFile.exists(), isFalse); - }); - }); -} diff --git a/mobile-app/test/unit/challenge_utils_test.dart b/mobile-app/test/unit/challenge_utils_test.dart deleted file mode 100644 index c07a0f830..000000000 --- a/mobile-app/test/unit/challenge_utils_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart'; -import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; - -void main() { - Challenge createChallenge({ - required String id, - required String title, - }) { - return Challenge( - id: id, - block: 'block1', - title: title, - description: '', - instructions: '', - dashedName: 'challenge-$id', - superBlock: 'superblock', - challengeType: 0, - tests: [], - files: [], - helpCategory: HelpCategory.htmlCss, - transcript: '', - hooks: Hooks(beforeAll: '', beforeEach: '', afterEach: ''), - ); - } - - Block createBlock(List challenges) { - return Block( - superBlock: SuperBlock(dashedName: 'superblock', name: 'Super Block'), - layout: BlockLayout.challengeList, - label: BlockLabel.lecture, - name: 'Block 1', - dashedName: 'block-1', - description: [], - challenges: challenges - .map((c) => ChallengeOrder(id: c.id, title: c.title)) - .toList(), - challengeTiles: [], - ); - } - - group('handleChallengeTitle', () { - test('should return empty string if the block contains a single challenge', - () { - final challenge = createChallenge(id: '1', title: 'HTML Review'); - final block = createBlock([challenge]); - - expect(handleChallengeTitle(challenge, block), ''); - }); - - test("should return empty string when challenge title contains 'Dialogue'", - () { - final challenge = createChallenge(id: '1', title: 'Dialogue 1'); - final block = createBlock([challenge]); - - expect(handleChallengeTitle(challenge, block), ''); - }); - - test("should return empty string when challenge title contains 'Step'", () { - final challenge = createChallenge(id: '1', title: 'Step 1'); - final other = createChallenge(id: '2', title: 'Task 1'); - final block = createBlock([challenge, other]); - - expect(handleChallengeTitle(challenge, block), ''); - }); - - test('should return correct Task title with count', () { - final dialogue = createChallenge(id: '3', title: 'Dialogue 1'); - final challenge1 = createChallenge(id: '1', title: 'Task 1'); - final challenge2 = createChallenge(id: '2', title: 'Task 2'); - final challenge3 = createChallenge(id: '3', title: 'Task 3'); - final block = createBlock([dialogue, challenge1, challenge2, challenge3]); - - expect(handleChallengeTitle(challenge1, block), 'Task 1 of 3'); - expect(handleChallengeTitle(challenge2, block), 'Task 2 of 3'); - expect(handleChallengeTitle(challenge3, block), 'Task 3 of 3'); - }); - - test('should return correct Step title with count', () { - final challenge1 = createChallenge(id: '1', title: 'Intro'); - final challenge2 = createChallenge(id: '2', title: 'Content'); - final block = createBlock([challenge1, challenge2]); - - expect(handleChallengeTitle(challenge1, block), 'Step 1 of 2'); - expect(handleChallengeTitle(challenge2, block), 'Step 2 of 2'); - }); - }); - - group('formatMonthFromDate', () { - test('should format date correctly', () { - final date = DateTime(2025, 1, 15); - expect(formatMonthFromDate(date), 'January 2025'); - }); - }); - - group('parseMonthYear', () { - test('should parse date correctly', () { - final result = parseMonthYear('January 2025'); - expect(result.year, 2025); - expect(result.month, 1); - expect(result.day, 1); - }); - }); - - group('formatChallengeDate', () { - test('formats single-digit month and day with leading zeros', () { - final date = DateTime(2025, 3, 7); - expect(formatChallengeDate(date), '2025-03-07'); - }); - - test('formats double-digit month and day correctly', () { - final date = DateTime(2025, 12, 31); - expect(formatChallengeDate(date), '2025-12-31'); - }); - }); - - group('parseSyntaxError', () { - test('parses syntax error with line number', () { - final errorMessage = - 'Exception: Babel transpilation failed: SyntaxError: unknown: Unexpected token (4:0)'; - final result = parseSyntaxError(errorMessage); - expect( - result, - 'There is a syntax error on line 3. Please check for missing brackets, quotes, or other syntax issues.', - ); - }); - - test('parses syntax error with different line number', () { - final errorMessage = 'SyntaxError: Unexpected identifier (15:22)'; - final result = parseSyntaxError(errorMessage); - expect( - result, - 'There is a syntax error on line 14. Please check for missing brackets, quotes, or other syntax issues.', - ); - }); - - test('returns generic message for Babel error without line numbers', () { - final errorMessage = 'Babel transpilation failed: unknown error'; - final result = parseSyntaxError(errorMessage); - expect( - result, - 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.', - ); - }); - - test('returns original message for non-syntax errors', () { - final errorMessage = 'Some other error occurred'; - final result = parseSyntaxError(errorMessage); - expect(result, 'Some other error occurred'); - }); - }); -} diff --git a/mobile-app/test/unit/learn_file_controller_test.dart b/mobile-app/test/unit/learn_file_controller_test.dart deleted file mode 100644 index 6217ed878..000000000 --- a/mobile-app/test/unit/learn_file_controller_test.dart +++ /dev/null @@ -1,369 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/enums/ext_type.dart'; -import 'package:freecodecamp/models/learn/challenge_model.dart'; -import 'package:freecodecamp/service/learn/learn_file_service.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - LearnFileService service = LearnFileService(); - - Challenge challenge = Challenge( - id: '1', - block: 'basic html', - title: 'hello-world', - description: 'this is the description', - instructions: 'make your first header', - transcript: 'this is the transcript', - dashedName: 'hello-world', - superBlock: '2022/responsive-web-design', - challengeType: 1, - helpCategory: HelpCategory.htmlCss, - tests: [], - hooks: Hooks(beforeAll: '', beforeEach: '', afterEach: ''), - files: [ - ChallengeFile( - ext: Ext.html, - name: 'index', - editableRegionBoundaries: [], - contents: 'this is the html file content', - history: [], - fileKey: 'index.html', - ), - ChallengeFile( - ext: Ext.css, - name: 'styles', - editableRegionBoundaries: [1, 5], - contents: 'this is the css file content', - history: [], - fileKey: 'styles.css', - ) - ], - ); - - group('getExactFileFromCache function', () { - testWidgets( - 'with testing it should return the exact file', - (tester) async { - String content = await service.getExactFileFromCache( - challenge, - challenge.files[1], - testing: true, - ); - - expect(content, 'this is the css file content'); - }, - ); - }); - - group('getFirstFileFromCache function', () { - testWidgets('it should give priority for html files over css files', - (tester) async { - String value = await service.getFirstFileFromCache( - challenge, - Ext.css, - testing: true, - ); - - expect(value, 'this is the html file content'); - }); - }); - - group('getCurrentEditedFileFromCache function', () { - testWidgets('it should get the file with the editable region', - (tester) async { - SharedPreferences.setMockInitialValues({}); - String value = - (await service.getCurrentEditedFileFromCache(challenge)).contents; - - expect(value, 'this is the css file content'); - }); - - testWidgets( - 'if there is no editable region it should return the first file', - (tester) async { - Challenge newChallenge = challenge; - newChallenge.files[1].editableRegionBoundaries = []; - - String value = - (await service.getCurrentEditedFileFromCache(newChallenge)).contents; - - expect(value, 'this is the html file content'); - }); - }); - - group('cssFileLinked function', () { - testWidgets('it should return false if the file is not linked', - (tester) async { - bool value = await service.cssFileIsLinked( - challenge.files[0].contents, - challenge.files[1].name, - ); - - expect(value, false); - }); - - testWidgets('it should return true if the file is linked', (tester) async { - String document = ''' - - - Document - - - - '''; - - bool value = await service.cssFileIsLinked( - document, - challenge.files[1].fileKey, - ); - - expect(value, true); - }); - - testWidgets('it should return true if the file in a folder and is linked', - (tester) async { - String document = ''' - - - Document - - - - '''; - - bool value = await service.cssFileIsLinked( - document, - challenge.files[1].fileKey, - ); - - expect(value, true); - }); - }); - - group('jsFileIsLinked function', () { - testWidgets( - 'it should return false if the JS file is not linked in the HTML document', - (tester) async { - String document = ''' - - - - - - - '''; - bool value = await service.jsFileIsLinked(document, 'script.js'); - expect(value, false); - }); - - testWidgets( - 'it should return true if the JS file is linked in the HTML document', - (tester) async { - String document = ''' - - - - - - - '''; - bool value = await service.jsFileIsLinked(document, 'script.js'); - expect(value, true); - }); - - testWidgets( - 'it should return true if the JS file is in a folder and is linked in the HTML document', - (tester) async { - String document = ''' - - - - - - - '''; - bool value = await service.jsFileIsLinked(document, 'script.js'); - expect(value, true); - }); - }); - - group('parseCssDocumentsAsStyleTags function', () { - testWidgets('it should inject style tag if CSS file is linked', - (tester) async { - Challenge cssChallenge = Challenge( - id: '2', - block: 'css block', - title: 'css-test', - description: '', - instructions: '', - transcript: '', - dashedName: 'css-test', - superBlock: 'super block', - challengeType: 1, - helpCategory: HelpCategory.htmlCss, - tests: [], - hooks: Hooks(beforeAll: '', beforeEach: '', afterEach: ''), - files: [ - ChallengeFile( - ext: Ext.html, - name: 'index', - editableRegionBoundaries: [], - contents: - '''''', - history: [], - fileKey: 'index.html', - ), - ChallengeFile( - ext: Ext.css, - name: 'styles', - editableRegionBoundaries: [1, 5], - contents: 'body { color: red; }', - history: [], - fileKey: 'styles.css', - ) - ], - ); - String html = cssChallenge.files[0].contents; - String result = await service - .parseCssDocumentsAsStyleTags(cssChallenge, html, testing: true); - expect( - result, - contains( - '')); - }); - - testWidgets('it should not inject style tag if CSS file is not linked', - (tester) async { - Challenge cssChallenge = Challenge( - id: '3', - block: 'css block', - title: 'css-test', - description: '', - instructions: '', - transcript: '', - dashedName: 'css-test', - superBlock: 'super block', - challengeType: 1, - helpCategory: HelpCategory.htmlCss, - tests: [], - hooks: Hooks(beforeAll: '', beforeEach: '', afterEach: ''), - files: [ - ChallengeFile( - ext: Ext.html, - name: 'index', - editableRegionBoundaries: [], - contents: '''''', - history: [], - fileKey: 'index.html', - ), - ChallengeFile( - ext: Ext.css, - name: 'styles', - editableRegionBoundaries: [1, 5], - contents: 'body { color: blue; }', - history: [], - fileKey: 'styles.css', - ) - ], - ); - String html = cssChallenge.files[0].contents; - String result = await service - .parseCssDocumentsAsStyleTags(cssChallenge, html, testing: true); - expect(result, isNot(contains('fcc-injected-styles'))); - }); - }); - - group('parseJsDocumentsAsScriptTags function', () { - testWidgets( - 'it should throw an error if JS file is linked when babelController is null', - (tester) async { - Challenge jsChallenge = Challenge( - id: '4', - block: 'js block', - title: 'js-test', - description: '', - instructions: '', - transcript: '', - dashedName: 'js-test', - superBlock: 'super block', - challengeType: 1, - helpCategory: HelpCategory.htmlCss, - tests: [], - hooks: Hooks(beforeAll: '', beforeEach: '', afterEach: ''), - files: [ - ChallengeFile( - ext: Ext.html, - name: 'index', - editableRegionBoundaries: [], - contents: - '''''', - history: [], - fileKey: 'index.html', - ), - ChallengeFile( - ext: Ext.js, - name: 'script', - editableRegionBoundaries: [1, 5], - contents: 'console.log("hello");', - history: [], - fileKey: 'script.js', - ) - ], - ); - String html = jsChallenge.files[0].contents; - expect( - () async => await service.parseJsDocumentsAsScriptTags(jsChallenge, html, null, testing: true), - throwsA(isA().having((e) => e.toString(), 'description', contains('Babel controller is required to transpile JS code.'))), - ); - }); - }); - - group('changeActiveFileLinks function', () { - test('it should replace href/src with data-href/data-src for known files', - () { - String html = ''' - - - - - - - - '''; - - const expectedHtml = ''' - - - - - - - - '''; - - String result = service.changeActiveFileLinks(html); - String stripWhitespace(String s) => s.replaceAll(RegExp(r'\s+'), ''); - - expect(stripWhitespace(result), stripWhitespace(expectedHtml)); - }); - - test('it should not change unrelated links/scripts', () { - String html = ''' - - - - - - - - '''; - - String result = service.changeActiveFileLinks(html); - String stripWhitespace(String s) => s.replaceAll(RegExp(r'\s+'), ''); - - expect(stripWhitespace(result), stripWhitespace(html)); - }); - }); -} diff --git a/mobile-app/test/widget/daily_challenge_card_test.dart b/mobile-app/test/widget/daily_challenge_card_test.dart deleted file mode 100644 index 6d09e7ca8..000000000 --- a/mobile-app/test/widget/daily_challenge_card_test.dart +++ /dev/null @@ -1,216 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/app/app.router.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/ui/views/learn/widgets/daily_challenge_card.dart'; -import 'package:mockito/mockito.dart'; -import 'package:timezone/data/latest.dart' as tzdata; - -import '../helpers/test_helpers.dart'; -import '../helpers/test_helpers.mocks.dart'; - -void main() { - // Mock service variables - late MockNavigationService mockNavigationService; - - // Test data - final testChallenge = DailyChallenge( - id: 'test-challenge-id', - challengeNumber: 1, - date: DateTime(2025, 7, 26), - title: 'Test Daily Challenge', - description: 'A test challenge description', - javascript: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - python: DailyChallengeLanguageData( - tests: [], - challengeFiles: [], - ), - ); - - setUp(() { - TestWidgetsFlutterBinding.ensureInitialized(); - tzdata.initializeTimeZones(); - - // Register services - registerServices(); - mockNavigationService = getAndRegisterNavigationService(); - }); - - tearDown(() { - locator.reset(); - }); - - Widget createTestWidget({ - DailyChallenge? challenge, - bool isCompleted = false, - }) { - return MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: DailyChallengeCard( - dailyChallenge: challenge, - isCompleted: isCompleted, - ), - ), - onGenerateRoute: (settings) { - // Handle navigation routes used by the widget - if (settings.name == '/challenge-template-view') { - return MaterialPageRoute( - builder: (context) => Scaffold( - body: Center(child: Text('Challenge Template View')), - ), - ); - } - return null; - }, - ); - } - - testWidgets('should return empty widget when no challenge is provided', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget()); - - expect(find.byType(DailyChallengeCard), findsOneWidget); - expect(find.text('Today\'s challenge'), findsNothing); - expect(find.text('Start the challenge'), findsNothing); - }); - - testWidgets( - 'should show challenge details when challenge is provided and not completed', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: false, - )); - - expect(find.text('Today\'s challenge'), findsOneWidget); - expect(find.text('Test Daily Challenge'), findsOneWidget); - expect(find.text('Do you have the skills to complete this challenge?'), - findsOneWidget); - expect(find.text('Start the challenge'), findsOneWidget); - expect(find.byIcon(Icons.arrow_forward_ios), findsOneWidget); - - // Test navigation to the challenge - await tester.tap(find.text('Start the challenge')); - await tester.pumpAndSettle(); - expect(find.text('Challenge Template View'), findsOneWidget); - }); - - testWidgets( - 'should show completed state with timer and allow navigation to past challenges', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: true, - )); - - expect(find.text('Today\'s challenge completed!'), findsOneWidget); - expect(find.textContaining('Next challenge in:'), findsOneWidget); - expect(find.text('View past challenges'), findsOneWidget); - expect(find.byIcon(Icons.history), findsOneWidget); - - final timerRegex = RegExp(r'^Next challenge in: \d{2}:\d{2}:\d{2}$'); - final timerFinder = find.byWidgetPredicate((widget) { - if (widget is Text) { - return timerRegex.hasMatch(widget.data ?? ''); - } - return false; - }); - expect(timerFinder, findsOneWidget); - - // Test navigation to past challenges - await tester.tap(find.text('View past challenges')); - await tester.pumpAndSettle(); - verify(mockNavigationService.navigateTo(Routes.dailyChallengeView)) - .called(1); - }); - - testWidgets( - 'should have proper accessibility semantics for not completed state', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: false, - )); - - final cardSemanticsFinder = find.byElementPredicate((element) { - return element.widget is Semantics && - (element.widget as Semantics).properties.label == - 'Daily challenge card'; - }); - - final buttonSemanticsFinder = find.byElementPredicate((element) { - return element.widget is Semantics && - (element.widget as Semantics).properties.label == 'Go to challenge'; - }); - - expect(cardSemanticsFinder, findsOneWidget); - expect(buttonSemanticsFinder, findsOneWidget); - }); - - testWidgets('should have proper accessibility semantics for completed state', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: true, - )); - - final cardSemanticsFinder = find.byElementPredicate((element) { - return element.widget is Semantics && - (element.widget as Semantics).properties.label == - 'Daily challenge completed. View past challenges.'; - }); - - expect(cardSemanticsFinder, findsOneWidget); - }); - - testWidgets('should have live region semantics for timer in completed state', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: true, - )); - - final semanticsWidgets = - tester.widgetList(find.byType(Semantics)); - bool hasLiveRegion = false; - - for (final semantics in semanticsWidgets) { - if (semantics.properties.liveRegion == true) { - hasLiveRegion = true; - break; - } - } - - expect(hasLiveRegion, isTrue, - reason: 'Expected to find a Semantics widget with liveRegion: true'); - }); - - testWidgets('should display countdown timer in completed state', - (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget( - challenge: testChallenge, - isCompleted: true, - )); - - final timerRegex = RegExp(r'^Next challenge in: \d{2}:\d{2}:\d{2}$'); - final timerFinder = find.byWidgetPredicate((widget) { - if (widget is Text) { - return timerRegex.hasMatch(widget.data ?? ''); - } - return false; - }); - - expect(timerFinder, findsOneWidget); - - // Check that timer text contains expected format - final timerWidget = tester.widget(timerFinder); - expect(timerWidget.data, matches(timerRegex)); - }); -} diff --git a/mobile-app/test/widget/daily_challenge_view_test.dart b/mobile-app/test/widget/daily_challenge_view_test.dart deleted file mode 100644 index 3b8a218e4..000000000 --- a/mobile-app/test/widget/daily_challenge_view_test.dart +++ /dev/null @@ -1,372 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:freecodecamp/app/app.locator.dart'; -import 'package:freecodecamp/l10n/app_localizations.dart'; -import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; -import 'package:freecodecamp/models/main/user_model.dart'; -import 'package:freecodecamp/service/authentication/authentication_service.dart'; -import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; -import 'package:freecodecamp/ui/views/learn/daily_challenge/daily_challenge_view.dart'; -import 'package:mockito/mockito.dart'; - -import '../helpers/test_helpers.dart'; -import '../helpers/test_helpers.mocks.dart'; - -void main() { - group('DailyChallengeView Widget Tests', () { - late MockDailyChallengeService mockDailyChallengeService; - late MockAuthenticationService mockAuthService; - - setUp(() { - registerServices(); - - mockDailyChallengeService = - locator() as MockDailyChallengeService; - mockAuthService = - locator() as MockAuthenticationService; - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenAnswer((_) async => []); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - mockAuthService.userModel = null; - }); - - tearDown(() { - locator.reset(); - }); - - Widget createTestWidget() { - return const MaterialApp( - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: DailyChallengeView(), - ); - } - - testWidgets('should display app bar with correct title', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.pump(); - - expect(find.byType(AppBar), findsOneWidget); - expect(find.text('Daily Coding Challenges'), findsOneWidget); - }); - - testWidgets('should show empty state when no challenges', (tester) async { - await tester.pumpWidget(createTestWidget()); - - await tester.pumpAndSettle(); - - expect( - find.text('No challenges available at the moment.'), findsOneWidget); - }); - - testWidgets('should display challenges when service returns data', - (tester) async { - reset(mockDailyChallengeService); - reset(mockAuthService); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - mockAuthService.userModel = null; - - final testChallenges = [ - // March 2025 challenges - DailyChallengeOverview( - id: 'mar-1', - challengeNumber: 3, - date: DateTime(2025, 3, 15), - title: 'March Challenge 3', - ), - DailyChallengeOverview( - id: 'mar-2', - challengeNumber: 1, - date: DateTime(2025, 3, 1), - title: 'March Challenge 1', - ), - DailyChallengeOverview( - id: 'mar-3', - challengeNumber: 2, - date: DateTime(2025, 3, 10), - title: 'March Challenge 2', - ), - - // February 2025 challenges - DailyChallengeOverview( - id: 'feb-1', - challengeNumber: 1, - date: DateTime(2025, 2, 1), - title: 'February Challenge 1', - ), - DailyChallengeOverview( - id: 'feb-2', - challengeNumber: 2, - date: DateTime(2025, 2, 15), - title: 'February Challenge 2', - ), - ]; - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenAnswer((_) async => testChallenges); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect(find.text('March 2025'), findsOneWidget); - expect(find.text('February 2025'), findsOneWidget); - - // Test month ordering: March should appear first (newest) - final marchPosition = tester.getTopLeft(find.text('March 2025')); - final februaryPosition = tester.getTopLeft(find.text('February 2025')); - - expect(marchPosition.dy < februaryPosition.dy, isTrue, - reason: 'March should appear before February'); - - // March block is open by default and its challenges are visible - expect(find.text('Challenge 3: March Challenge 3'), findsOneWidget); - expect(find.text('Challenge 2: March Challenge 2'), findsOneWidget); - expect(find.text('Challenge 1: March Challenge 1'), findsOneWidget); - - // Scroll down to make the February block fully visible - // Target the main ListView by finding the one that contains the month headers - final mainListView = find.ancestor( - of: find.text('March 2025'), - matching: find.byType(ListView), - ); - await tester.drag(mainListView, const Offset(0, -300)); - await tester.pumpAndSettle(); - - // Find the "Show Challenges" button for February - final showFebButton = find.text('Show Challenges'); - expect(showFebButton, findsOneWidget); - - // Tap the button to reveal February's challenges - await tester.tap(showFebButton); - await tester.pumpAndSettle(); - - // Assert that February's challenges are now visible - expect(find.text('Challenge 2: February Challenge 2'), findsOneWidget); - expect(find.text('Challenge 1: February Challenge 1'), findsOneWidget); - }); - - testWidgets( - 'should order challenges within month by challengeNumber descending', - (tester) async { - reset(mockDailyChallengeService); - reset(mockAuthService); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - mockAuthService.userModel = null; - - final testChallenges = [ - DailyChallengeOverview( - id: 'challenge-1', - challengeNumber: 1, - date: DateTime(2025, 2, 1), - title: 'Challenge 1', - ), - DailyChallengeOverview( - id: 'challenge-3', - challengeNumber: 3, - date: DateTime(2025, 2, 15), - title: 'Challenge 3', - ), - DailyChallengeOverview( - id: 'challenge-2', - challengeNumber: 2, - date: DateTime(2025, 2, 10), - title: 'Challenge 2', - ), - ]; - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenAnswer((_) async => testChallenges); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - // Verify the ordering by checking vertical positions - final challenge3Position = - tester.getTopLeft(find.text('Challenge 3: Challenge 3')); - final challenge2Position = - tester.getTopLeft(find.text('Challenge 2: Challenge 2')); - final challenge1Position = - tester.getTopLeft(find.text('Challenge 1: Challenge 1')); - - expect(challenge3Position.dy < challenge2Position.dy, isTrue, - reason: 'Challenge 3 should appear before Challenge 2'); - expect(challenge2Position.dy < challenge1Position.dy, isTrue, - reason: 'Challenge 2 should appear before Challenge 1'); - }); - - testWidgets('should show completion state when user is logged in', - (tester) async { - reset(mockDailyChallengeService); - reset(mockAuthService); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - final testChallenges = [ - DailyChallengeOverview( - id: 'challenge-1', - challengeNumber: 1, - date: DateTime(2025, 2, 1), - title: 'Foo', - ), - DailyChallengeOverview( - id: 'challenge-2', - challengeNumber: 2, - date: DateTime(2025, 2, 2), - title: 'Bar', - ), - ]; - - final testUserJson = { - 'id': 'test-user-id', - 'email': 'test@example.com', - 'username': 'testuser', - 'name': 'Test User', - 'picture': '', - 'currentChallengeId': '', - 'githubProfile': '', - 'linkedin': '', - 'twitter': '', - 'website': '', - 'location': '', - 'about': '', - 'emailVerified': true, - 'isEmailVerified': true, - 'acceptedPrivacyTerms': true, - 'sendQuincyEmail': false, - 'isCheater': false, - 'isDonating': false, - 'isHonest': true, - '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, - 'isCollegeAlgebraPyCertV8': false, - 'isFoundationalCSharpCertV8': false, - 'isBanned': false, - 'joinDate': '2025-01-01T00:00:00.000Z', - 'points': 0, - 'calendar': {}, - 'completedChallenges': [], - 'completedDailyCodingChallenges': [ - { - 'id': 'challenge-1', - 'completedDate': DateTime(2025, 2, 1).millisecondsSinceEpoch, - 'languages': ['javascript'], - } - ], - 'savedChallenges': [], - 'portfolio': [], - 'yearsTopContributor': [], - 'theme': 'default', - 'profileUI': { - 'isLocked': false, - 'showAbout': true, - 'showCerts': true, - 'showDonation': true, - 'showHeatMap': true, - 'showLocation': true, - 'showName': true, - 'showPoints': true, - 'showPortfolio': true, - 'showTimeLine': true, - }, - }; - - final testUser = FccUserModel.fromJson(testUserJson); - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenAnswer((_) async => testChallenges); - - when(mockAuthService.userModel).thenAnswer((_) async => testUser); - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect( - find.bySemanticsLabel('Challenge 1: Foo, completed'), - findsOneWidget, - ); - expect( - find.bySemanticsLabel('Challenge 2: Bar, not completed'), - findsOneWidget, - ); - }); - - testWidgets('should show no completion state when user is not logged in', - (tester) async { - reset(mockDailyChallengeService); - reset(mockAuthService); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - final testChallenges = [ - DailyChallengeOverview( - id: 'challenge-1', - challengeNumber: 1, - date: DateTime(2025, 2, 1), - title: 'Challenge 1', - ), - ]; - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenAnswer((_) async => testChallenges); - - mockAuthService.userModel = null; - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect( - find.bySemanticsLabel('Challenge 1: Challenge 1, not completed'), - findsOneWidget, - ); - }); - - testWidgets('should handle service errors gracefully', (tester) async { - reset(mockDailyChallengeService); - reset(mockAuthService); - - when(mockAuthService.progress) - .thenAnswer((_) => StreamController()); - - when(mockDailyChallengeService.fetchAllDailyChallenges()) - .thenThrow(Exception('Network error')); - - mockAuthService.userModel = null; - - await tester.pumpWidget(createTestWidget()); - await tester.pumpAndSettle(); - - expect( - find.text('No challenges available at the moment.'), findsOneWidget); - }); - }); -} diff --git a/mobile-app/test_driver/integration_test.dart b/mobile-app/test_driver/integration_test.dart index 145db3d14..7477d299d 100644 --- a/mobile-app/test_driver/integration_test.dart +++ b/mobile-app/test_driver/integration_test.dart @@ -1,37 +1,21 @@ import 'dart:io'; -import 'package:flutter_driver/flutter_driver.dart'; import 'package:integration_test/integration_test_driver_extended.dart'; Future main() async { - FlutterDriver driver = await FlutterDriver.connect(); - final vm = await driver.serviceClient.getVM(); - final platform = vm.operatingSystem; - - if (platform!.toLowerCase().contains('android')) { - Process.runSync( - 'adb', - [ - 'shell', - 'pm', - 'grant', - 'org.freecodecamp', - 'android.permission.POST_NOTIFICATIONS' - ], - ); - } - try { await integrationDriver( - onScreenshot: (String screenshotName, List screenshotBytes, - [Map? _]) async { - final File image = await File('screenshots/$screenshotName.png') - .create(recursive: true); - image.writeAsBytesSync(screenshotBytes); - return true; - }, + writeResponseOnFailure: true, + onScreenshot: + (String name, List bytes, [Map? _]) async { + final image = await File( + 'screenshots/$name.png', + ).create(recursive: true); + image.writeAsBytesSync(bytes); + return true; + }, ); } catch (e) { - print('Error occured: $e'); + stderr.writeln('Integration driver error: $e'); } } diff --git a/package.json b/package.json index 0bb9b62c3..695f8aee9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "develop": "npm-run-all flutter:build-runner -p develop:*", "develop:api": "cd mobile-api && npm run dev", "develop:app": "cd mobile-app && flutter run", - "flutter:build-runner": "cd mobile-app && flutter pub run build_runner build --delete-conflicting-outputs", + "flutter:build-runner": "cd mobile-app && dart run build_runner build", "install:api": "cd mobile-api && npm ci", "install:app": "cd mobile-app && flutter pub get", "postinstall": "npm-run-all -p install:*",