diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 06ddd1cd..daabfcab 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -15,6 +15,7 @@ on: play_track: { required: false, type: string, default: internal } environment: { required: false, type: string, default: '' } upload_to_store: { required: false, type: boolean, default: true } + version_name: { required: false, type: string, default: '' } # CI-computed semver; falls back to pubspec when empty permissions: contents: read @@ -88,7 +89,9 @@ jobs: CM_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} CM_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | - VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) + # Prefer the CI-computed version (release-prod), else fall back to pubspec. + VERSION_NAME="${{ inputs.version_name }}" + [ -z "$VERSION_NAME" ] && VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) OFFSET="${{ vars.BUILD_NUMBER_OFFSET || '100' }}" BUILD_NUMBER=$(( ${{ github.run_number }} + OFFSET )) echo "Building $VERSION_NAME (+$BUILD_NUMBER)" diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index c31d8d50..22bc9be6 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -20,6 +20,7 @@ on: ios_bundle_id: { required: true, type: string } # org.pecha.app[.dev] environment: { required: false, type: string, default: '' } upload_to_store: { required: false, type: boolean, default: true } + version_name: { required: false, type: string, default: '' } # CI-computed semver; falls back to pubspec when empty permissions: contents: read @@ -176,7 +177,9 @@ jobs: env: AIRBRIDGE_SDK_TOKEN: ${{ secrets.AIRBRIDGE_SDK_TOKEN }} run: | - VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) + # Prefer the CI-computed version (release-prod), else fall back to pubspec. + VERSION_NAME="${{ inputs.version_name }}" + [ -z "$VERSION_NAME" ] && VERSION_NAME=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) OFFSET="${{ vars.BUILD_NUMBER_OFFSET || '100' }}" BUILD_NUMBER=$(( ${{ github.run_number }} + OFFSET )) echo "Building $VERSION_NAME (+$BUILD_NUMBER)" diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 2858fc57..5bb685ad 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -15,7 +15,27 @@ permissions: contents: read jobs: + # Compute a PREVIEW of the next version (same rules as prod) so the dev / + # TestFlight build carries the version you're about to ship — but do NOT commit + # it. The version is only persisted on the prod release (main), so promoting + # develop -> main bumps exactly once (no double bump). See CONTRIBUTING.md. + version: + name: 🔖 Preview version + runs-on: ubuntu-latest + outputs: + version_name: ${{ steps.bump.outputs.version_name }} + steps: + - name: 📂 Checkout (full history + tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 🔢 Compute next version (preview only — not committed) + id: bump + run: bash ci/scripts/bump_version.sh + android: + needs: version uses: ./.github/workflows/build-android.yml with: flavor: dev @@ -25,9 +45,11 @@ jobs: play_track: internal environment: development upload_to_store: true + version_name: ${{ needs.version.outputs.version_name }} secrets: inherit ios: + needs: version uses: ./.github/workflows/build-ios.yml with: flavor: dev @@ -37,4 +59,5 @@ jobs: ios_bundle_id: org.pecha.app.dev environment: development upload_to_store: true + version_name: ${{ needs.version.outputs.version_name }} secrets: inherit diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index aa1b77f6..6127cba4 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -16,7 +16,43 @@ permissions: contents: write # needed to create the GitHub Release + tag jobs: + # Decide the next semantic version from the commits / branch names landed + # since the last release tag (Conventional Commits, PATCH fallback), write it + # to pubspec.yaml and commit it back. Both store builds and the in-app + # settings page read the version from pubspec via --build-name, so this single + # bump keeps all three in sync. See CONTRIBUTING.md for the conventions. + version: + name: 🔖 Bump version + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version_name: ${{ steps.bump.outputs.version_name }} + steps: + - name: 📂 Checkout (full history + tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: 🔢 Compute & apply version bump + id: bump + run: bash ci/scripts/bump_version.sh + + - name: ⬆️ Commit bumped pubspec.yaml + run: | + if git diff --quiet -- pubspec.yaml; then + echo "pubspec.yaml unchanged — nothing to commit."; exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pubspec.yaml + # [skip ci] so this bump commit does not re-trigger the release. + git commit -m "chore(release): bump version to ${{ steps.bump.outputs.version_name }} [skip ci]" + git push origin HEAD:${{ github.ref_name }} + android: + needs: version uses: ./.github/workflows/build-android.yml with: flavor: prod @@ -26,9 +62,11 @@ jobs: play_track: internal # bump to "production" when ready to ship live environment: production upload_to_store: true + version_name: ${{ needs.version.outputs.version_name }} secrets: inherit ios: + needs: version uses: ./.github/workflows/build-ios.yml with: flavor: prod @@ -38,23 +76,24 @@ jobs: ios_bundle_id: org.pecha.app environment: production upload_to_store: true + version_name: ${{ needs.version.outputs.version_name }} secrets: inherit github_release: name: 📦 GitHub Release - needs: [ android, ios ] + needs: [ version, android, ios ] runs-on: ubuntu-latest permissions: contents: write steps: - - name: 📂 Checkout + - name: 📂 Checkout (post-bump main) uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} # tag the commit that carries the bumped version - name: 🏷️ Compute tag id: ver - run: | - V=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1) - echo "tag=v$V-build-${{ github.run_number }}" >> "$GITHUB_OUTPUT" + run: echo "tag=v${{ needs.version.outputs.version_name }}-build-${{ github.run_number }}" >> "$GITHUB_OUTPUT" - name: ⬇️ Download build artifacts uses: actions/download-artifact@v4 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..a719d993 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,19 @@ +# Contributing + +## Versioning (automatic — don't edit the version by hand) + +CI bumps the app version from your **commit messages** / **branch names**: + +| Bump | Commit starts with | or branch named | +| --- | --- | --- | +| **major** (`3.0.0`) | `feat!:` · `fix!:` · a `BREAKING CHANGE:` footer | `breaking/…` · `major/…` | +| **minor** (`2.10.0`) | `feat:` | `feat/…` · `feature/…` | +| **patch** (`2.9.3`) | anything else (`fix:`, `chore:`, …) | anything else | + +**No convention → patch.** That's the only rule you need. + +### Dev vs prod +- Merge to **`develop`** → the dev TestFlight build *previews* the next version (not saved). +- Merge to **`main`** → the version is bumped for real, shipped, and tagged. + +So a change tested on dev and then shipped to prod bumps **once**, never twice. diff --git a/assets/images/buddha.jpeg b/assets/images/buddha.jpeg new file mode 100644 index 00000000..f442292e Binary files /dev/null and b/assets/images/buddha.jpeg differ diff --git a/ci/scripts/bump_version.sh b/ci/scripts/bump_version.sh new file mode 100755 index 00000000..9ae88fa8 --- /dev/null +++ b/ci/scripts/bump_version.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Auto-bumps the semantic version (the "X.Y.Z" version name) in pubspec.yaml +# based on the Conventional Commits / branch names landed since the last +# release tag. Falls back to a PATCH bump when no convention is detected. +# +# major (X+1.0.0) : a commit "!:" or a "BREAKING CHANGE" footer, +# or a branch named breaking/* or major/* +# minor (X.Y+1.0) : a "feat:"/"feat(scope):" commit, +# or a branch named feat/* or feature/* +# patch (X.Y.Z+1) : anything else (fix, chore, docs, refactor, …) and the +# fallback when no convention is present at all +# +# The "+build" metadata is preserved as-is — CI sets the real, monotonic build +# number at build time (github.run_number + offset), so pubspec's build value +# is only a placeholder. +# +# The script only computes + writes pubspec.yaml; the CALLER decides whether to +# persist it: +# * release-prod (main) commits the bump back -> the version advances. +# * release-dev (develop) does NOT commit -> a throwaway preview build, +# so promoting dev->prod does +# not double-bump. +# +# Outputs (when $GITHUB_OUTPUT is set, i.e. on CI): +# version_name= level= version= +# --------------------------------------------------------------------------- +set -euo pipefail + +PUBSPEC="${PUBSPEC_PATH:-pubspec.yaml}" + +# --- current version ------------------------------------------------------- +current_line="$(grep -E '^version:' "$PUBSPEC" | head -n1)" +current="${current_line#version:}" +current="${current// /}" # strip spaces -> "X.Y.Z+build" +name="${current%%+*}" # "X.Y.Z" +build="" +[ "$current" != "$name" ] && build="${current#*+}" # "build" (empty if no +) + +# --- version to bump FROM -------------------------------------------------- +# The higher of pubspec's version and the highest released "v*" tag. On main +# they are equal. For DEV builds the bump is NOT committed back, so develop's +# pubspec can lag the last prod release — using the tag keeps dev one step ahead +# of what already shipped instead of re-previewing an already-released version. +tag_ver="$(git tag -l 'v*' 2>/dev/null | sed -E 's/^v//; s/-.*$//' \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k1,1n -k2,2n -k3,3n | tail -n1 || true)" +base="$name" +[ -n "$tag_ver" ] && base="$(printf '%s\n%s\n' "$name" "$tag_ver" \ + | sort -t. -k1,1n -k2,2n -k3,3n | tail -n1)" + +IFS='.' read -r major minor patch <<<"$base" +: "${major:=0}" "${minor:=0}" "${patch:=0}" + +# --- commit range whose messages decide the bump LEVEL (since last release) - +last_tag="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" +if [ -n "$last_tag" ]; then + range="${last_tag}..HEAD" +else + range="HEAD" # no tags yet: inspect all history +fi + +subjects="$(git log --format='%s' "$range" 2>/dev/null || true)" +bodies="$(git log --format='%b' "$range" 2>/dev/null || true)" +# Branch name behind each merge commit, e.g. "Merge pull request #1 from org/feat/x" -> "feat/x" +branches="$(git log --merges --format='%s' "$range" 2>/dev/null | sed -nE 's/.*from [^/]+\/(.+)$/\1/p' || true)" + +# --- decide the bump level (highest wins) ---------------------------------- +level="patch" + +if printf '%s\n' "$subjects" | grep -qiE '^[[:space:]]*(feat|feature)(\([^)]+\))?!?:' \ + || printf '%s\n' "$branches" | grep -qiE '^(feat|feature)/'; then + level="minor" +fi + +if printf '%s\n' "$subjects" | grep -qE '^[[:space:]]*[a-z]+(\([^)]+\))?!:' \ + || printf '%s\n' "$bodies" | grep -qE '^[[:space:]]*BREAKING[ -]CHANGE:' \ + || printf '%s\n' "$branches" | grep -qiE '^(breaking|major)/'; then + level="major" +fi + +case "$level" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; +esac + +new_name="${major}.${minor}.${patch}" +new_version="$new_name" +[ -n "$build" ] && new_version="${new_name}+${build}" + +# --- rewrite the top-level version line in pubspec.yaml -------------------- +NEW_VERSION="$new_version" perl -i -pe 'if (!$done && /^version:/) { $_ = "version: $ENV{NEW_VERSION}\n"; $done = 1 }' "$PUBSPEC" + +echo "Last release tag : ${last_tag:-}" +echo "Base version : $base (pubspec=$name, highest tag=${tag_ver:-})" +echo "Bump level : $level" +echo "New version : $new_version" + +if [ -n "${GITHUB_OUTPUT:-}" ]; then + { + echo "version_name=$new_name" + echo "version=$new_version" + echo "level=$level" + } >>"$GITHUB_OUTPUT" +fi diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 850b5925..8ee2da07 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -365,18 +365,18 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: airbridge-ios-sdk: 6049688ddcfafa85da5ab35b943430c3309ca97b - airbridge_flutter_sdk: 79d47074aae010182e265e9bdac9c904d9a01f4d - app_links: 585674be3c6661708e6cd794ab4f39fb9d8356f9 - audio_service: cab6c1a0eaf01b5a35b567e11fa67d3cc1956910 - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe + airbridge_flutter_sdk: 31632f81cacf04d55acc4e6ef17f536e389a8d0c + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 Auth0: 2876d0c36857422eda9cb580a6cc896c7d14cb36 - auth0_flutter: 0f4846524696ef8441bcb96ceab7bfdbe31b8a05 - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + auth0_flutter: 031042ca6cf84f1b21611062b33ea7ec4bf3bc52 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Firebase: a8539b633d474fbeb654c7043f9c1649e274045b - firebase_analytics: 444603cf97c21326b60115cd18d28f4a56bd78f6 - firebase_core: 2f51bd38a4e012634cf229f81dc9e2506df32f1c - firebase_crashlytics: 251d4c57b75af742297bb7a39d50b40b9caf88cc - firebase_messaging: c8d9730ad2cf85f50f1e16be82ffebcb9a20a792 + firebase_analytics: 5e356f558625ce2d8b21deff00946b56d6e8d0d5 + firebase_core: fc23178af8ea070194d09031ae4198a9608a3d22 + firebase_crashlytics: 344bb168f55aee1086c6cdd0b105a9db018cd344 + firebase_messaging: 870b2939a7f7cc52bc38ca0250127c45b42f4bad FirebaseAnalytics: 9c9fa7915fc52ea03077000d5a7b6a8947b2d76e FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e FirebaseCoreExtension: 10d2a627977b39418759ad88ada80fbbd34f1c4f @@ -387,38 +387,38 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: 7e3d57ce4b1e958bb1d15403faa7178f46bbb5b7 FirebaseSessions: acfe7eadca47cda94ac86592737204581bb1abf6 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_image_compress_common: ec1d45c362c9d30a3f6a0426c297f47c52007e3e - flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 - flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f - flutter_secure_storage_darwin: 557817588b80e60213cbecb573c45c76b788018d - flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 GoogleAdsOnDeviceConversion: 80ce443fa1b4b5750913d53a04ecda644ff57744 GoogleAppMeasurement: a6d37949071d456e9147dac6789c4342e0e7a8c5 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 4f2618a4a1e762a1ee134a1e2323bba9843e06da - image_gallery_saver_plus: 782ade975fe6a4600b53e7c1983e3a2979d1e9e5 - image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b - just_audio: a42c63806f16995daf5b219ae1d679deb76e6a79 + image_gallery_saver_plus: e597bf65a7846979417a3eae0763b71b6dfec6c3 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed JWTDecode: 7dae24cb9bf9b608eae61e5081029ec169bb5527 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - permission_handler_apple: ee2fe0fd04551b304eb002714ff067c371c822ed + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e PostHog: 82eee1ba92efc8144b06b6e4412abba5438c8fda - posthog_flutter: 70c295afe8f9eb52e99eefe031af0001bdf9b735 + posthog_flutter: 343db84a70d44b12fa0fe2fc35bbb58098074bd4 PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f - shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb SimpleKeychain: 768cf43ae778b1c21816e94dddf01bb8ee96a075 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - webview_flutter_wkwebview: 29eb20d43355b48fe7d07113835b9128f84e3af4 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d PODFILE CHECKSUM: d93c32cc7d677c987ce520e85932c4ff335f3ec2 diff --git a/lib/core/config/router/app_router.dart b/lib/core/config/router/app_router.dart index 7e61a097..c8171eda 100644 --- a/lib/core/config/router/app_router.dart +++ b/lib/core/config/router/app_router.dart @@ -224,6 +224,7 @@ final appRouterProvider = Provider((ref) { GoRoute( path: "group/:groupId", name: "home-group-profile", + parentNavigatorKey: rootNavigatorKey, builder: (context, state) { final groupId = state.pathParameters['groupId'] ?? ''; return GroupProfileScreen(groupId: groupId); diff --git a/lib/core/constants/app_assets.dart b/lib/core/constants/app_assets.dart index 8541ff93..9639d9f6 100644 --- a/lib/core/constants/app_assets.dart +++ b/lib/core/constants/app_assets.dart @@ -14,6 +14,7 @@ class AppAssets { static const String recitationCoverDefault = 'assets/images/recitation_cover/recitation_05.jpg'; static const String connect = 'assets/images/connect.png'; + static const String verseOfDayFallback = 'assets/images/buddha.jpeg'; // ========== AUDIO ========== static const String meditationSound = 'assets/audios/meditation.mp3'; @@ -71,6 +72,7 @@ class AppAssets { static const IconData caretRight = PhosphorIconsRegular.caretRight; static const IconData caretDown = PhosphorIconsRegular.caretDown; static const IconData caretUp = PhosphorIconsRegular.caretUp; + static const IconData caretLeft = PhosphorIconsRegular.caretLeft; static const IconData arrowSquareOut = PhosphorIconsRegular.arrowSquareOut; static const IconData arrowLeft = PhosphorIconsRegular.arrowLeft; static const IconData lock = PhosphorIconsRegular.lock; @@ -86,6 +88,7 @@ class AppAssets { static const IconData trash = PhosphorIconsRegular.trash; static const IconData fileText = PhosphorIconsRegular.fileText; static const IconData bookmarkSimple = PhosphorIconsRegular.bookmarkSimple; + static const IconData bookmarkSimpleFill = PhosphorIconsFill.bookmarkSimple; static const IconData speakerSimpleHigh = PhosphorIconsRegular.speakerSimpleHigh; static const IconData vibrate = PhosphorIconsRegular.vibrate; diff --git a/lib/core/deep_linking/app_links_deep_link_service.dart b/lib/core/deep_linking/app_links_deep_link_service.dart index 2aef5ea3..d67bcc5c 100644 --- a/lib/core/deep_linking/app_links_deep_link_service.dart +++ b/lib/core/deep_linking/app_links_deep_link_service.dart @@ -47,11 +47,17 @@ class AppLinksDeepLinkService { void setRouter(GoRouter router) { _router = router; + } + + bool drainPendingLink() { + if (_router == null) return false; + final pending = _pendingUri; - if (pending == null) return; + if (pending == null) return false; _pendingUri = null; _dispatch(pending); + return true; } Future dispose() async { diff --git a/lib/core/l10n/app_bo.arb b/lib/core/l10n/app_bo.arb index 9c1a5457..b24733c0 100644 --- a/lib/core/l10n/app_bo.arb +++ b/lib/core/l10n/app_bo.arb @@ -676,6 +676,29 @@ "joined": "ཞུགས་ཟིན།", "group_member": "ཚོགས་མི།", "group_members": "ཚོགས་མི།", + "group_tab_members": "ཚོགས་མི།", + "group_tab_followers": "རྗེས་འབྲང་པ།", + "group_members_heading": "ཚོགས་མི({count})", + "group_followers_heading": "རྗེས་འབྲང་པ({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "སྐུ་འཕྲིན།", + "group_members_load_error": "ཚོགས་མི་མངག་ཐུབ་མ་སོང་། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།", + "group_followers_load_error": "རྗེས་འབྲང་པ་མངག་ཐུབ་མ་སོང་། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།", + "group_members_empty": "ཚོགས་མི་མེད།", + "group_followers_empty": "རྗེས་འབྲང་པ་མེད།", "group_follower": "རྗེས་འབྲང་པ།", "group_followers": "རྗེས་འབྲང་པ།", "group_links_title": "འབྲེལ་ཐག", diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index ff853c43..2a70fb1c 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -753,6 +753,29 @@ "joined": "Joined", "group_member": "member", "group_members": "members", + "group_tab_members": "Members", + "group_tab_followers": "Followers", + "group_members_heading": "Members({count})", + "group_followers_heading": "Followers({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "Invite", + "group_members_load_error": "Unable to load members. Please try again.", + "group_followers_load_error": "Unable to load followers. Please try again.", + "group_members_empty": "No members yet", + "group_followers_empty": "No followers yet", "group_follower": "follower", "group_followers": "followers", "group_links_title": "Links", diff --git a/lib/core/l10n/app_hi.arb b/lib/core/l10n/app_hi.arb index d943ce97..b078f28f 100644 --- a/lib/core/l10n/app_hi.arb +++ b/lib/core/l10n/app_hi.arb @@ -742,6 +742,29 @@ "joined": "शामिल हो गए", "group_member": "सदस्य", "group_members": "सदस्य", + "group_tab_members": "सदस्य", + "group_tab_followers": "अनुयायी", + "group_members_heading": "सदस्य({count})", + "group_followers_heading": "अनुयायी({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "आमंत्रित करें", + "group_members_load_error": "सदस्य लोड नहीं हो सके। कृपया पुनः प्रयास करें।", + "group_followers_load_error": "अनुयायी लोड नहीं हो सके। कृपया पुनः प्रयास करें।", + "group_members_empty": "अभी कोई सदस्य नहीं", + "group_followers_empty": "अभी कोई अनुयायी नहीं", "group_follower": "अनुयायी", "group_followers": "अनुयायी", "group_links_title": "लिंक", diff --git a/lib/core/l10n/app_mn.arb b/lib/core/l10n/app_mn.arb index 0a8a2eeb..8e95b75e 100644 --- a/lib/core/l10n/app_mn.arb +++ b/lib/core/l10n/app_mn.arb @@ -742,6 +742,29 @@ "joined": "Нэгдсэн", "group_member": "гишүүн", "group_members": "гишүүд", + "group_tab_members": "Гишүүд", + "group_tab_followers": "Дагагчид", + "group_members_heading": "Гишүүд({count})", + "group_followers_heading": "Дагагчид({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "Урих", + "group_members_load_error": "Гишүүдийг ачаалж чадсангүй. Дахин оролдоно уу.", + "group_followers_load_error": "Дагагчдыг ачаалж чадсангүй. Дахин оролдоно уу.", + "group_members_empty": "Одоогоор гишүүн байхгүй", + "group_followers_empty": "Одоогоор дагагч байхгүй", "group_follower": "дагагч", "group_followers": "дагагчид", "group_links_title": "Холбоосууд", diff --git a/lib/core/l10n/app_ne.arb b/lib/core/l10n/app_ne.arb index 449b29bf..cd84c842 100644 --- a/lib/core/l10n/app_ne.arb +++ b/lib/core/l10n/app_ne.arb @@ -742,6 +742,29 @@ "joined": "सामेल भयो", "group_member": "सदस्य", "group_members": "सदस्यहरू", + "group_tab_members": "सदस्यहरू", + "group_tab_followers": "अनुयायीहरू", + "group_members_heading": "सदस्यहरू({count})", + "group_followers_heading": "अनुयायीहरू({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "निमन्त्रणा", + "group_members_load_error": "सदस्यहरू लोड गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।", + "group_followers_load_error": "अनुयायीहरू लोड गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।", + "group_members_empty": "अहिलेसम्म कुनै सदस्य छैन", + "group_followers_empty": "अहिलेसम्म कुनै अनुयायी छैन", "group_follower": "अनुयायी", "group_followers": "अनुयायीहरू", "group_links_title": "लिङ्कहरू", diff --git a/lib/core/l10n/app_zh.arb b/lib/core/l10n/app_zh.arb index 3e95a2f9..f86f10d1 100644 --- a/lib/core/l10n/app_zh.arb +++ b/lib/core/l10n/app_zh.arb @@ -676,6 +676,29 @@ "joined": "已加入", "group_member": "位成員", "group_members": "位成員", + "group_tab_members": "成員", + "group_tab_followers": "追蹤者", + "group_members_heading": "成員({count})", + "group_followers_heading": "追蹤者({count})", + "@group_members_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "@group_followers_heading": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "group_invite": "邀請", + "group_members_load_error": "無法載入成員,請再試一次。", + "group_followers_load_error": "無法載入追蹤者,請再試一次。", + "group_members_empty": "尚無成員", + "group_followers_empty": "尚無追蹤者", "group_follower": "位追蹤者", "group_followers": "位追蹤者", "group_links_title": "連結", diff --git a/lib/core/l10n/generated/app_localizations.dart b/lib/core/l10n/generated/app_localizations.dart index 29089db3..6d08702c 100644 --- a/lib/core/l10n/generated/app_localizations.dart +++ b/lib/core/l10n/generated/app_localizations.dart @@ -2848,6 +2848,60 @@ abstract class AppLocalizations { /// **'members'** String get group_members; + /// No description provided for @group_tab_members. + /// + /// In en, this message translates to: + /// **'Members'** + String get group_tab_members; + + /// No description provided for @group_tab_followers. + /// + /// In en, this message translates to: + /// **'Followers'** + String get group_tab_followers; + + /// No description provided for @group_members_heading. + /// + /// In en, this message translates to: + /// **'Members({count})'** + String group_members_heading(int count); + + /// No description provided for @group_followers_heading. + /// + /// In en, this message translates to: + /// **'Followers({count})'** + String group_followers_heading(int count); + + /// No description provided for @group_invite. + /// + /// In en, this message translates to: + /// **'Invite'** + String get group_invite; + + /// No description provided for @group_members_load_error. + /// + /// In en, this message translates to: + /// **'Unable to load members. Please try again.'** + String get group_members_load_error; + + /// No description provided for @group_followers_load_error. + /// + /// In en, this message translates to: + /// **'Unable to load followers. Please try again.'** + String get group_followers_load_error; + + /// No description provided for @group_members_empty. + /// + /// In en, this message translates to: + /// **'No members yet'** + String get group_members_empty; + + /// No description provided for @group_followers_empty. + /// + /// In en, this message translates to: + /// **'No followers yet'** + String get group_followers_empty; + /// No description provided for @group_follower. /// /// In en, this message translates to: diff --git a/lib/core/l10n/generated/app_localizations_bo.dart b/lib/core/l10n/generated/app_localizations_bo.dart index 6158723f..523c0e97 100644 --- a/lib/core/l10n/generated/app_localizations_bo.dart +++ b/lib/core/l10n/generated/app_localizations_bo.dart @@ -1569,6 +1569,39 @@ class AppLocalizationsBo extends AppLocalizations { @override String get group_members => 'ཚོགས་མི།'; + @override + String get group_tab_members => 'ཚོགས་མི།'; + + @override + String get group_tab_followers => 'རྗེས་འབྲང་པ།'; + + @override + String group_members_heading(int count) { + return 'ཚོགས་མི($count)'; + } + + @override + String group_followers_heading(int count) { + return 'རྗེས་འབྲང་པ($count)'; + } + + @override + String get group_invite => 'སྐུ་འཕྲིན།'; + + @override + String get group_members_load_error => + 'ཚོགས་མི་མངག་ཐུབ་མ་སོང་། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།'; + + @override + String get group_followers_load_error => + 'རྗེས་འབྲང་པ་མངག་ཐུབ་མ་སོང་། ཡང་བསྐྱར་ཚོད་ལྟ་གནང་རོགས།'; + + @override + String get group_members_empty => 'ཚོགས་མི་མེད།'; + + @override + String get group_followers_empty => 'རྗེས་འབྲང་པ་མེད།'; + @override String get group_follower => 'རྗེས་འབྲང་པ།'; diff --git a/lib/core/l10n/generated/app_localizations_en.dart b/lib/core/l10n/generated/app_localizations_en.dart index 7b203e3d..2695b43d 100644 --- a/lib/core/l10n/generated/app_localizations_en.dart +++ b/lib/core/l10n/generated/app_localizations_en.dart @@ -1548,6 +1548,39 @@ class AppLocalizationsEn extends AppLocalizations { @override String get group_members => 'members'; + @override + String get group_tab_members => 'Members'; + + @override + String get group_tab_followers => 'Followers'; + + @override + String group_members_heading(int count) { + return 'Members($count)'; + } + + @override + String group_followers_heading(int count) { + return 'Followers($count)'; + } + + @override + String get group_invite => 'Invite'; + + @override + String get group_members_load_error => + 'Unable to load members. Please try again.'; + + @override + String get group_followers_load_error => + 'Unable to load followers. Please try again.'; + + @override + String get group_members_empty => 'No members yet'; + + @override + String get group_followers_empty => 'No followers yet'; + @override String get group_follower => 'follower'; diff --git a/lib/core/l10n/generated/app_localizations_hi.dart b/lib/core/l10n/generated/app_localizations_hi.dart index e76624c8..7617d1bc 100644 --- a/lib/core/l10n/generated/app_localizations_hi.dart +++ b/lib/core/l10n/generated/app_localizations_hi.dart @@ -1561,6 +1561,39 @@ class AppLocalizationsHi extends AppLocalizations { @override String get group_members => 'सदस्य'; + @override + String get group_tab_members => 'सदस्य'; + + @override + String get group_tab_followers => 'अनुयायी'; + + @override + String group_members_heading(int count) { + return 'सदस्य($count)'; + } + + @override + String group_followers_heading(int count) { + return 'अनुयायी($count)'; + } + + @override + String get group_invite => 'आमंत्रित करें'; + + @override + String get group_members_load_error => + 'सदस्य लोड नहीं हो सके। कृपया पुनः प्रयास करें।'; + + @override + String get group_followers_load_error => + 'अनुयायी लोड नहीं हो सके। कृपया पुनः प्रयास करें।'; + + @override + String get group_members_empty => 'अभी कोई सदस्य नहीं'; + + @override + String get group_followers_empty => 'अभी कोई अनुयायी नहीं'; + @override String get group_follower => 'अनुयायी'; diff --git a/lib/core/l10n/generated/app_localizations_mn.dart b/lib/core/l10n/generated/app_localizations_mn.dart index 7482408c..675e267b 100644 --- a/lib/core/l10n/generated/app_localizations_mn.dart +++ b/lib/core/l10n/generated/app_localizations_mn.dart @@ -1564,6 +1564,39 @@ class AppLocalizationsMn extends AppLocalizations { @override String get group_members => 'гишүүд'; + @override + String get group_tab_members => 'Гишүүд'; + + @override + String get group_tab_followers => 'Дагагчид'; + + @override + String group_members_heading(int count) { + return 'Гишүүд($count)'; + } + + @override + String group_followers_heading(int count) { + return 'Дагагчид($count)'; + } + + @override + String get group_invite => 'Урих'; + + @override + String get group_members_load_error => + 'Гишүүдийг ачаалж чадсангүй. Дахин оролдоно уу.'; + + @override + String get group_followers_load_error => + 'Дагагчдыг ачаалж чадсангүй. Дахин оролдоно уу.'; + + @override + String get group_members_empty => 'Одоогоор гишүүн байхгүй'; + + @override + String get group_followers_empty => 'Одоогоор дагагч байхгүй'; + @override String get group_follower => 'дагагч'; diff --git a/lib/core/l10n/generated/app_localizations_ne.dart b/lib/core/l10n/generated/app_localizations_ne.dart index d991eacd..39a869ac 100644 --- a/lib/core/l10n/generated/app_localizations_ne.dart +++ b/lib/core/l10n/generated/app_localizations_ne.dart @@ -1569,6 +1569,39 @@ class AppLocalizationsNe extends AppLocalizations { @override String get group_members => 'सदस्यहरू'; + @override + String get group_tab_members => 'सदस्यहरू'; + + @override + String get group_tab_followers => 'अनुयायीहरू'; + + @override + String group_members_heading(int count) { + return 'सदस्यहरू($count)'; + } + + @override + String group_followers_heading(int count) { + return 'अनुयायीहरू($count)'; + } + + @override + String get group_invite => 'निमन्त्रणा'; + + @override + String get group_members_load_error => + 'सदस्यहरू लोड गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।'; + + @override + String get group_followers_load_error => + 'अनुयायीहरू लोड गर्न सकिएन। कृपया पुनः प्रयास गर्नुहोस्।'; + + @override + String get group_members_empty => 'अहिलेसम्म कुनै सदस्य छैन'; + + @override + String get group_followers_empty => 'अहिलेसम्म कुनै अनुयायी छैन'; + @override String get group_follower => 'अनुयायी'; diff --git a/lib/core/l10n/generated/app_localizations_zh.dart b/lib/core/l10n/generated/app_localizations_zh.dart index 643cbac8..e788cb38 100644 --- a/lib/core/l10n/generated/app_localizations_zh.dart +++ b/lib/core/l10n/generated/app_localizations_zh.dart @@ -1476,6 +1476,37 @@ class AppLocalizationsZh extends AppLocalizations { @override String get group_members => '位成員'; + @override + String get group_tab_members => '成員'; + + @override + String get group_tab_followers => '追蹤者'; + + @override + String group_members_heading(int count) { + return '成員($count)'; + } + + @override + String group_followers_heading(int count) { + return '追蹤者($count)'; + } + + @override + String get group_invite => '邀請'; + + @override + String get group_members_load_error => '無法載入成員,請再試一次。'; + + @override + String get group_followers_load_error => '無法載入追蹤者,請再試一次。'; + + @override + String get group_members_empty => '尚無成員'; + + @override + String get group_followers_empty => '尚無追蹤者'; + @override String get group_follower => '位追蹤者'; diff --git a/lib/core/widgets/destructive_confirmation_dialog.dart b/lib/core/widgets/destructive_confirmation_dialog.dart index f6697bd7..8bdf2477 100644 --- a/lib/core/widgets/destructive_confirmation_dialog.dart +++ b/lib/core/widgets/destructive_confirmation_dialog.dart @@ -150,6 +150,7 @@ class _DestructiveConfirmationDialogState ), style: textTheme.labelLarge?.copyWith( fontSize: buttonFontSize, + color: Colors.red.shade600, ), ), ), diff --git a/lib/features/auth/presentation/widgets/login_drawer.dart b/lib/features/auth/presentation/widgets/login_drawer.dart index 0001099a..cad36a6c 100644 --- a/lib/features/auth/presentation/widgets/login_drawer.dart +++ b/lib/features/auth/presentation/widgets/login_drawer.dart @@ -11,10 +11,15 @@ class LoginDrawer extends ConsumerStatefulWidget { const LoginDrawer({super.key}); /// Show the login drawer as a bottom sheet - static Future show(BuildContext context, WidgetRef ref) { + static Future show( + BuildContext context, + WidgetRef ref, { + bool useRootNavigator = false, + }) { return showModalBottomSheet( context: context, isScrollControlled: true, + useRootNavigator: useRootNavigator, backgroundColor: Colors.transparent, isDismissible: true, enableDrag: true, @@ -98,11 +103,7 @@ class _LoginDrawerState extends ConsumerState ), ), // App logo - Image.asset( - AppAssets.weBuddhistLogo, - height: 80, - width: 80, - ), + Image.asset(AppAssets.weBuddhistLogo, height: 80, width: 80), const SizedBox(height: 24), // Title Text( diff --git a/lib/features/calendar/presentation/widgets/calendar_month_nav.dart b/lib/features/calendar/presentation/widgets/calendar_month_nav.dart index 22eb9309..78d940b5 100644 --- a/lib/features/calendar/presentation/widgets/calendar_month_nav.dart +++ b/lib/features/calendar/presentation/widgets/calendar_month_nav.dart @@ -15,17 +15,19 @@ class CalendarMonthNav extends ConsumerWidget { final theme = Theme.of(context); final l10n = context.l10n; final focusedMonth = ref.watch(focusedCalendarMonthProvider); - final service = ref.watch(tibetanCalendarServiceProvider); final monthTitle = DateFormat.yMMMM( dateFormatLocale(context), ).format(focusedMonth); - // Lunar month label for the focused month, sampled at its mid-point so a - // lunar-month boundary near the start/end doesn't mislabel the page. - final midLunar = service.fromWestern( - DateTime(focusedMonth.year, focusedMonth.month, 15), - ); - final lunarSubtitle = lunarMonthLabel(context, l10n, midLunar.month); + final today = dateOnly(DateTime.now()); + final isCurrentMonth = + today.year == focusedMonth.year && today.month == focusedMonth.month; + final sampleDate = + isCurrentMonth + ? today + : DateTime(focusedMonth.year, focusedMonth.month + 1, 0); + final lunarMonth = ref.watch(resolvedDayProvider(sampleDate)).lunarMonth; + final lunarSubtitle = lunarMonthLabel(context, l10n, lunarMonth); void shift(int months) { ref.read(focusedCalendarMonthProvider.notifier).state = DateTime( diff --git a/lib/features/calendar/presentation/widgets/upcoming_events_list.dart b/lib/features/calendar/presentation/widgets/upcoming_events_list.dart index 95f24681..4662ef5d 100644 --- a/lib/features/calendar/presentation/widgets/upcoming_events_list.dart +++ b/lib/features/calendar/presentation/widgets/upcoming_events_list.dart @@ -30,10 +30,20 @@ class _UpcomingEventsListState extends ConsumerState { final theme = Theme.of(context); final l10n = context.l10n; final focusedMonth = ref.watch(focusedCalendarMonthProvider); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); final events = ref .watch(monthEventsProvider(focusedMonth)) .where(showsInUpcomingEvents) + .where((event) { + final eventDay = DateTime( + event.date.year, + event.date.month, + event.date.day, + ); + return !eventDay.isBefore(today); + }) .toList(); if (events.isEmpty) return const SizedBox.shrink(); diff --git a/lib/features/group_profile/data/datasource/group_profile_remote_datasource.dart b/lib/features/group_profile/data/datasource/group_profile_remote_datasource.dart index 8ce567ec..e9eb4fa0 100644 --- a/lib/features/group_profile/data/datasource/group_profile_remote_datasource.dart +++ b/lib/features/group_profile/data/datasource/group_profile_remote_datasource.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter_pecha/core/error/exceptions.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/group_profile/data/models/group_member_model.dart'; import 'package:flutter_pecha/features/group_profile/data/models/group_profile_model.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; @@ -87,6 +88,36 @@ class GroupProfileRemoteDatasource { } } + Future fetchGroupMembers( + String groupId, { + required int skip, + required int limit, + }) async { + try { + final response = await dio.get( + '/author/groups/$groupId/members', + queryParameters: {'skip': skip, 'limit': limit}, + ); + + if (response.statusCode == 200) { + return GroupMembersPageModel.fromJson( + response.data as Map, + ); + } else { + _logger.error( + 'Failed to load group members $groupId: ${response.statusCode}', + ); + throw _statusToException( + response.statusCode, + 'Failed to load group members', + ); + } + } on DioException catch (e) { + _logger.error('Dio error in fetchGroupMembers', e); + throw _dioToException(e, 'Failed to load group members'); + } + } + Future unfollowGroup(String groupId, GroupType groupType) async { final action = groupType.isPage ? 'follow' : 'join'; try { diff --git a/lib/features/group_profile/data/models/group_member_model.dart b/lib/features/group_profile/data/models/group_member_model.dart new file mode 100644 index 00000000..048cfd10 --- /dev/null +++ b/lib/features/group_profile/data/models/group_member_model.dart @@ -0,0 +1,67 @@ +import 'package:flutter_pecha/features/group_profile/domain/entities/group_member.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_members_page.dart'; + +class GroupMemberModel { + final String username; + final String fullname; + final String? avatarUrl; + + GroupMemberModel({ + required this.username, + required this.fullname, + this.avatarUrl, + }); + + factory GroupMemberModel.fromJson(Map json) { + return GroupMemberModel( + username: json['username'] as String? ?? '', + fullname: json['fullname'] as String? ?? '', + avatarUrl: json['avatar_url'] as String?, + ); + } + + GroupMember toEntity() { + return GroupMember( + username: username, + fullname: fullname, + avatarUrl: avatarUrl, + ); + } +} + +class GroupMembersPageModel { + final List members; + final int skip; + final int limit; + final int totalMembers; + + GroupMembersPageModel({ + required this.members, + required this.skip, + required this.limit, + required this.totalMembers, + }); + + factory GroupMembersPageModel.fromJson(Map json) { + return GroupMembersPageModel( + members: + (json['list'] as List?) + ?.whereType>() + .map(GroupMemberModel.fromJson) + .toList() ?? + const [], + skip: (json['skip'] as num?)?.toInt() ?? 0, + limit: (json['limit'] as num?)?.toInt() ?? 0, + totalMembers: (json['total_members'] as num?)?.toInt() ?? 0, + ); + } + + GroupMembersPage toEntity() { + return GroupMembersPage( + members: members.map((member) => member.toEntity()).toList(), + skip: skip, + limit: limit, + totalMembers: totalMembers, + ); + } +} diff --git a/lib/features/group_profile/data/repositories/group_profile_repository_impl.dart b/lib/features/group_profile/data/repositories/group_profile_repository_impl.dart index 68403666..f8f7f80d 100644 --- a/lib/features/group_profile/data/repositories/group_profile_repository_impl.dart +++ b/lib/features/group_profile/data/repositories/group_profile_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:fpdart/fpdart.dart'; import 'package:flutter_pecha/core/error/exceptions.dart'; import 'package:flutter_pecha/core/error/failures.dart'; import 'package:flutter_pecha/features/group_profile/data/datasource/group_profile_remote_datasource.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_members_page.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; import 'package:flutter_pecha/features/group_profile/domain/repositories/group_profile_repository.dart'; @@ -82,6 +83,34 @@ class GroupProfileRepositoryImpl implements GroupProfileRepositoryInterface { } } + @override + Future> getGroupMembers( + String groupId, { + required int skip, + required int limit, + }) async { + try { + final model = await remote.fetchGroupMembers( + groupId, + skip: skip, + limit: limit, + ); + return Right(model.toEntity()); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException catch (e) { + return Left(NetworkFailure(e.message)); + } on AuthenticationException catch (e) { + return Left(AuthenticationFailure(e.message)); + } on NotFoundException catch (e) { + return Left(NotFoundFailure(e.message)); + } on RateLimitException catch (e) { + return Left(RateLimitFailure(e.message)); + } catch (e) { + return Left(UnknownFailure('Failed to load group members: $e')); + } + } + @override Future> unfollowGroup( String groupId, diff --git a/lib/features/group_profile/domain/entities/group_member.dart b/lib/features/group_profile/domain/entities/group_member.dart new file mode 100644 index 00000000..65abb8fa --- /dev/null +++ b/lib/features/group_profile/domain/entities/group_member.dart @@ -0,0 +1,11 @@ +class GroupMember { + final String username; + final String fullname; + final String? avatarUrl; + + const GroupMember({ + required this.username, + required this.fullname, + this.avatarUrl, + }); +} diff --git a/lib/features/group_profile/domain/entities/group_members_page.dart b/lib/features/group_profile/domain/entities/group_members_page.dart new file mode 100644 index 00000000..be3c6803 --- /dev/null +++ b/lib/features/group_profile/domain/entities/group_members_page.dart @@ -0,0 +1,17 @@ +import 'package:flutter_pecha/features/group_profile/domain/entities/group_member.dart'; + +class GroupMembersPage { + final List members; + final int skip; + final int limit; + final int totalMembers; + + const GroupMembersPage({ + required this.members, + required this.skip, + required this.limit, + required this.totalMembers, + }); + + bool get hasMore => skip + members.length < totalMembers; +} diff --git a/lib/features/group_profile/domain/repositories/group_profile_repository.dart b/lib/features/group_profile/domain/repositories/group_profile_repository.dart index e4714b4b..0dcfd1cb 100644 --- a/lib/features/group_profile/domain/repositories/group_profile_repository.dart +++ b/lib/features/group_profile/domain/repositories/group_profile_repository.dart @@ -1,5 +1,6 @@ import 'package:fpdart/fpdart.dart'; import 'package:flutter_pecha/core/error/failures.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_members_page.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; abstract class GroupProfileRepositoryInterface { @@ -22,4 +23,10 @@ abstract class GroupProfileRepositoryInterface { String groupId, GroupType groupType, ); + + Future> getGroupMembers( + String groupId, { + required int skip, + required int limit, + }); } diff --git a/lib/features/group_profile/presentation/providers/group_profile_providers.dart b/lib/features/group_profile/presentation/providers/group_profile_providers.dart index 99ccf04c..731cb7cc 100644 --- a/lib/features/group_profile/presentation/providers/group_profile_providers.dart +++ b/lib/features/group_profile/presentation/providers/group_profile_providers.dart @@ -6,6 +6,7 @@ import 'package:flutter_pecha/features/auth/presentation/providers/state_provide import 'package:flutter_pecha/features/connect/presentation/providers/connect_providers.dart'; import 'package:flutter_pecha/features/group_profile/data/datasource/group_profile_remote_datasource.dart'; import 'package:flutter_pecha/features/group_profile/data/repositories/group_profile_repository_impl.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_member.dart'; import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; import 'package:flutter_pecha/features/group_profile/domain/repositories/group_profile_repository.dart'; import 'package:flutter_pecha/features/group_profile/domain/usecases/get_group_profile_usecase.dart'; @@ -249,3 +250,133 @@ final groupFollowProvider = StateNotifierProvider.autoDispose isAuthenticated: isAuthenticated, ); }); + +class GroupMembersState { + final List members; + final int totalMembers; + final bool isLoading; + final bool isLoadingMore; + final String? error; + final bool hasMore; + final int skip; + + const GroupMembersState({ + this.members = const [], + this.totalMembers = 0, + this.isLoading = false, + this.isLoadingMore = false, + this.error, + this.hasMore = true, + this.skip = 0, + }); + + GroupMembersState copyWith({ + List? members, + int? totalMembers, + bool? isLoading, + bool? isLoadingMore, + String? error, + bool? hasMore, + int? skip, + bool clearError = false, + }) { + return GroupMembersState( + members: members ?? this.members, + totalMembers: totalMembers ?? this.totalMembers, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + error: clearError ? null : error ?? this.error, + hasMore: hasMore ?? this.hasMore, + skip: skip ?? this.skip, + ); + } +} + +class GroupMembersNotifier extends StateNotifier { + GroupMembersNotifier({ + required GroupProfileRepositoryInterface repository, + required String groupId, + }) : _repository = repository, + _groupId = groupId, + super(const GroupMembersState()); + + final GroupProfileRepositoryInterface _repository; + final String _groupId; + static const int _limit = 20; + + Future loadInitial() async { + if (state.isLoading) return; + + state = state.copyWith(isLoading: true, clearError: true); + + final result = await _repository.getGroupMembers( + _groupId, + skip: 0, + limit: _limit, + ); + + if (!mounted) return; + + result.fold( + (failure) { + state = state.copyWith(isLoading: false, error: failure.message); + }, + (page) { + state = state.copyWith( + members: page.members, + totalMembers: page.totalMembers, + isLoading: false, + hasMore: page.hasMore, + skip: page.members.length, + clearError: true, + ); + }, + ); + } + + Future loadMore() async { + if (state.isLoadingMore || !state.hasMore || state.isLoading) return; + + state = state.copyWith(isLoadingMore: true, clearError: true); + + final result = await _repository.getGroupMembers( + _groupId, + skip: state.skip, + limit: _limit, + ); + + if (!mounted) return; + + result.fold( + (failure) { + state = state.copyWith(isLoadingMore: false, error: failure.message); + }, + (page) { + state = state.copyWith( + members: [...state.members, ...page.members], + totalMembers: page.totalMembers, + isLoadingMore: false, + hasMore: page.hasMore, + skip: state.skip + page.members.length, + clearError: true, + ); + }, + ); + } + + void retry() { + if (state.members.isEmpty) { + loadInitial(); + } else { + loadMore(); + } + } +} + +final groupMembersProvider = StateNotifierProvider.autoDispose + .family((ref, groupId) { + return GroupMembersNotifier( + repository: ref.watch(groupProfileRepositoryProvider), + groupId: groupId, + ); +}); diff --git a/lib/features/group_profile/presentation/widgets/group_profile_body.dart b/lib/features/group_profile/presentation/widgets/group_profile_body.dart index dce256d0..2d000733 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_body.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_body.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; -import 'package:flutter_pecha/core/l10n/intl_format_locale.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; @@ -10,24 +9,22 @@ import 'package:flutter_pecha/features/auth/presentation/widgets/login_drawer.da import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_profile_links_drawer.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/widgets/group_profile_members_tab.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_inline_markdown_view.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:url_launcher/url_launcher.dart'; class GroupProfileBody extends ConsumerStatefulWidget { final GroupProfile profile; final bool isDark; - final ScrollController? scrollController; final VoidCallback? onSeriesTap; const GroupProfileBody({ super.key, required this.profile, required this.isDark, - this.scrollController, this.onSeriesTap, }); @@ -37,19 +34,28 @@ class GroupProfileBody extends ConsumerStatefulWidget { class _GroupProfileBodyState extends ConsumerState with SingleTickerProviderStateMixin { - bool _isDescriptionExpanded = false; late TabController _tabController; + int get _tabCount { + var count = 2; + if (_hasAboutContent(widget.profile)) count++; + return count; + } + + int get _membersTabIndex => 1; + bool _hasAboutContent(GroupProfile profile) { + final hasBanner = profile.bannerUrl != null && profile.bannerUrl!.isNotEmpty; final descriptionLong = profile.descriptionLong?.trim(); - return descriptionLong != null && descriptionLong.isNotEmpty; + return hasBanner || + (descriptionLong != null && descriptionLong.isNotEmpty); } @override void initState() { super.initState(); _tabController = TabController( - length: _hasAboutContent(widget.profile) ? 2 : 1, + length: _tabCount, vsync: this, ); } @@ -71,63 +77,39 @@ class _GroupProfileBodyState extends ConsumerState profile.socialLinks, ); - return SingleChildScrollView( - controller: widget.scrollController, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (profile.bannerUrl != null && profile.bannerUrl!.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: AspectRatio( - aspectRatio: 16 / 9, - child: CachedNetworkImageWidget( - key: ValueKey(profile.bannerUrl), - imageUrl: profile.bannerUrl, - fit: BoxFit.cover, - errorWidget: Container( - color: - isDark - ? AppColors.surfaceVariantDark - : AppColors.grey100, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - _buildProfileHeader(profile, isDark, lineHeight), - if (profile.description != null && - profile.description!.trim().isNotEmpty) ...[ - const SizedBox(height: 12), - _buildDescription(profile.description!, isDark), - ], - if (orderedLinks.isNotEmpty) ...[ - const SizedBox(height: 12), - _buildLinksSummary(orderedLinks, isDark, lineHeight), - ], - const SizedBox(height: 20), - _GroupFollowButton(profile: profile, isDark: isDark), - const SizedBox(height: 24), - _buildTabBar(isDark, _hasAboutContent(profile)), - const SizedBox(height: 16), - AnimatedBuilder( + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProfileHeader(profile, isDark, lineHeight), + if (orderedLinks.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildLinksSummary(orderedLinks, isDark, lineHeight), + ], + const SizedBox(height: 20), + _GroupFollowButton(profile: profile, isDark: isDark), + const SizedBox(height: 24), + _buildTabBar(isDark, profile), + Expanded( + child: AnimatedBuilder( animation: _tabController, builder: (context, _) { - if (_tabController.index == 0) { + final tabIndex = _tabController.index; + if (tabIndex == 0) { return _buildPracticesTab(profile, isDark, lineHeight); } + if (tabIndex == _membersTabIndex) { + return GroupProfileMembersTab( + groupId: profile.id, + groupType: profile.groupType, + isDark: isDark, + lineHeight: lineHeight, + ); + } return _buildAboutTab(profile, isDark, locale.languageCode); }, ), - const SizedBox(height: 32), - ], - ), + ), + ], ); } @@ -189,16 +171,7 @@ class _GroupProfileBodyState extends ConsumerState height: lineHeight, ), ), - const SizedBox(height: 4), - ] else - const SizedBox(height: 8), - _GroupMemberCountText( - groupId: profile.id, - groupType: profile.groupType, - baseCount: profile.memberOrFollowerCount, - isDark: isDark, - lineHeight: lineHeight, - ), + ], ], ), ); @@ -215,28 +188,6 @@ class _GroupProfileBodyState extends ConsumerState ); } - Widget _buildDescription(String description, bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: GestureDetector( - onTap: - () => setState( - () => _isDescriptionExpanded = !_isDescriptionExpanded, - ), - behavior: HitTestBehavior.opaque, - child: Text( - description, - style: TextStyle( - fontSize: 15, - color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - ), - maxLines: _isDescriptionExpanded ? null : 6, - overflow: _isDescriptionExpanded ? null : TextOverflow.ellipsis, - ), - ), - ); - } - Widget _buildLinksSummary( List links, bool isDark, @@ -293,10 +244,15 @@ class _GroupProfileBodyState extends ConsumerState ); } - Widget _buildTabBar(bool isDark, bool hasAbout) { + Widget _buildTabBar(bool isDark, GroupProfile profile) { final labelColor = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; final dividerColor = isDark ? AppColors.grey800 : AppColors.grey300; + final hasAbout = _hasAboutContent(profile); + final membersTabLabel = + profile.groupType.isPage + ? context.l10n.group_tab_followers + : context.l10n.group_tab_members; return Column( children: [ @@ -316,6 +272,7 @@ class _GroupProfileBodyState extends ConsumerState ), tabs: [ Tab(text: context.l10n.nav_practice), + Tab(text: membersTabLabel), if (hasAbout) Tab(text: context.l10n.about_title), ], ), @@ -333,11 +290,12 @@ class _GroupProfileBodyState extends ConsumerState return const SizedBox.shrink(); } - return Column( - children: - profile.series - .map((series) => _buildSeriesRow(series, isDark, lineHeight)) - .toList(), + return ListView.builder( + padding: const EdgeInsets.only(top: 16, bottom: 32), + itemCount: profile.series.length, + itemBuilder: (context, index) { + return _buildSeriesRow(profile.series[index], isDark, lineHeight); + }, ); } @@ -347,17 +305,47 @@ class _GroupProfileBodyState extends ConsumerState String languageCode, ) { final descriptionLong = profile.descriptionLong?.trim(); - if (descriptionLong == null || descriptionLong.isEmpty) { + final hasBanner = profile.bannerUrl != null && profile.bannerUrl!.isNotEmpty; + final hasDescription = + descriptionLong != null && descriptionLong.isNotEmpty; + + if (!hasBanner && !hasDescription) { return const SizedBox.shrink(); } final bodyFontSize = getLocalizedFontSize(AppTextSize.body); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: PlanInlineMarkdownView( - content: descriptionLong, - fontSize: bodyFontSize, + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasBanner) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AspectRatio( + aspectRatio: 16 / 9, + child: CachedNetworkImageWidget( + key: ValueKey(profile.bannerUrl), + imageUrl: profile.bannerUrl, + fit: BoxFit.cover, + errorWidget: Container( + color: + isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + ), + ), + ), + ), + if (hasDescription) const SizedBox(height: 20), + ], + if (hasDescription) + PlanInlineMarkdownView( + content: descriptionLong, + fontSize: bodyFontSize, + ), + ], ), ); } @@ -454,64 +442,6 @@ class _GroupProfileBodyState extends ConsumerState } } -class _GroupMemberCountText extends ConsumerWidget { - final String groupId; - final GroupType groupType; - final int baseCount; - final bool isDark; - final double? lineHeight; - - const _GroupMemberCountText({ - required this.groupId, - required this.groupType, - required this.baseCount, - required this.isDark, - this.lineHeight, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final followKey = GroupFollowKey( - groupId: groupId, - groupType: groupType, - ); - final followState = ref.watch(groupFollowProvider(followKey)); - final delta = switch (followState) { - GroupFollowSuccess(countDelta: final d) => d, - _ => 0, - }; - final count = (baseCount + delta).clamp(0, 1 << 31); - final formattedCount = NumberFormat.decimalPattern( - intlFormatLocaleOf(context), - ).format(count); - final countLabel = - groupType.isPage - ? (count == 1 - ? context.l10n.group_follower - : context.l10n.group_followers) - : (count == 1 - ? context.l10n.group_member - : context.l10n.group_members); - - return RichText( - text: TextSpan( - style: TextStyle( - fontSize: 14, - color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, - height: lineHeight, - ), - children: [ - TextSpan( - text: formattedCount, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextSpan(text: ' $countLabel'), - ], - ), - ); - } -} - class _GroupFollowButton extends ConsumerWidget { final GroupProfile profile; final bool isDark; @@ -535,61 +465,154 @@ class _GroupFollowButton extends ConsumerWidget { const fontSize = 16.0; final locale = Localizations.localeOf(context); final isTibetan = context.isTibetanLocale; + final buttonHeight = isTibetan ? 52.0 : 48.0; + final buttonStyle = ElevatedButton.styleFrom( + minimumSize: Size(0, buttonHeight), + padding: EdgeInsets.symmetric( + horizontal: 24, + vertical: isTibetan ? 10 : 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + elevation: 0, + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: - isLoading - ? null - : () => - _onFollowPressed(context, ref, followKey, isFollowing), - style: ElevatedButton.styleFrom( - minimumSize: Size(double.infinity, isTibetan ? 52 : 48), - padding: EdgeInsets.symmetric( - horizontal: 24, - vertical: isTibetan ? 10 : 12, - ), - backgroundColor: - isFollowing - ? (isDark - ? AppColors.surfaceVariantDark - : AppColors.grey100) - : (isDark ? AppColors.surfaceWhite : AppColors.textPrimary), - foregroundColor: - isFollowing - ? (isDark ? AppColors.surfaceWhite : AppColors.textPrimary) - : (isDark ? AppColors.textPrimary : AppColors.surfaceWhite), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - elevation: 0, - ), - child: - isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text( - isFollowing - ? (isPage - ? context.l10n.following - : context.l10n.joined) - : (isPage ? context.l10n.follow : context.l10n.join), - textAlign: TextAlign.center, - strutStyle: context.tibetanStrutStyle(fontSize), - style: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.w600, - fontFamily: getSystemFontFamily(locale.languageCode), + child: + isFollowing + ? Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: + isLoading + ? null + : () => _onFollowPressed( + context, + ref, + followKey, + isFollowing, + ), + style: buttonStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + ), + foregroundColor: WidgetStatePropertyAll( + isDark + ? AppColors.surfaceWhite + : AppColors.textPrimary, + ), + ), + child: + isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text( + isPage + ? context.l10n.following + : context.l10n.joined, + textAlign: TextAlign.center, + strutStyle: context.tibetanStrutStyle(fontSize), + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + fontFamily: getSystemFontFamily( + locale.languageCode, + ), + ), + ), ), ), - ), - ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.mala_action_coming_soon), + behavior: SnackBarBehavior.floating, + ), + ); + }, + style: buttonStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + isDark + ? AppColors.surfaceWhite + : AppColors.textPrimary, + ), + foregroundColor: WidgetStatePropertyAll( + isDark + ? AppColors.textPrimary + : AppColors.surfaceWhite, + ), + ), + child: Text( + context.l10n.group_invite, + textAlign: TextAlign.center, + strutStyle: context.tibetanStrutStyle(fontSize), + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + fontFamily: getSystemFontFamily(locale.languageCode), + ), + ), + ), + ), + ], + ) + : SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + isLoading + ? null + : () => _onFollowPressed( + context, + ref, + followKey, + isFollowing, + ), + style: buttonStyle.copyWith( + backgroundColor: WidgetStatePropertyAll( + isDark ? AppColors.surfaceWhite : AppColors.textPrimary, + ), + foregroundColor: WidgetStatePropertyAll( + isDark ? AppColors.textPrimary : AppColors.surfaceWhite, + ), + minimumSize: WidgetStatePropertyAll( + Size(double.infinity, buttonHeight), + ), + ), + child: + isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + isPage ? context.l10n.follow : context.l10n.join, + textAlign: TextAlign.center, + strutStyle: context.tibetanStrutStyle(fontSize), + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + fontFamily: getSystemFontFamily( + locale.languageCode, + ), + ), + ), + ), + ), ); } diff --git a/lib/features/group_profile/presentation/widgets/group_profile_drawer.dart b/lib/features/group_profile/presentation/widgets/group_profile_drawer.dart index e4377bd2..b75b6f55 100644 --- a/lib/features/group_profile/presentation/widgets/group_profile_drawer.dart +++ b/lib/features/group_profile/presentation/widgets/group_profile_drawer.dart @@ -30,7 +30,7 @@ class GroupProfileDrawer extends ConsumerWidget { initialChildSize: 0.75, minChildSize: 0.4, maxChildSize: 0.95, - builder: (context, scrollController) { + builder: (context, _) { return Container( decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, @@ -54,7 +54,6 @@ class GroupProfileDrawer extends ConsumerWidget { (profile) => GroupProfileBody( profile: profile, isDark: isDark, - scrollController: scrollController, onSeriesTap: () => Navigator.of(context).pop(), ), ), diff --git a/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart b/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart new file mode 100644 index 00000000..cc80fab0 --- /dev/null +++ b/lib/features/group_profile/presentation/widgets/group_profile_members_tab.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/core/theme/app_colors.dart'; +import 'package:flutter_pecha/core/widgets/cached_network_image_widget.dart'; +import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_member.dart'; +import 'package:flutter_pecha/features/group_profile/domain/entities/group_profile.dart'; +import 'package:flutter_pecha/features/group_profile/presentation/providers/group_profile_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class GroupProfileMembersTab extends ConsumerStatefulWidget { + final String groupId; + final GroupType groupType; + final bool isDark; + final double? lineHeight; + + const GroupProfileMembersTab({ + super.key, + required this.groupId, + required this.groupType, + required this.isDark, + this.lineHeight, + }); + + @override + ConsumerState createState() => + _GroupProfileMembersTabState(); +} + +class _GroupProfileMembersTabState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + bool _hasRequestedInitialLoad = false; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + _loadInitialIfNeeded(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _loadInitialIfNeeded() { + if (_hasRequestedInitialLoad) return; + _hasRequestedInitialLoad = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(groupMembersProvider(widget.groupId).notifier).loadInitial(); + }); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + ref.read(groupMembersProvider(widget.groupId).notifier).loadMore(); + } + } + + String _membersHeading(BuildContext context, int count) { + return widget.groupType.isPage + ? context.l10n.group_followers_heading(count) + : context.l10n.group_members_heading(count); + } + + @override + Widget build(BuildContext context) { + final membersState = ref.watch(groupMembersProvider(widget.groupId)); + + if (membersState.isLoading && membersState.members.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (membersState.error != null && membersState.members.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: ErrorStateWidget( + error: membersState.error!, + onRetry: + () => ref + .read(groupMembersProvider(widget.groupId).notifier) + .retry(), + customMessage: + widget.groupType.isPage + ? context.l10n.group_followers_load_error + : context.l10n.group_members_load_error, + ), + ), + ); + } + + if (membersState.members.isEmpty) { + return ListView( + controller: _scrollController, + padding: const EdgeInsets.only(top: 16, bottom: 32), + children: [ + if (!membersState.isLoading) + _MembersHeading( + title: _membersHeading(context, membersState.totalMembers), + isDark: widget.isDark, + lineHeight: widget.lineHeight, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + child: Text( + widget.groupType.isPage + ? context.l10n.group_followers_empty + : context.l10n.group_members_empty, + style: TextStyle( + fontSize: 15, + color: + widget.isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + height: widget.lineHeight, + ), + ), + ), + ], + ); + } + + final itemCount = + 1 + membersState.members.length + (membersState.isLoadingMore ? 1 : 0); + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only(top: 16, bottom: 32), + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == 0) { + return _MembersHeading( + title: _membersHeading(context, membersState.totalMembers), + isDark: widget.isDark, + lineHeight: widget.lineHeight, + ); + } + + final memberIndex = index - 1; + if (memberIndex < membersState.members.length) { + return _GroupMemberRow( + member: membersState.members[memberIndex], + isDark: widget.isDark, + lineHeight: widget.lineHeight, + ); + } + + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + }, + ); + } +} + +class _MembersHeading extends StatelessWidget { + final String title; + final bool isDark; + final double? lineHeight; + + const _MembersHeading({ + required this.title, + required this.isDark, + this.lineHeight, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + height: lineHeight, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + ), + ), + ); + } +} + +class _GroupMemberRow extends StatelessWidget { + final GroupMember member; + final bool isDark; + final double? lineHeight; + + const _GroupMemberRow({ + required this.member, + required this.isDark, + this.lineHeight, + }); + + @override + Widget build(BuildContext context) { + final secondaryColor = + isDark ? AppColors.textTertiaryDark : AppColors.textSecondary; + final displayName = + member.fullname.trim().isNotEmpty + ? member.fullname.trim() + : member.username; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + ClipOval( + child: SizedBox( + width: 44, + height: 44, + child: + member.avatarUrl != null && member.avatarUrl!.isNotEmpty + ? CachedNetworkImageWidget( + key: ValueKey(member.avatarUrl), + imageUrl: member.avatarUrl, + width: 44, + height: 44, + fit: BoxFit.cover, + errorWidget: _buildAvatarFallback(isDark), + ) + : _buildAvatarFallback(isDark), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + height: lineHeight, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (member.username.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '@${member.username}', + style: TextStyle( + fontSize: 13, + color: secondaryColor, + height: lineHeight, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildAvatarFallback(bool isDark) { + return ColoredBox( + color: isDark ? AppColors.surfaceVariantDark : AppColors.grey100, + child: Icon( + AppAssets.profile, + size: 22, + color: isDark ? AppColors.grey500 : AppColors.grey600, + ), + ); + } +} diff --git a/lib/features/home/presentation/screens/series_detail_screen.dart b/lib/features/home/presentation/screens/series_detail_screen.dart index 2beb7930..8bcdde1c 100644 --- a/lib/features/home/presentation/screens/series_detail_screen.dart +++ b/lib/features/home/presentation/screens/series_detail_screen.dart @@ -10,7 +10,8 @@ import 'package:flutter_pecha/features/home/presentation/providers/series_provid import 'package:flutter_pecha/features/home/presentation/widgets/plan_list_view.dart'; import 'package:flutter_pecha/features/home/presentation/widgets/series_more_bottom_sheet.dart'; import 'package:flutter_pecha/features/plans/presentation/providers/user_plans_provider.dart'; -import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -40,6 +41,17 @@ class SeriesDetailScreen extends ConsumerWidget { ) ?? series; + if (resolvedSeries != null) { + ref.watch( + prefetchBookmarkExistsProvider( + BookmarkTarget( + type: BookmarkType.series, + sourceId: resolvedSeries.id, + ), + ), + ); + } + return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: SafeArea( @@ -152,12 +164,9 @@ class SeriesDetailScreen extends ConsumerWidget { void _openMoreSheet(BuildContext context, WidgetRef ref, Series series) { showSeriesMoreBottomSheet( context, + seriesId: series.id, + seriesName: series.title, onAddToPractices: () => _onAddToPractices(context, ref, series), - onBookmark: - () => BookmarkController( - ref: ref, - context: context, - ).bookmarkSeries(series.id, name: series.title), ); } diff --git a/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart b/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart index 7df7aedf..3be113ce 100644 --- a/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart +++ b/lib/features/home/presentation/widgets/series_more_bottom_sheet.dart @@ -1,30 +1,63 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Bottom sheet opened from the three-dot (⋮) button on the series detail /// screen. /// /// Contains: /// • "+ Add to my practices" action -/// • Bookmark action -/// -/// Both callbacks fire after the sheet is dismissed so any feedback -/// (snackbar / login drawer) is shown on the underlying screen rather than -/// behind the closing modal. -class SeriesMoreBottomSheet extends StatelessWidget { +/// • Bookmark toggle action +class SeriesMoreBottomSheet extends ConsumerStatefulWidget { const SeriesMoreBottomSheet({ super.key, + required this.seriesId, + required this.seriesName, this.onAddToPractices, - this.onBookmark, }); + final String seriesId; + final String seriesName; final VoidCallback? onAddToPractices; - final VoidCallback? onBookmark; + + @override + ConsumerState createState() => + _SeriesMoreBottomSheetState(); +} + +class _SeriesMoreBottomSheetState extends ConsumerState { + bool _isBookmarking = false; + + BookmarkTarget get _bookmarkTarget => BookmarkTarget( + type: BookmarkType.series, + sourceId: widget.seriesId, + ); + + Future _toggleBookmark() async { + if (_isBookmarking) return; + setState(() => _isBookmarking = true); + try { + final nav = Navigator.of(context); + final didToggle = await BookmarkController( + ref: ref, + context: context, + ).toggleSeries(widget.seriesId, name: widget.seriesName); + if (mounted && didToggle) nav.pop(); + } finally { + if (mounted) setState(() => _isBookmarking = false); + } + } @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; + final isBookmarked = ref.watch(isBookmarkedProvider(_bookmarkTarget)); return SafeArea( top: false, @@ -55,22 +88,33 @@ class SeriesMoreBottomSheet extends StatelessWidget { onTap: () { HapticFeedback.lightImpact(); Navigator.of(context).pop(); - onAddToPractices?.call(); + widget.onAddToPractices?.call(); }, ), // ── Bookmark ─────────────────────────────────────────────────── _SectionDivider(theme: theme), ListTile( - leading: Icon( - AppAssets.bookmarkSimple, - color: theme.colorScheme.onSurface, - ), - title: Text('Bookmark', style: theme.textTheme.bodyLarge), + leading: + _isBookmarking + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSurface, + ), + ) + : Icon( + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, + color: theme.colorScheme.onSurface, + ), + title: Text(l10n.bookmark, style: theme.textTheme.bodyLarge), onTap: () { HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - onBookmark?.call(); + _toggleBookmark(); }, ), @@ -93,8 +137,9 @@ class _SectionDivider extends StatelessWidget { /// Shows the series "more" bottom sheet. void showSeriesMoreBottomSheet( BuildContext context, { + required String seriesId, + required String seriesName, VoidCallback? onAddToPractices, - VoidCallback? onBookmark, }) { showModalBottomSheet( context: context, @@ -105,8 +150,9 @@ void showSeriesMoreBottomSheet( useRootNavigator: true, builder: (_) => SeriesMoreBottomSheet( + seriesId: seriesId, + seriesName: seriesName, onAddToPractices: onAddToPractices, - onBookmark: onBookmark, ), ); } diff --git a/lib/features/home/presentation/widgets/verse_of_day_content.dart b/lib/features/home/presentation/widgets/verse_of_day_content.dart index fecaf4ca..27f77d44 100644 --- a/lib/features/home/presentation/widgets/verse_of_day_content.dart +++ b/lib/features/home/presentation/widgets/verse_of_day_content.dart @@ -162,6 +162,7 @@ class VerseOfDayContent extends StatelessWidget { aspectRatio: imageAspectRatio, child: CachedNetworkImageWidget( imageUrl: verseOfDay.imageUrl, + fallbackAsset: AppAssets.verseOfDayFallback, fit: BoxFit.cover, width: double.infinity, ), diff --git a/lib/features/mala/presentation/screens/mala_screen.dart b/lib/features/mala/presentation/screens/mala_screen.dart index b6bbe9ba..dcadbe7b 100644 --- a/lib/features/mala/presentation/screens/mala_screen.dart +++ b/lib/features/mala/presentation/screens/mala_screen.dart @@ -10,6 +10,8 @@ import 'package:flutter_pecha/features/mala/presentation/widgets/mala_beads.dart import 'package:flutter_pecha/features/mala/presentation/widgets/mala_skeleton.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/mantra_switcher.dart'; import 'package:flutter_pecha/features/mala/presentation/widgets/mala_settings_sheet.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -91,6 +93,10 @@ class _MalaScreenState extends ConsumerState { ); } + void _openMalaSettings(BuildContext context, Mantra mantra) { + MalaSettingsSheet.show(context, mantra: mantra); + } + Widget _buildLoaded(BuildContext context, List mantras) { if (mantras.isEmpty) { return const _MalaAppBarScaffold( @@ -111,6 +117,12 @@ class _MalaScreenState extends ConsumerState { final mantra = mantras[_index]; _trackOpened(mantra); + ref.watch( + prefetchBookmarkExistsProvider( + BookmarkTarget(type: BookmarkType.accumulator, sourceId: mantra.presetId), + ), + ); + final language = Localizations.localeOf(context).languageCode; final counter = ref.watch(malaCounterProvider(mantra)); final notifier = ref.read(malaCounterProvider(mantra).notifier); @@ -120,7 +132,7 @@ class _MalaScreenState extends ConsumerState { children: [ _MalaAppBar( title: mantra.displayTitle(language), - onMorePressed: () => MalaSettingsSheet.show(context, mantra: mantra), + onMorePressed: () => _openMalaSettings(context, mantra), ), Expanded( child: Padding( diff --git a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart index 5def3a38..c9d1e0f9 100644 --- a/lib/features/mala/presentation/widgets/mala_settings_sheet.dart +++ b/lib/features/mala/presentation/widgets/mala_settings_sheet.dart @@ -9,7 +9,9 @@ import 'package:flutter_pecha/core/widgets/destructive_confirmation_dialog.dart' import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_settings_provider.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/shared/widgets/app_toggle_switch.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -37,6 +39,11 @@ class MalaSettingsSheet extends ConsumerWidget { final settings = ref.watch(malaSettingsProvider); final settingsNotifier = ref.read(malaSettingsProvider.notifier); final isOnline = ref.watch(connectivityNotifierProvider); + final isBookmarked = ref.watch( + isBookmarkedProvider( + BookmarkTarget(type: BookmarkType.accumulator, sourceId: mantra.presetId), + ), + ); final dividerColor = isDark ? AppColors.cardBorderDark : AppColors.grey300; const destructiveColor = Color(0xFFB03027); @@ -67,9 +74,12 @@ class MalaSettingsSheet extends ConsumerWidget { ), Divider(height: 1, color: dividerColor), _MalaSettingsTile( - icon: AppAssets.bookmarkSimple, + icon: + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, label: l10n.mala_add_to_bookmark, - onTap: () => _onAddToBookmark(context, ref), + onTap: () => _onToggleBookmark(context, ref), ), Divider(height: 1, color: dividerColor), _MalaSettingsToggleTile( @@ -111,19 +121,19 @@ class MalaSettingsSheet extends ConsumerWidget { router.pushNamed('edit-routine', extra: {'initialMantra': mantra}); } - Future _onAddToBookmark(BuildContext context, WidgetRef ref) async { + Future _onToggleBookmark(BuildContext context, WidgetRef ref) async { final language = ref.read(contentLanguageProvider); final navigator = Navigator.of(context); - // The controller handles the guest → login-drawer flow and shows its own - // success/error snackbar, so it must run before the sheet is dismissed - // (its context drives both). The sheet is closed once the call resolves. - await BookmarkController(ref: ref, context: context).bookmarkMala( + final didToggle = await BookmarkController( + ref: ref, + context: context, + ).toggleMala( mantra.presetId, name: mantra.displayTitle(language), ); - if (context.mounted) navigator.pop(); + if (context.mounted && didToggle) navigator.pop(); } Future _onResetCount(BuildContext context, WidgetRef ref) async { diff --git a/lib/features/more/presentation/delete_account_screen.dart b/lib/features/more/presentation/delete_account_screen.dart index 9bb4aee8..068abe49 100644 --- a/lib/features/more/presentation/delete_account_screen.dart +++ b/lib/features/more/presentation/delete_account_screen.dart @@ -107,29 +107,30 @@ class _DeleteAccountScreenState extends ConsumerState { child: ElevatedButton( onPressed: _isDeleting ? null : _confirmDelete, style: ElevatedButton.styleFrom( - backgroundColor: isDark ? Colors.white : Colors.black, - foregroundColor: isDark ? Colors.black : Colors.white, - disabledBackgroundColor: - isDark ? Colors.white54 : Colors.black54, + backgroundColor: Colors.transparent, + disabledBackgroundColor: Colors.transparent, + side: BorderSide(color: isDark ? Colors.white : Colors.black), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(30), ), ), - child: _isDeleting - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: isDark ? Colors.black : Colors.white, + child: + _isDeleting + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: isDark ? Colors.black : Colors.white, + ), + ) + : Text( + AppLocalizations.of(context)!.delete_account_button, + style: TextStyle( + fontSize: 16, + color: Colors.red.shade600, + ), ), - ) - : Text( - AppLocalizations.of(context)!.delete_account_button, - style: const TextStyle( - fontSize: 16, - ), - ), ), ), ], diff --git a/lib/features/more/presentation/more_screen.dart b/lib/features/more/presentation/more_screen.dart index cd998c6e..05871ba7 100644 --- a/lib/features/more/presentation/more_screen.dart +++ b/lib/features/more/presentation/more_screen.dart @@ -133,7 +133,9 @@ class MoreScreen extends ConsumerWidget { context, icon: AppAssets.signIn, title: localizations.sign_in, - onTap: () => LoginDrawer.show(context, ref), + onTap: + () => + LoginDrawer.show(context, ref, useRootNavigator: true), ), ] else ...[ _buildSettingsRow( @@ -306,6 +308,7 @@ class MoreScreen extends ConsumerWidget { borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), isScrollControlled: true, + useRootNavigator: true, builder: (sheetContext) { final theme = Theme.of(sheetContext); final selected = currentLocale ?? Localizations.localeOf(sheetContext); diff --git a/lib/features/more/presentation/widgets/streak_share_sheet.dart b/lib/features/more/presentation/widgets/streak_share_sheet.dart index 4da8e790..a26ba4ea 100644 --- a/lib/features/more/presentation/widgets/streak_share_sheet.dart +++ b/lib/features/more/presentation/widgets/streak_share_sheet.dart @@ -31,29 +31,32 @@ class StreakSharePreview extends StatelessWidget { return Theme( data: lightTheme, - child: Container( - color: AppColors.goldLight, - padding: const EdgeInsets.fromLTRB(14, 20, 14, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[400]!), + child: Material( + type: MaterialType.transparency, + child: Container( + color: AppColors.goldLight, + padding: const EdgeInsets.fromLTRB(14, 20, 14, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[400]!), + ), + padding: const EdgeInsets.fromLTRB(24, 32, 24, 28), + child: StreakShareContent(streak: streak), ), - padding: const EdgeInsets.fromLTRB(24, 32, 24, 28), - child: StreakShareContent(streak: streak), - ), - const SizedBox(height: 24), - const VerseShareBranding( - logoSize: 32, - sharedFromFontSize: 12, - appTitleFontSize: 14, - ), - ], + const SizedBox(height: 24), + const VerseShareBranding( + logoSize: 32, + sharedFromFontSize: 12, + appTitleFontSize: 14, + ), + ], + ), ), ), ); diff --git a/lib/features/plans/presentation/screens/plan_text_screen.dart b/lib/features/plans/presentation/screens/plan_text_screen.dart index a59f16a5..814683a4 100644 --- a/lib/features/plans/presentation/screens/plan_text_screen.dart +++ b/lib/features/plans/presentation/screens/plan_text_screen.dart @@ -109,21 +109,37 @@ class _PlanTextScreenState extends ConsumerState { // ─── Build ───────────────────────────────────────────────────────────── + ThemeData _readerTheme(BuildContext context) { + final theme = Theme.of(context); + if (theme.brightness != Brightness.light) return theme; + + return theme.copyWith( + scaffoldBackgroundColor: Colors.white, + appBarTheme: theme.appBarTheme.copyWith(backgroundColor: Colors.white), + ); + } + @override Widget build(BuildContext context) { final currentItem = widget.navigationContext.currentItem; final fontSize = ref.watch(fontSizeProvider); + final readerTheme = _readerTheme(context); if (currentItem == null || currentItem.inlineContent == null) { - return _buildMissingContentScaffold(context); + return Theme( + data: readerTheme, + child: _buildMissingContentScaffold(context), + ); } final canSwipe = widget.navigationContext.canSwipe; - return Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: _buildAppBar(context, currentItem.title), - body: GestureDetector( + return Theme( + data: readerTheme, + child: Scaffold( + backgroundColor: readerTheme.scaffoldBackgroundColor, + appBar: _buildAppBar(context, currentItem.title), + body: GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragStart: canSwipe ? _onDragStart : null, onHorizontalDragUpdate: canSwipe ? _onDragUpdate : null, @@ -180,6 +196,7 @@ class _PlanTextScreenState extends ConsumerState { ), ), ), + ), ); } @@ -204,7 +221,9 @@ class _PlanTextScreenState extends ConsumerState { Widget _buildMissingContentScaffold(BuildContext context) { return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, leading: IconButton( icon: const Icon(AppAssets.arrowLeft), onPressed: () => context.pop(), diff --git a/lib/features/plans/presentation/widgets/day_carousel.dart b/lib/features/plans/presentation/widgets/day_carousel.dart index 1011579a..0cd39cb0 100644 --- a/lib/features/plans/presentation/widgets/day_carousel.dart +++ b/lib/features/plans/presentation/widgets/day_carousel.dart @@ -101,7 +101,7 @@ class _DayCarouselState extends State { itemCount: widget.days.length, itemBuilder: (context, index) { final day = widget.days[index]; - final dayDate = localStartDate.add(Duration(days: day.dayNumber)); + final dayDate = localStartDate.add(Duration(days: day.dayNumber - 1)); final dayDateString = DateFormat('dd MMM').format(dayDate); final isSelected = widget.selectedDay == day.dayNumber; final isCompleted = diff --git a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart index ea689623..cc4ecc42 100644 --- a/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart +++ b/lib/features/plans/presentation/widgets/plan_navigation/plan_navigation_bottom_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; @@ -84,8 +85,7 @@ class PlanNavigationBottomBar extends StatelessWidget { center: _TitleText(text: title, fontFamily: fallbackTitleFontFamily), trailing: () => _NavigationButton( - icon: Icons.check, - isEnabled: true, + icon: AppAssets.check, onTap: onFinish, ), ); @@ -118,8 +118,7 @@ class PlanNavigationBottomBar extends StatelessWidget { leading: hasPrevious ? () => _NavigationButton( - icon: Icons.chevron_left, - isEnabled: true, + icon: AppAssets.caretLeft, onTap: onPreviousTap ?? onPop, ) : null, @@ -127,13 +126,11 @@ class PlanNavigationBottomBar extends StatelessWidget { () => hasNext ? _NavigationButton( - icon: Icons.chevron_right, - isEnabled: true, + icon: AppAssets.caretRight, onTap: onNextTap ?? onPop, ) : _NavigationButton( - icon: Icons.check, - isEnabled: true, + icon: AppAssets.check, onTap: onFinishedTap ?? onPop, ), ); @@ -196,39 +193,18 @@ class _TitleText extends StatelessWidget { class _NavigationButton extends StatelessWidget { final IconData icon; - final bool isEnabled; final VoidCallback onTap; const _NavigationButton({ required this.icon, - required this.isEnabled, required this.onTap, }); @override Widget build(BuildContext context) { - final color = - isEnabled - ? Theme.of(context).colorScheme.onSurface - : Theme.of(context).colorScheme.onSurface.withAlpha(80); - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(24), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: color.withAlpha(isEnabled ? 100 : 50), - width: 1, - ), - ), - child: Icon(icon, size: 24, color: color), - ), - ), + return IconButton( + onPressed: onTap, + icon: Icon(icon, size: 24), ); } } diff --git a/lib/features/practice/data/datasource/bookmark_remote_datasource.dart b/lib/features/practice/data/datasource/bookmark_remote_datasource.dart index 9afff76d..4ae3592a 100644 --- a/lib/features/practice/data/datasource/bookmark_remote_datasource.dart +++ b/lib/features/practice/data/datasource/bookmark_remote_datasource.dart @@ -104,6 +104,30 @@ class BookmarkRemoteDatasource { return all; } + /// GET /users/me/bookmarks/exists + /// + /// [sourceId] – entity id (text, segment, timer, accumulator, series, …) + /// [type] – bookmark kind; pass when known so the check is unambiguous + Future checkBookmarkExists({ + required String sourceId, + BookmarkType? type, + }) async { + final response = await dio.get( + '/users/me/bookmarks/exists', + queryParameters: { + 'source_id': sourceId, + if (type != null) 'type': type.value, + }, + ); + final data = response.data; + if (data is! Map) { + throw const FormatException( + 'Unexpected /users/me/bookmarks/exists payload type', + ); + } + return BookmarkExistsResult.fromJson(data); + } + /// DELETE /users/me/bookmarks/{bookmarkId} Future deleteBookmark(String bookmarkId) async { await dio.delete('/users/me/bookmarks/$bookmarkId'); diff --git a/lib/features/practice/data/models/bookmark_models.dart b/lib/features/practice/data/models/bookmark_models.dart index 5391b655..1eccdb97 100644 --- a/lib/features/practice/data/models/bookmark_models.dart +++ b/lib/features/practice/data/models/bookmark_models.dart @@ -1,5 +1,20 @@ import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; +/// Response from `GET /users/me/bookmarks/exists`. +class BookmarkExistsResult { + final bool exists; + final String? id; + + const BookmarkExistsResult({required this.exists, this.id}); + + factory BookmarkExistsResult.fromJson(Map json) { + return BookmarkExistsResult( + exists: json['exists'] as bool? ?? false, + id: json['id'] as String?, + ); + } +} + /// Models for `GET /users/me/bookmarks`. /// /// Each row embeds a bookmark-specific object for its type (`text` / `plan` / diff --git a/lib/features/practice/data/models/routine_api_models.dart b/lib/features/practice/data/models/routine_api_models.dart index dd03af87..875e3963 100644 --- a/lib/features/practice/data/models/routine_api_models.dart +++ b/lib/features/practice/data/models/routine_api_models.dart @@ -44,7 +44,10 @@ class SessionRequest { Map toJson() => { 'session_type': sessionType.toJson(), - if (sessionType != SessionType.timer) 'source_id': sourceId, + if (sessionType == SessionType.accumulator) + 'accumulator_id': sourceId + else if (sessionType != SessionType.timer) + 'source_id': sourceId, 'display_order': displayOrder, if (durationMs != null) 'duration_ms': durationMs, }; @@ -119,7 +122,7 @@ class SessionDTO { return SessionDTO( id: json['id'] as String, sessionType: sessionType, - sourceId: (json['source_id'] as String?) ?? json['id'] as String, + sourceId: _sourceIdFromJson(json, sessionType), title: sessionType == SessionType.timer ? ((json['title'] as String?) ?? fallbackTimerTitle) @@ -140,6 +143,27 @@ class SessionDTO { currentPlanTitle: json['current_plan_title'] as String?, ); } + + /// Preset/content id used when re-syncing this session to the API. + /// + /// Accumulator sessions expose [accumulator_id] (preset id) rather than + /// [source_id]. Falling back to the session [id] would break PUT updates. + static String _sourceIdFromJson( + Map json, + SessionType sessionType, + ) { + if (sessionType == SessionType.accumulator) { + final accumulatorId = json['accumulator_id'] as String?; + if (accumulatorId != null && accumulatorId.isNotEmpty) { + return accumulatorId; + } + } + + final sourceId = json['source_id'] as String?; + if (sourceId != null && sourceId.isNotEmpty) return sourceId; + + return json['id'] as String; + } } class TimeBlockDTO { diff --git a/lib/features/practice/data/models/session_selection.dart b/lib/features/practice/data/models/session_selection.dart index 431f3b1e..862924cc 100644 --- a/lib/features/practice/data/models/session_selection.dart +++ b/lib/features/practice/data/models/session_selection.dart @@ -1,4 +1,5 @@ import 'package:flutter_pecha/features/home/domain/entities/series.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; import 'package:flutter_pecha/features/plans/domain/entities/plan.dart'; import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; @@ -46,3 +47,10 @@ class TimerSessionSelection extends SessionSelection { const TimerSessionSelection(this.timer); } + +/// Represents a mantra/accumulator selection from the Malas tab. +class MantraSessionSelection extends SessionSelection { + final Mantra mantra; + + const MantraSessionSelection(this.mantra); +} diff --git a/lib/features/practice/data/repositories/bookmark_repository.dart b/lib/features/practice/data/repositories/bookmark_repository.dart index 24f6deba..170b6a54 100644 --- a/lib/features/practice/data/repositories/bookmark_repository.dart +++ b/lib/features/practice/data/repositories/bookmark_repository.dart @@ -37,6 +37,23 @@ class BookmarkRepository { } } + Future> checkBookmarkExists({ + required String sourceId, + BookmarkType? type, + }) async { + try { + final result = await remoteDatasource.checkBookmarkExists( + sourceId: sourceId, + type: type, + ); + return Right(result); + } catch (e) { + return Left( + ExceptionMapper.map(e, context: 'Failed to check bookmark status'), + ); + } + } + Future> deleteBookmark(String bookmarkId) async { try { await remoteDatasource.deleteBookmark(bookmarkId); diff --git a/lib/features/practice/presentation/controllers/bookmark_controller.dart b/lib/features/practice/presentation/controllers/bookmark_controller.dart index baa30c4b..5bdb7995 100644 --- a/lib/features/practice/presentation/controllers/bookmark_controller.dart +++ b/lib/features/practice/presentation/controllers/bookmark_controller.dart @@ -3,13 +3,15 @@ import 'package:flutter_pecha/core/utils/app_logger.dart'; import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/auth/presentation/widgets/login_drawer.dart'; import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/data/models/bookmark_models.dart'; +import 'package:flutter_pecha/features/practice/data/repositories/bookmark_repository.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Controller for creating bookmarks. +/// Controller for bookmark create/remove (toggle) operations. /// -/// Mirrors the [RecitationSaveController] pattern: -/// guest → show login drawer, authenticated → POST and show feedback. +/// Uses the pre-warmed exists cache for a single API call per tap, with +/// optimistic UI that reverts on failure. class BookmarkController { final _logger = AppLogger('BookmarkController'); final WidgetRef ref; @@ -17,41 +19,33 @@ class BookmarkController { BookmarkController({required this.ref, required this.context}); - /// Create a TEXT bookmark for a full text (used from the reader "more" sheet). - Future bookmarkText(String textId) => - _createBookmark(type: BookmarkType.text, sourceId: textId); + Future toggleText(String textId) => + toggle(type: BookmarkType.text, sourceId: textId); - /// Create a VERSE bookmark for a selected segment. - Future bookmarkVerse(String segmentId) => - _createBookmark(type: BookmarkType.verse, sourceId: segmentId); + Future toggleVerse(String segmentId) => + toggle(type: BookmarkType.verse, sourceId: segmentId); - /// Create a TIMER bookmark for a preset timer. - Future bookmarkTimer(String timerId) => - _createBookmark(type: BookmarkType.timer, sourceId: timerId); + Future toggleTimer(String timerId) => + toggle(type: BookmarkType.timer, sourceId: timerId); - /// Create an ACCUMULATOR bookmark for a preset mala/mantra. - /// - /// [name] is the localized mantra title, stored so the bookmarks list can - /// label the entry without a follow-up lookup. - Future bookmarkMala(String accumulatorId, {String? name}) => - _createBookmark( + Future toggleMala(String accumulatorId, {String? name}) => toggle( type: BookmarkType.accumulator, sourceId: accumulatorId, name: name, ); - /// Create a SERIES bookmark. - /// - /// [name] is the series title, stored so the bookmarks list can label the - /// entry without a follow-up lookup. - Future bookmarkSeries(String seriesId, {String? name}) => - _createBookmark( + Future toggleSeries(String seriesId, {String? name}) => toggle( type: BookmarkType.series, sourceId: seriesId, name: name, ); - Future _createBookmark({ + /// Optimistically toggles bookmark state, then POST or DELETE (one call). + /// + /// Returns `false` when the guest login gate blocked the action (the login + /// drawer is already visible). Callers that dismiss a modal after bookmarking + /// must skip [Navigator.pop] in that case so they do not pop the drawer. + Future toggle({ required BookmarkType type, required String sourceId, String? name, @@ -59,26 +53,86 @@ class BookmarkController { final authState = ref.read(authProvider); if (authState.isGuest) { LoginDrawer.show(context, ref); - return; + return false; } + final target = BookmarkTarget(type: type, sourceId: sourceId); + final repository = ref.read(bookmarkRepositoryProvider); + final cache = ref.read(bookmarkExistsCacheProvider.notifier); + final previous = readBookmarkStatus(ref, target); + final wasBookmarked = previous.exists; + + cache.set( + target, + BookmarkExistsResult( + exists: !wasBookmarked, + id: wasBookmarked ? null : previous.id, + ), + ); + try { - final result = await ref.read(bookmarkRepositoryProvider).createBookmark( - type: type, - sourceId: sourceId, - name: name, - ); - result.fold( - (failure) => throw Exception(failure.message), - (_) => _showSuccessSnackBar(), - ); + if (wasBookmarked) { + final bookmarkId = await _resolveBookmarkId( + repository: repository, + target: target, + cachedId: previous.id, + ); + final deleteResult = await repository.deleteBookmark(bookmarkId); + deleteResult.fold( + (failure) => throw Exception(failure.message), + (_) {}, + ); + cache.set(target, const BookmarkExistsResult(exists: false)); + _showRemovedSnackBar(); + } else { + final createResult = await repository.createBookmark( + type: type, + sourceId: sourceId, + name: name, + ); + createResult.fold( + (failure) => throw Exception(failure.message), + (_) {}, + ); + cache.set(target, const BookmarkExistsResult(exists: true)); + _showSavedSnackBar(); + } + + ref.invalidate(bookmarksProvider); } catch (e, st) { - _logger.error('Error creating bookmark', e, st); - _showErrorSnackBar(); + _logger.error('Error toggling bookmark', e, st); + cache.set(target, previous); + _showErrorSnackBar(wasBookmarked); } + + return true; + } + + /// Uses cached id when available; falls back to exists check only if needed. + Future _resolveBookmarkId({ + required BookmarkRepository repository, + required BookmarkTarget target, + required String? cachedId, + }) async { + if (cachedId != null && cachedId.isNotEmpty) return cachedId; + + final existsResult = await repository.checkBookmarkExists( + sourceId: target.sourceId, + type: target.type, + ); + return existsResult.fold( + (failure) => throw Exception(failure.message), + (exists) { + final id = exists.id; + if (!exists.exists || id == null || id.isEmpty) { + throw Exception('Bookmark exists but id is missing'); + } + return id; + }, + ); } - void _showSuccessSnackBar() { + void _showSavedSnackBar() { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -88,13 +142,25 @@ class BookmarkController { ); } - void _showErrorSnackBar() { + void _showRemovedSnackBar() { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Failed to save bookmark'), + content: Text('Bookmark removed'), duration: Duration(seconds: 2), ), ); } + + void _showErrorSnackBar(bool wasBookmarked) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Failed to ${wasBookmarked ? 'remove' : 'save'} bookmark', + ), + duration: const Duration(seconds: 2), + ), + ); + } } diff --git a/lib/features/practice/presentation/providers/bookmark_providers.dart b/lib/features/practice/presentation/providers/bookmark_providers.dart index fb0800a6..b60b275f 100644 --- a/lib/features/practice/presentation/providers/bookmark_providers.dart +++ b/lib/features/practice/presentation/providers/bookmark_providers.dart @@ -1,6 +1,8 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_pecha/core/config/locale/locale_notifier.dart'; import 'package:flutter_pecha/core/di/core_providers.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/data/models/bookmark_models.dart'; import 'package:flutter_pecha/features/practice/data/repositories/bookmark_repository.dart'; @@ -14,6 +16,225 @@ final bookmarkRepositoryProvider = Provider((ref) { ); }); +/// Identifies a bookmarkable entity for exists checks and cache invalidation. +@immutable +class BookmarkTarget { + final BookmarkType type; + final String sourceId; + + const BookmarkTarget({required this.type, required this.sourceId}); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BookmarkTarget && type == other.type && sourceId == other.sourceId; + + @override + int get hashCode => Object.hash(type, sourceId); +} + +/// Maps list-item types to create/check types. [BookmarkItemType.plan] has no +/// in-app toggle entry point. +BookmarkType? bookmarkTypeFromItem(BookmarkItemType type) => switch (type) { + BookmarkItemType.text => BookmarkType.text, + BookmarkItemType.verse => BookmarkType.verse, + BookmarkItemType.series => BookmarkType.series, + BookmarkItemType.accumulator => BookmarkType.accumulator, + BookmarkItemType.timer => BookmarkType.timer, + BookmarkItemType.plan => null, +}; + +/// Finds a bookmark row matching [target] in an already-loaded list. +BookmarkDTO? findBookmarkInList( + List bookmarks, + BookmarkTarget target, +) { + for (final bookmark in bookmarks) { + if (bookmarkTypeFromItem(bookmark.type) == target.type && + bookmark.sourceId == target.sourceId) { + return bookmark; + } + } + return null; +} + +/// In-memory exists cache so UI can render instantly after the first lookup. +class BookmarkExistsCacheNotifier + extends StateNotifier> { + BookmarkExistsCacheNotifier() : super(const {}); + + void set(BookmarkTarget target, BookmarkExistsResult result) { + state = {...state, target: result}; + } + + void clear(BookmarkTarget target) { + if (!state.containsKey(target)) return; + final next = Map.from(state); + next.remove(target); + state = next; + } +} + +final bookmarkExistsCacheProvider = StateNotifierProvider< + BookmarkExistsCacheNotifier, + Map>((ref) { + return BookmarkExistsCacheNotifier(); +}); + +/// Seeds the exists cache from a loaded bookmarks list (positive hits only). +void applyBookmarkListCache( + BookmarkExistsCacheNotifier notifier, + List bookmarks, + BookmarkTarget target, { + required bool alreadyCached, +}) { + if (alreadyCached) return; + + final hit = findBookmarkInList(bookmarks, target); + if (hit != null) { + notifier.set(target, BookmarkExistsResult(exists: true, id: hit.id)); + } +} + +void warmBookmarkExistsCacheFromList(Ref ref, BookmarkTarget target) { + if (ref.read(bookmarkExistsCacheProvider).containsKey(target)) return; + + // Only reuse the list when it is already loaded — never read [bookmarksProvider] + // to spawn a full paginated fetch just for a exists warm-up. + if (!ref.exists(bookmarksProvider)) return; + + final listState = ref.read(bookmarksProvider); + if (listState.isLoading) return; + + applyBookmarkListCache( + ref.read(bookmarkExistsCacheProvider.notifier), + listState.bookmarks, + target, + alreadyCached: false, + ); +} + +/// Whether [target] is bookmarked for the signed-in user. +/// +/// Checks the in-memory cache first, then the loaded bookmarks list, then +/// `GET /users/me/bookmarks/exists`. Guests resolve to `exists: false`. +final bookmarkExistsProvider = FutureProvider.autoDispose + .family((ref, target) async { + final auth = ref.watch(authProvider); + if (auth.isGuest || !auth.isLoggedIn) { + return const BookmarkExistsResult(exists: false); + } + + final cached = ref.read(bookmarkExistsCacheProvider)[target]; + if (cached != null) return cached; + + warmBookmarkExistsCacheFromList(ref, target); + final fromList = ref.read(bookmarkExistsCacheProvider)[target]; + if (fromList != null) return fromList; + + final result = await ref + .read(bookmarkRepositoryProvider) + .checkBookmarkExists( + sourceId: target.sourceId, + type: target.type, + ); + return result.fold( + (failure) => throw Exception(failure.message), + (exists) { + ref.read(bookmarkExistsCacheProvider.notifier).set(target, exists); + ref.keepAlive(); + return exists; + }, + ); + }); + +/// Starts loading bookmark exists state early so sheets open with the right icon. +/// +/// Uses [ref.watch] (not listen) so [bookmarkExistsProvider] actually runs while +/// the host screen is mounted. +final prefetchBookmarkExistsProvider = + Provider.autoDispose.family((ref, target) { + final auth = ref.watch(authProvider); + if (auth.isGuest || !auth.isLoggedIn) return; + + warmBookmarkExistsCacheFromList(ref, target); + + // If the bookmarks list is already in memory, re-warm when it finishes loading. + if (ref.exists(bookmarksProvider)) { + ref.listen(bookmarksProvider, (_, next) { + if (next.isLoading) return; + applyBookmarkListCache( + ref.read(bookmarkExistsCacheProvider.notifier), + next.bookmarks, + target, + alreadyCached: ref.read(bookmarkExistsCacheProvider).containsKey(target), + ); + }, fireImmediately: true); + } + + ref.watch(bookmarkExistsProvider(target)); + }); + +/// Synchronous bookmark-filled state for UI — prefers cache over async loading. +final isBookmarkedProvider = Provider.autoDispose.family( + (ref, target) { + ref.watch(prefetchBookmarkExistsProvider(target)); + + final cached = ref.watch(bookmarkExistsCacheProvider)[target]; + if (cached != null) return cached.exists; + + return ref.watch(bookmarkExistsProvider(target)).maybeWhen( + data: (result) => result.exists, + orElse: () => false, + ); + }, +); + +/// Reads the best-known bookmark status synchronously (cache → list → async data). +BookmarkExistsResult readBookmarkStatus(WidgetRef ref, BookmarkTarget target) { + final auth = ref.read(authProvider); + if (auth.isGuest || !auth.isLoggedIn) { + return const BookmarkExistsResult(exists: false); + } + + if (!ref.read(bookmarkExistsCacheProvider).containsKey(target)) { + if (ref.exists(bookmarksProvider)) { + final listState = ref.read(bookmarksProvider); + if (!listState.isLoading) { + applyBookmarkListCache( + ref.read(bookmarkExistsCacheProvider.notifier), + listState.bookmarks, + target, + alreadyCached: false, + ); + } + } + } + + final cached = ref.read(bookmarkExistsCacheProvider)[target]; + if (cached != null) return cached; + + return ref.read(bookmarkExistsProvider(target)).maybeWhen( + data: (result) => result, + orElse: () => const BookmarkExistsResult(exists: false), + ); +} + +/// Refreshes the bookmarks list after a mutation. Pass [exists] to commit cache. +void invalidateBookmarkCaches( + WidgetRef ref, { + BookmarkTarget? target, + BookmarkExistsResult? exists, +}) { + if (target != null && exists != null) { + ref.read(bookmarkExistsCacheProvider.notifier).set(target, exists); + } else if (target != null) { + ref.read(bookmarkExistsCacheProvider.notifier).clear(target); + ref.invalidate(bookmarkExistsProvider(target)); + } + ref.invalidate(bookmarksProvider); +} + /// The tabs shown on the bookmarks screen, in display order. enum BookmarkTab { all, plans, mala, timers, texts } diff --git a/lib/features/practice/presentation/screens/bookmarks_screen.dart b/lib/features/practice/presentation/screens/bookmarks_screen.dart index 606d2e80..a895108e 100644 --- a/lib/features/practice/presentation/screens/bookmarks_screen.dart +++ b/lib/features/practice/presentation/screens/bookmarks_screen.dart @@ -54,6 +54,15 @@ class _BookmarksScreenState extends ConsumerState final messenger = ScaffoldMessenger.of(context); final ok = await ref.read(bookmarksProvider.notifier).remove(bookmark); if (!mounted) return; + if (ok) { + final type = bookmarkTypeFromItem(bookmark.type); + if (type != null) { + ref.read(bookmarkExistsCacheProvider.notifier).set( + BookmarkTarget(type: type, sourceId: bookmark.sourceId), + const BookmarkExistsResult(exists: false), + ); + } + } messenger.showSnackBar( SnackBar( content: Text(ok ? 'Bookmark removed' : 'Failed to remove bookmark'), diff --git a/lib/features/practice/presentation/screens/edit_routine_screen.dart b/lib/features/practice/presentation/screens/edit_routine_screen.dart index 70c45b27..974d2855 100644 --- a/lib/features/practice/presentation/screens/edit_routine_screen.dart +++ b/lib/features/practice/presentation/screens/edit_routine_screen.dart @@ -32,6 +32,7 @@ import 'package:flutter_pecha/features/practice/presentation/providers/routine_a import 'package:flutter_pecha/features/practice/presentation/providers/routine_provider.dart'; import 'package:flutter_pecha/features/practice/presentation/screens/select_session_screen.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/routine_time_block.dart'; +import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -40,6 +41,12 @@ import 'package:uuid/uuid.dart'; const _uuid = Uuid(); final _logger = AppLogger('EditRoutineScreen'); +ResponsiveImage? _accumulatorCoverImage(Mantra mantra) { + final url = mantra.beadImageUrl ?? mantra.mantra?.beadImageUrl; + if (url == null || url.trim().isEmpty) return null; + return ResponsiveImage.uniform(url); +} + class _EditableBlock { String id; String? apiTimeBlockId; @@ -105,9 +112,6 @@ class _EditRoutineScreenState extends ConsumerState { /// Sequential queue so API calls never overlap or race. Future _opQueue = Future.value(); - bool get _isLastBlockEmpty => - _blocks.isNotEmpty && _blocks.last.items.isEmpty; - bool get _hasEmptyBlocks => _blocks.any((b) => b.items.isEmpty); @override @@ -219,7 +223,7 @@ class _EditRoutineScreenState extends ConsumerState { } RoutineItem _routineItemFromTimer(PresetTimer timer) => RoutineItem( - id: timer.id, + id: _uuid.v4(), title: '${timer.displayMinutes} min session', type: RoutineItemType.timer, durationMs: timer.durationMs, @@ -238,8 +242,8 @@ class _EditRoutineScreenState extends ConsumerState { } /// Adds the preset mala/accumulator into the routine as an ACCUMULATOR - /// session (source_id = preset id). Like series, a mala may live in multiple - /// time blocks, so the duplicate guard is scoped to the target block only. + /// session (accumulator_id = preset id). Like series, a mala may live in + /// multiple time blocks, so the duplicate guard is scoped to the target block only. _EditableBlock? _injectInitialAccumulator(Mantra mantra) { final resolved = _resolveInjectionTarget(); @@ -255,6 +259,7 @@ class _EditRoutineScreenState extends ConsumerState { RoutineItem( id: mantra.presetId, title: mantra.displayTitle(language), + coverImage: _accumulatorCoverImage(mantra), type: RoutineItemType.accumulator, enrolledAt: DateTime.now(), ), @@ -980,13 +985,15 @@ class _EditRoutineScreenState extends ConsumerState { } bool get _isAtMaxBlocks => !canAddBlock(_blocks.length); - bool get _shouldShowAddButton => !_isLastBlockEmpty && !_isAtMaxBlocks; + bool get _shouldShowAddButton => !_hasEmptyBlocks && !_isAtMaxBlocks; int _calculateListItemCount() { return _shouldShowAddButton ? _blocks.length + 1 : _blocks.length; } void _addBlock() { + if (_hasEmptyBlocks) return; + if (_isAtMaxBlocks) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1108,6 +1115,8 @@ class _EditRoutineScreenState extends ConsumerState { await _handleSeriesEnrollmentFromSelection(blockIndex, series); case TimerSessionSelection(:final timer): await _addTimerToBlock(blockIndex, timer); + case MantraSessionSelection(:final mantra): + await _addAccumulatorToBlock(blockIndex, mantra); } } finally { _isSelectingSession = false; @@ -1155,12 +1164,31 @@ class _EditRoutineScreenState extends ConsumerState { int blockIndex, RecitationModel recitation, ) async { + if (blockIndex < 0 || blockIndex >= _blocks.length) return; + final block = _blocks[blockIndex]; + + final duplicateInBlock = block.items.any( + (item) => + item.id == recitation.textId && + item.type == RoutineItemType.recitation, + ); + if (duplicateInBlock) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.duplicateItem), + duration: const Duration(seconds: 2), + ), + ); + } + return; + } + final newItem = RoutineItem( id: recitation.textId, title: recitation.title, type: RoutineItemType.recitation, ); - final block = _blocks[blockIndex]; setState(() => block.items.add(newItem)); try { @@ -1188,6 +1216,47 @@ class _EditRoutineScreenState extends ConsumerState { } } + Future _addAccumulatorToBlock(int blockIndex, Mantra mantra) async { + if (blockIndex < 0 || blockIndex >= _blocks.length) return; + final block = _blocks[blockIndex]; + + final duplicateInBlock = block.items.any( + (item) => + item.id == mantra.presetId && + item.type == RoutineItemType.accumulator, + ); + if (duplicateInBlock) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.duplicateItem), + duration: const Duration(seconds: 2), + ), + ); + } + return; + } + + final language = ref.read(contentLanguageProvider); + final newItem = RoutineItem( + id: mantra.presetId, + title: mantra.displayTitle(language), + coverImage: _accumulatorCoverImage(mantra), + type: RoutineItemType.accumulator, + enrolledAt: DateTime.now(), + ); + setState(() => block.items.add(newItem)); + + try { + await _syncBlock(block); + } catch (e) { + if (mounted) { + setState(() => block.items.remove(newItem)); + _showErrorSnackBar(_mapError(e)); + } + } + } + /// Enrolls the user in [series] (if not already enrolled) and adds the /// series to the tapped [blockIndex]. /// @@ -1306,9 +1375,7 @@ class _EditRoutineScreenState extends ConsumerState { ); } if (widget.initialTimer != null) { - injectedTimerBlock = _injectInitialTimer( - widget.initialTimer!, - ); + injectedTimerBlock = _injectInitialTimer(widget.initialTimer!); } if (widget.initialSeries != null) { injectedSeriesBlock = _injectInitialSeries( @@ -1503,12 +1570,19 @@ class _DoneButton extends StatelessWidget { HapticFeedback.lightImpact(); onTap(); }, - child: Text( - label, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: isDark ? AppColors.surfaceVariantDark : AppColors.grey100, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + ), ), ), ), diff --git a/lib/features/practice/presentation/screens/practice_explore_screen.dart b/lib/features/practice/presentation/screens/practice_explore_screen.dart index 14dd7e08..6be1697b 100644 --- a/lib/features/practice/presentation/screens/practice_explore_screen.dart +++ b/lib/features/practice/presentation/screens/practice_explore_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; +import 'package:flutter_pecha/features/auth/presentation/widgets/login_drawer.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_explore_providers.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_accumulations_section.dart'; import 'package:flutter_pecha/features/practice/presentation/widgets/practice_chants_section.dart'; @@ -63,13 +65,11 @@ class _PracticeExploreScreenState extends ConsumerState { icon: Icons.bookmark_border, variant: PracticeActionButtonVariant.outlined, onTap: () { + if (ref.read(authProvider).isGuest) { + LoginDrawer.show(context, ref); + return; + } context.pushNamed('bookmarks'); - // ScaffoldMessenger.of(context).showSnackBar( - // SnackBar( - // content: Text(context.l10n.mala_action_coming_soon), - // duration: const Duration(seconds: 2), - // ), - // ); }, ), ), diff --git a/lib/features/practice/presentation/screens/select_session_screen.dart b/lib/features/practice/presentation/screens/select_session_screen.dart index c9a436a0..d352827b 100644 --- a/lib/features/practice/presentation/screens/select_session_screen.dart +++ b/lib/features/practice/presentation/screens/select_session_screen.dart @@ -1,33 +1,33 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; +import 'package:flutter_pecha/core/config/locale/locale_notifier.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/theme/app_colors.dart'; import 'package:flutter_pecha/core/utils/app_logger.dart'; import 'package:flutter_pecha/core/widgets/responsive_cover_image.dart'; -import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; -import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; import 'package:flutter_pecha/core/widgets/skeletons/skeletons.dart'; +import 'package:flutter_pecha/features/mala/domain/entities/mantra.dart'; +import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/practice/data/models/session_selection.dart'; import 'package:flutter_pecha/features/practice/domain/entities/practice_item.dart'; import 'package:flutter_pecha/features/practice/domain/entities/practice_items_tab.dart'; import 'package:flutter_pecha/features/practice/presentation/providers/practice_items_paginated_provider.dart'; +import 'package:flutter_pecha/features/recitation/data/models/recitation_model.dart'; import 'package:flutter_pecha/features/recitation/presentation/providers/recitations_providers.dart'; import 'package:flutter_pecha/features/recitation/presentation/widgets/recitation_list_skeleton.dart'; +import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; +import 'package:flutter_pecha/features/timer/presentation/providers/timers_providers.dart'; +import 'package:flutter_pecha/shared/domain/value_objects/responsive_image.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:phosphor_flutter/phosphor_flutter.dart'; +import 'package:flutter_pecha/core/l10n/generated/app_localizations.dart'; final _logger = AppLogger('SelectSessionScreen'); -/// Combined screen for selecting either a Plan, Series, or Recitation to add -/// to the routine. Returns a [SessionSelection] subtype that the caller -/// (`EditRoutineScreen`) dispatches on. -/// -/// Plans/series come from `GET /practice/items` (page-based). Recitations -/// remain on their own list endpoint. Every item is always shown — there is no -/// list-level filtering. Per-timeblock duplicate rules (a plan can't be added -/// twice to the same block; a series can't be added to a block that already -/// holds all of its active plans) are enforced by the caller -/// (`EditRoutineScreen`) when an item is selected. +/// Session picker with four tabs: Plans · Chants · Malas · Timers. +/// Returns a [SessionSelection] subtype that [EditRoutineScreen] dispatches on. class SelectSessionScreen extends ConsumerStatefulWidget { const SelectSessionScreen({super.key}); @@ -38,23 +38,17 @@ class SelectSessionScreen extends ConsumerStatefulWidget { class _SelectSessionScreenState extends ConsumerState with SingleTickerProviderStateMixin { - /// Tab used by the practice picker. Both plans and series live on the same - /// "Add Plan" tab; recitations get their own tab. static const PracticeItemsTab _practiceTab = PracticeItemsTab.all; late TabController _tabController; final ScrollController _plansScrollController = ScrollController(); - /// ID of the item currently being enrolled/saved (null if idle). - /// Reserved for future inline-loading affordances; today's flow pops - /// immediately, so this stays null in normal use. final String? _enrollingItemId = null; @override void initState() { super.initState(); - _logger.debug('🚀 initState() called'); - _tabController = TabController(length: 2, vsync: this); + _tabController = TabController(length: 4, vsync: this); _plansScrollController.addListener(_onPlansScroll); } @@ -84,14 +78,24 @@ class _SelectSessionScreenState extends ConsumerState Navigator.of(context).pop(selection); } - Future _onRecitationSelected(dynamic recitation) async { + void _onRecitationSelected(RecitationModel recitation) { if (_enrollingItemId != null) return; - Navigator.of(context).pop(RecitationSessionSelection(recitation)); + Navigator.of(context).pop( + RecitationSessionSelection(recitation), + ); + } + + void _onMantraSelected(Mantra mantra) { + Navigator.of(context).pop(MantraSessionSelection(mantra)); + } + + void _onTimerSelected(PresetTimer timer) { + Navigator.of(context).pop(TimerSessionSelection(timer)); } @override Widget build(BuildContext context) { - _logger.debug('🎨 ===== BUILD STARTED ====='); + final isDark = Theme.of(context).brightness == Brightness.dark; final localizations = AppLocalizations.of(context)!; return Scaffold( @@ -102,65 +106,71 @@ class _SelectSessionScreenState extends ConsumerState ), title: Text( localizations.routine_add_session, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), ), scrolledUnderElevation: 0, centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.search, size: 22), + onPressed: () {}, + ), + ], bottom: TabBar( controller: _tabController, tabs: [ - Tab(text: localizations.routine_add_plan), - Tab(text: localizations.routine_add_recitation), + Tab(text: localizations.home_plans), + Tab(text: localizations.home_chants), + Tab(text: localizations.home_mala), + Tab(text: localizations.home_timer), ], labelStyle: const TextStyle( fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 14, ), unselectedLabelStyle: const TextStyle( fontWeight: FontWeight.normal, - fontSize: 16, + fontSize: 14, ), - labelColor: - Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - unselectedLabelColor: - Theme.of(context).brightness == Brightness.dark - ? Colors.white.withValues(alpha: 0.5) - : Colors.black.withValues(alpha: 0.5), + labelColor: isDark ? AppColors.textPrimaryDark : AppColors.textPrimary, + unselectedLabelColor: isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + indicatorColor: Colors.blue, + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, ), ), body: TabBarView( controller: _tabController, children: [ - _PracticeItemsTab( + _PlansTab( tab: _practiceTab, scrollController: _plansScrollController, enrollingItemId: _enrollingItemId, onItemSelected: _onPracticeItemSelected, ), - _RecitationsTab( + _ChantsTab( enrollingItemId: _enrollingItemId, onRecitationSelected: _onRecitationSelected, ), + _MalasTab(onMantraSelected: _onMantraSelected), + _TimersTab(onTimerSelected: _onTimerSelected), ], ), ); } } -/// Tab content for the practice picker (plans + series). -/// -/// No list-level filtering: every plan and series the API returns is shown. -/// Whether an item can actually be added to the chosen timeblock is decided by -/// the caller (`EditRoutineScreen`) on selection. -class _PracticeItemsTab extends ConsumerWidget { +// ─── Plans tab ─────────────────────────────────────────────────────────────── + +class _PlansTab extends ConsumerWidget { final PracticeItemsTab tab; final ScrollController scrollController; final String? enrollingItemId; final void Function(PracticeItem item) onItemSelected; - const _PracticeItemsTab({ + const _PlansTab({ required this.tab, required this.scrollController, required this.enrollingItemId, @@ -169,11 +179,13 @@ class _PracticeItemsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final localizations = AppLocalizations.of(context)!; final itemsState = ref.watch(practiceItemsPaginatedProvider(tab)); + Future onRefresh() => + ref.read(practiceItemsPaginatedProvider(tab).notifier).refresh(); + _logger.debug( - '📋 _PracticeItemsTab BUILD: ${itemsState.items.length} items, ' + '📋 _PlansTab BUILD: ${itemsState.items.length} items, ' 'isLoading: ${itemsState.isLoading}, error: ${itemsState.error}', ); @@ -182,255 +194,605 @@ class _PracticeItemsTab extends ConsumerWidget { } if (itemsState.error != null && itemsState.items.isEmpty) { - return ErrorStateWidget( - error: itemsState.error!, - onRetry: - () => - ref.read(practiceItemsPaginatedProvider(tab).notifier).retry(), - customMessage: 'Unable to load plans.\nPlease try again later.', + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: _SessionTabMessage( + message: 'Unable to load plans.\nPlease try again later.', + ), ); } final items = itemsState.items; if (items.isEmpty && !itemsState.isLoading) { - return Center( - child: Text( - localizations.no_plans_found, - style: TextStyle(color: AppColors.textSecondary), - ), + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No plans found'), ); } - return ListView.separated( - controller: scrollController, - padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), - itemCount: items.length + (itemsState.hasMore ? 1 : 0), - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - if (index == items.length) { + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + controller: scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: items.length + (itemsState.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == items.length) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: itemsState.isLoadingMore + ? const CircularProgressIndicator() + : const SizedBox.shrink(), + ), + ); + } + + final item = items[index]; return Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Center( - child: - itemsState.isLoadingMore - ? const CircularProgressIndicator() - : const SizedBox.shrink(), - ), + padding: const EdgeInsets.only(bottom: 8), + child: _buildPlanCard(context, item), ); - } - - final item = items[index]; - return _buildItemTile(item); - }, + }, + ), ); } - Widget _buildItemTile(PracticeItem item) { + Widget _buildPlanCard(BuildContext context, PracticeItem item) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (item) { case PracticePlanItem(:final plan): - final isEnrolling = enrollingItemId == plan.id; - return _SessionListTile( - title: plan.title, - subtitle: null, - coverImage: plan.coverImage, - isLoading: isEnrolling, - isDisabled: enrollingItemId != null, - onTap: () => onItemSelected(item), + final dateRange = _formatDateRange( + plan.startDate, + plan.startDate?.add(Duration(days: plan.totalDays)), ); + return _SessionCard( + isDark: isDark, + onTap: enrollingItemId == null ? () => onItemSelected(item) : null, + child: Row( + children: [ + _CoverImage(image: plan.coverImage, size: 56), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plan.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (dateRange != null) ...[ + const SizedBox(height: 3), + Text( + dateRange, + style: TextStyle( + fontSize: 13, + color: isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + ), + ), + ], + ], + ), + ), + ], + ), + ); + case PracticeSeriesItem(:final series): - final isEnrolling = enrollingItemId == series.id; - return _SessionListTile( - title: series.title, - subtitle: - series.subTitle?.isNotEmpty == true ? series.subTitle : null, - coverImage: series.coverImage, - isLoading: isEnrolling, - isDisabled: enrollingItemId != null, - onTap: () => onItemSelected(item), + final dateRange = _formatDateRange(series.startDate, series.endDate); + final subtitle = series.subTitle?.isNotEmpty == true + ? series.subTitle + : dateRange; + return _SessionCard( + isDark: isDark, + onTap: enrollingItemId == null ? () => onItemSelected(item) : null, + child: Row( + children: [ + _CoverImage(image: series.coverImage, size: 56), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + series.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text( + subtitle, + style: TextStyle( + fontSize: 13, + color: isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), ); } } + + static String? _formatDateRange(DateTime? start, DateTime? end) { + if (start == null) return null; + final fmt = DateFormat('MMM d'); + final startStr = fmt.format(start.toLocal()); + if (end == null) return startStr; + return '$startStr - ${fmt.format(end.toLocal())}'; + } } -/// Tab content for displaying and selecting recitations. -/// Filters out recitations that are already saved or in the routine. -class _RecitationsTab extends ConsumerWidget { +// ─── Chants tab ────────────────────────────────────────────────────────────── + +class _ChantsTab extends ConsumerWidget { final String? enrollingItemId; - final void Function(dynamic recitation) onRecitationSelected; + final void Function(RecitationModel recitation) onRecitationSelected; - const _RecitationsTab({ + const _ChantsTab({ required this.enrollingItemId, required this.onRecitationSelected, }); @override Widget build(BuildContext context, WidgetRef ref) { - final localizations = AppLocalizations.of(context)!; + final isDark = Theme.of(context).brightness == Brightness.dark; final recitationsAsync = ref.watch(recitationsFutureProvider); + Future onRefresh() async { + ref.invalidate(recitationsFutureProvider); + await ref.read(recitationsFutureProvider.future); + } return recitationsAsync.when( loading: () => const RecitationListSkeleton(), - error: - (error, _) => Center( - child: Text( - localizations.recitations_no_content, - style: TextStyle(color: AppColors.textSecondary), - ), - ), - data: (recitationsEither) { - return recitationsEither.fold( - (failure) => Center( - child: Text( - localizations.recitations_no_content, - style: TextStyle(color: AppColors.textSecondary), - ), - ), - (recitations) { - if (recitations.isEmpty) { - return Center( - child: Text( - localizations.recitations_no_content, - style: TextStyle(color: AppColors.textSecondary), - ), - ); - } + error: (_, __) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load chants'), + ), + data: (recitationsEither) => recitationsEither.fold( + (_) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load chants'), + ), + (recitations) { + if (recitations.isEmpty) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No chants found'), + ); + } - return ListView.separated( - padding: const EdgeInsets.symmetric( - horizontal: 20.0, - vertical: 16.0, - ), + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), itemCount: recitations.length, - separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final recitation = recitations[index]; - final isEnrolling = enrollingItemId == recitation.textId; - - return _SessionListTile( - title: recitation.title, - subtitle: null, - imageUrl: AppAssets.recitationCoverDefault, - isLoading: isEnrolling, - isDisabled: enrollingItemId != null, - onTap: () => onRecitationSelected(recitation), + final description = recitation.firstSegment?.content; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: enrollingItemId == null + ? () => onRecitationSelected(recitation) + : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 4, + height: description != null ? null : 44, + constraints: const BoxConstraints(minHeight: 44), + decoration: BoxDecoration( + color: isDark + ? AppColors.textTertiaryDark + : AppColors.grey400, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recitation.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (description != null && + description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + description, + style: TextStyle( + fontSize: 13, + color: isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), ); }, + ), + ); + }, + ), + ); + } +} + +// ─── Malas tab ─────────────────────────────────────────────────────────────── + +class _MalasTab extends ConsumerWidget { + final void Function(Mantra mantra) onMantraSelected; + + const _MalasTab({required this.onMantraSelected}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final language = ref.watch(localeProvider).languageCode; + final catalogueAsync = ref.watch(malaCatalogueProvider); + Future onRefresh() async { + ref.invalidate(malaCatalogueProvider); + await ref.read(malaCatalogueProvider.future); + } + + return catalogueAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load malas'), + ), + data: (either) => either.fold( + (_) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load malas'), + ), + (mantras) { + if (mantras.isEmpty) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No malas found'), ); - }, - ); - }, + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: mantras.length, + itemBuilder: (context, index) { + final mantra = mantras[index]; + final imageUrl = + mantra.beadImageUrl ?? mantra.mantra?.beadImageUrl; + final title = mantra.displayTitle(language); + _logger.debug( + '🪬 Mala[$index] title=$title imageUrl=$imageUrl', + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: () => onMantraSelected(mantra), + child: Row( + children: [ + _CircularImage(imageUrl: imageUrl, size: 52), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + ); + }, + ), ); } } -/// Reusable list tile for session selection (plans, series, recitations). -/// Supports loading and disabled states for enrollment/save feedback. -class _SessionListTile extends StatelessWidget { - final String title; - final String? subtitle; - final ResponsiveImage? coverImage; - final String? imageUrl; - final VoidCallback onTap; - final bool isLoading; - final bool isDisabled; - - const _SessionListTile({ - required this.title, - required this.subtitle, - this.coverImage, - this.imageUrl, - required this.onTap, - this.isLoading = false, - this.isDisabled = false, - }); +// ─── Timers tab ────────────────────────────────────────────────────────────── + +class _TimersTab extends ConsumerWidget { + final void Function(PresetTimer timer) onTimerSelected; + + const _TimersTab({required this.onTimerSelected}); @override - Widget build(BuildContext context) { - return InkWell( - onTap: isDisabled ? null : onTap, - child: AnimatedOpacity( - opacity: isDisabled && !isLoading ? 0.5 : 1.0, - duration: const Duration(milliseconds: 200), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: - coverImage != null && !coverImage!.isEmpty - ? ResponsiveCoverImage( - image: coverImage, - width: 56, - height: 56, - fit: BoxFit.cover, - borderRadius: BorderRadius.circular(8), - ) - : imageUrl?.trim().isNotEmpty == true - ? ResponsiveCoverImage( - image: ResponsiveImage.uniform(imageUrl!), - width: 56, - height: 56, - fit: BoxFit.cover, - borderRadius: BorderRadius.circular(8), - ) - : Container( - width: 56, - height: 56, + Widget build(BuildContext context, WidgetRef ref) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final localizations = AppLocalizations.of(context)!; + final timersAsync = ref.watch(presetTimersFutureProvider); + Future onRefresh() async { + ref.invalidate(presetTimersFutureProvider); + await ref.read(presetTimersFutureProvider.future); + } + + return timersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load timers'), + ), + data: (either) => either.fold( + (_) => _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'Unable to load timers'), + ), + (timers) { + if (timers.isEmpty) { + return _RefreshableScrollBody( + onRefresh: onRefresh, + child: const _SessionTabMessage(message: 'No timers found'), + ); + } + + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: timers.length, + itemBuilder: (context, index) { + final timer = timers[index]; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _SessionCard( + isDark: isDark, + onTap: () => onTimerSelected(timer), + child: Row( + children: [ + Container( + width: 44, + height: 44, decoration: BoxDecoration( - color: AppColors.grey100, + color: isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isDark + ? AppColors.cardBorderDark + : AppColors.grey300, + ), ), child: Icon( - Icons.music_note, - color: AppColors.textSecondary, - size: 24, + PhosphorIconsRegular.timer, + size: 22, + color: isDark + ? AppColors.textTertiaryDark + : AppColors.textSecondary, ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (subtitle != null && subtitle!.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - subtitle!, - style: TextStyle( - fontSize: 14, - color: AppColors.textSecondary, + const SizedBox(width: 12), + Expanded( + child: Text( + '${timer.displayMinutes} ${localizations.timer_min}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ], - ), - ), - if (isLoading) - const Padding( - padding: EdgeInsets.only(left: 12.0), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + ], + ), ), - ), - ], + ); + }, + ), + ); + }, + ), + ); + } +} + +// ─── Shared card wrapper ────────────────────────────────────────────────────── + +/// Makes pull-to-refresh work when tab content does not fill the viewport. +class _RefreshableScrollBody extends StatelessWidget { + const _RefreshableScrollBody({ + required this.onRefresh, + required this.child, + }); + + final Future Function() onRefresh; + final Widget child; + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: onRefresh, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight, + child: child, + ), + ), + ), + ); + } +} + +class _SessionTabMessage extends StatelessWidget { + const _SessionTabMessage({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 48), + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + height: 1.5, + ), + ), + ), + ); + } +} + +class _SessionCard extends StatelessWidget { + final Widget child; + final bool isDark; + final VoidCallback? onTap; + + const _SessionCard({ + required this.child, + required this.isDark, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: isDark ? AppColors.cardBackgroundDark : AppColors.cardBackgroundLight, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? AppColors.cardBorderDark : AppColors.grey100, + ), ), + child: child, ), ), ); } } + +// ─── Shared image widgets ───────────────────────────────────────────────────── + +/// Rounded-rectangle cover image or grey placeholder. +class _CoverImage extends StatelessWidget { + final ResponsiveImage? image; + final double size; + + const _CoverImage({this.image, required this.size}); + + @override + Widget build(BuildContext context) { + if (image != null && !image!.isEmpty) { + return ResponsiveCoverImage( + image: image, + width: size, + height: size, + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(8), + ); + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(8), + ), + ); + } +} + +/// Circular image for malas — uses CachedNetworkImage so bead art renders +/// correctly (same approach as MalaBeads). +class _CircularImage extends StatelessWidget { + final String? imageUrl; + final double size; + + const _CircularImage({this.imageUrl, required this.size}); + + @override + Widget build(BuildContext context) { + if (imageUrl != null && imageUrl!.isNotEmpty) { + return ClipOval( + child: CachedNetworkImage( + imageUrl: imageUrl!, + width: size, + height: size, + fit: BoxFit.cover, + placeholder: (_, __) => _placeholder(), + errorWidget: (_, __, ___) => _placeholder(), + ), + ); + } + return _placeholder(); + } + + Widget _placeholder() => Container( + width: size, + height: size, + decoration: const BoxDecoration( + color: AppColors.grey100, + shape: BoxShape.circle, + ), + ); +} diff --git a/lib/features/practice/presentation/widgets/routine_filled_state.dart b/lib/features/practice/presentation/widgets/routine_filled_state.dart index 65b81f53..fea00f4b 100644 --- a/lib/features/practice/presentation/widgets/routine_filled_state.dart +++ b/lib/features/practice/presentation/widgets/routine_filled_state.dart @@ -373,14 +373,26 @@ class _RoutineBlockSection extends ConsumerWidget { ), const SizedBox(height: 8), for (int i = 0; i < block.items.length; i++) ...[ - _buildItemCard(context, ref, block.items[i]), - if (i < block.items.length - 1) const Divider(height: 1, indent: 80), - ], - if (block.items.isNotEmpty) - const Padding( - padding: EdgeInsets.only(top: 8.0), - child: Divider(height: 1), + Container( + margin: EdgeInsets.only( + bottom: i < block.items.length - 1 ? 8.0 : 0, + ), + decoration: BoxDecoration( + color: isDark + ? AppColors.cardBackgroundDark + : AppColors.cardBackgroundLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark ? AppColors.cardBorderDark : AppColors.grey100, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: _buildItemCard(context, ref, block.items[i]), + ), ), + ], + const SizedBox(height: 8), ], ); } @@ -391,6 +403,7 @@ class _RoutineBlockSection extends ConsumerWidget { coverImage: item.coverImage, type: item.type, planTitle: item.currentPlanTitle, + imageSize: 56, onTap: () => _onItemTap(context, ref, item), onPlanTap: item.currentPlanId != null diff --git a/lib/features/practice/presentation/widgets/routine_item_card.dart b/lib/features/practice/presentation/widgets/routine_item_card.dart index 1690119a..a855f51d 100644 --- a/lib/features/practice/presentation/widgets/routine_item_card.dart +++ b/lib/features/practice/presentation/widgets/routine_item_card.dart @@ -29,6 +29,9 @@ class RoutineItemCard extends StatelessWidget { /// Optional callback for the circular plan navigation button on the right. final VoidCallback? onPlanTap; + /// Size of the cover image/icon placeholder. Defaults to 74. + final double imageSize; + const RoutineItemCard({ super.key, required this.title, @@ -42,6 +45,7 @@ class RoutineItemCard extends StatelessWidget { this.planTitle, this.trailing, this.onPlanTap, + this.imageSize = 74, }); @override @@ -85,15 +89,15 @@ class RoutineItemCard extends StatelessWidget { borderRadius: BorderRadius.circular(10), child: Image.asset( AppAssets.recitationCoverDefault, - width: 74, - height: 74, + width: imageSize, + height: imageSize, fit: BoxFit.cover, ), ) else if (type == RoutineItemType.timer) Container( - width: 74, - height: 74, + width: imageSize, + height: imageSize, decoration: BoxDecoration( color: isDark ? AppColors.surfaceVariantDark @@ -102,29 +106,18 @@ class RoutineItemCard extends StatelessWidget { ), child: Icon( PhosphorIconsRegular.timer, - size: 32, + size: imageSize * 0.45, color: isDark ? AppColors.textTertiaryDark : AppColors.textSecondary, ), ) else if (type == RoutineItemType.accumulator) - Container( - width: 74, - height: 74, - decoration: BoxDecoration( - color: isDark - ? AppColors.surfaceVariantDark - : AppColors.grey100, - shape: BoxShape.circle, - ), - child: Icon( - PhosphorIconsRegular.circlesThree, - size: 32, - color: isDark - ? AppColors.textTertiaryDark - : AppColors.textSecondary, - ), + _AccumulatorCoverImage( + coverImage: coverImage, + imageUrl: imageUrl, + size: imageSize, + isDark: isDark, ) else ResponsiveCoverImage( @@ -133,8 +126,8 @@ class RoutineItemCard extends StatelessWidget { (imageUrl != null && imageUrl!.isNotEmpty ? ResponsiveImage.uniform(imageUrl!) : null), - width: 74, - height: 74, + width: imageSize, + height: imageSize, fit: BoxFit.cover, borderRadius: BorderRadius.circular(10), ), @@ -218,6 +211,54 @@ class RoutineItemCard extends StatelessWidget { } } +class _AccumulatorCoverImage extends StatelessWidget { + const _AccumulatorCoverImage({ + required this.coverImage, + required this.imageUrl, + required this.size, + required this.isDark, + }); + + final ResponsiveImage? coverImage; + final String? imageUrl; + final double size; + final bool isDark; + + @override + Widget build(BuildContext context) { + final image = + coverImage ?? + (imageUrl != null && imageUrl!.isNotEmpty + ? ResponsiveImage.uniform(imageUrl!) + : null); + + if (image != null && !image.isEmpty) { + return ClipOval( + child: ResponsiveCoverImage( + image: image, + width: size, + height: size, + fit: BoxFit.cover, + ), + ); + } + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: isDark ? AppColors.surfaceVariantDark : AppColors.grey100, + shape: BoxShape.circle, + ), + child: Icon( + PhosphorIconsRegular.circlesThree, + size: size * 0.45, + color: isDark ? AppColors.textTertiaryDark : AppColors.textSecondary, + ), + ); + } +} + class _PlanNavigationButton extends StatelessWidget { final VoidCallback onTap; final bool isDark; diff --git a/lib/features/practice/presentation/widgets/routine_time_block.dart b/lib/features/practice/presentation/widgets/routine_time_block.dart index 5d1597de..f674c946 100644 --- a/lib/features/practice/presentation/widgets/routine_time_block.dart +++ b/lib/features/practice/presentation/widgets/routine_time_block.dart @@ -99,25 +99,70 @@ class RoutineTimeBlock extends StatelessWidget { proxyDecorator: (child, index, animation) { return Material( elevation: 2, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(12), child: child, ); }, itemBuilder: (context, i) { final item = items[i]; - return Column( + return Padding( key: ValueKey(item.id), - mainAxisSize: MainAxisSize.min, - children: [ - RoutineItemCard( - title: item.title, - coverImage: item.coverImage, - type: item.type, - onDelete: () => _confirmDeleteItem(context, i), - reorderIndex: i, - ), - const Divider(height: 1, indent: 140), - ], + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Minus button — outside the card + GestureDetector( + onTap: () => _confirmDeleteItem(context, i), + child: Container( + width: 28, + height: 28, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + color: isDark + ? AppColors.surfaceVariantDark + : AppColors.grey100, + shape: BoxShape.circle, + ), + child: Center( + child: Icon( + AppAssets.minus, + size: 14, + color: isDark + ? AppColors.textPrimaryDark + : AppColors.textPrimary, + ), + ), + ), + ), + // White card containing item content + drag handle + Expanded( + child: Container( + decoration: BoxDecoration( + color: isDark + ? AppColors.cardBackgroundDark + : AppColors.cardBackgroundLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark + ? AppColors.cardBorderDark + : AppColors.grey100, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: RoutineItemCard( + title: item.title, + coverImage: item.coverImage, + type: item.type, + reorderIndex: i, + imageSize: 56, + ), + ), + ), + ), + ], + ), ); }, ), @@ -268,7 +313,7 @@ class _AddSessionButton extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.only(left: 54), + padding: const EdgeInsets.only(left: 40), child: Row( children: [ Container( diff --git a/lib/features/reader/presentation/screens/reader_screen.dart b/lib/features/reader/presentation/screens/reader_screen.dart index bdb9cc50..07bbffbe 100644 --- a/lib/features/reader/presentation/screens/reader_screen.dart +++ b/lib/features/reader/presentation/screens/reader_screen.dart @@ -7,7 +7,8 @@ import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigatio import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_navigator.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_segment_audio_controller.dart'; import 'package:flutter_pecha/features/plans/presentation/widgets/plan_navigation/plan_subtask_completion.dart'; -import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/reader/constants/reader_constants.dart'; import 'package:flutter_pecha/features/reader/data/models/navigation_context.dart'; import 'package:flutter_pecha/features/reader/data/models/reader_slot_config.dart'; @@ -188,6 +189,12 @@ class _ReaderScreenState extends ConsumerState @override Widget build(BuildContext context) { + ref.watch( + prefetchBookmarkExistsProvider( + BookmarkTarget(type: BookmarkType.text, sourceId: widget.textId), + ), + ); + final state = ref.watch(readerNotifierProvider(_params)); final notifier = ref.read(readerNotifierProvider(_params).notifier); final readerTheme = _readerTheme(context); @@ -539,22 +546,15 @@ class _ReaderScreenState extends ConsumerState showReaderMoreBottomSheet( context, + textId: widget.textId, showAddToPractices: showAddToPractices, onAddToPractices: showAddToPractices ? () => _openRoutineWithRecitation(context, textDetail) : null, - onBookmark: () => _bookmarkText(context), ); } - /// Bookmarks the current text. Invoked after the "more" sheet has been - /// dismissed, using the reader's own context so the success/login feedback - /// isn't drawn behind the closing modal. - void _bookmarkText(BuildContext context) { - BookmarkController(ref: ref, context: context).bookmarkText(widget.textId); - } - void _openRoutineWithRecitation(BuildContext context, TextDetail textDetail) { context.push( AppRoutes.practiceEditRoutine, diff --git a/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart b/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart index 57c8b30c..6506702f 100644 --- a/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart +++ b/lib/features/reader/presentation/widgets/reader_actions/segement_action_bar.dart @@ -3,7 +3,9 @@ import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/deep_linking/deep_link_url_builder.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/reader/presentation/providers/reader_notifier.dart'; import 'package:flutter_pecha/features/texts/data/models/segment.dart'; import 'package:flutter_pecha/shared/utils/helper_functions.dart'; @@ -62,7 +64,7 @@ class _SegmentActionBarState extends ConsumerState { await BookmarkController( ref: ref, context: context, - ).bookmarkVerse(widget.segment.segmentId); + ).toggleVerse(widget.segment.segmentId); } finally { if (mounted) setState(() => _isBookmarking = false); } @@ -92,6 +94,15 @@ class _SegmentActionBarState extends ConsumerState { return const SizedBox.shrink(); } + final isBookmarked = ref.watch( + isBookmarkedProvider( + BookmarkTarget( + type: BookmarkType.verse, + sourceId: widget.segment.segmentId, + ), + ), + ); + return _ResourcesPanel( onDismiss: widget.onClose, copyButton: _IconActionButton( @@ -109,7 +120,10 @@ class _SegmentActionBarState extends ConsumerState { onClose: widget.onClose, ), bookmarkButton: _IconActionButton( - icon: AppAssets.bookmarkSimple, + icon: + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, label: localizations.bookmark, isLoading: _isBookmarking, onTap: _handleBookmark, diff --git a/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart b/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart index fa17eb24..4ea62773 100644 --- a/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart +++ b/lib/features/reader/presentation/widgets/reader_app_bar/reader_more_bottom_sheet.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/reader/presentation/widgets/reader_app_bar/reader_font_size_bottom_sheet.dart'; import 'package:flutter_pecha/features/texts/presentation/providers/font_size_notifier.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,28 +14,47 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// Contains: /// • Font-size A / A buttons /// • "+ Add to my practices" action -/// • Bookmark action +/// • Bookmark toggle action class ReaderMoreBottomSheet extends ConsumerStatefulWidget { const ReaderMoreBottomSheet({ super.key, + required this.textId, required this.showAddToPractices, this.onAddToPractices, - this.onBookmark, }); + final String textId; final bool showAddToPractices; final VoidCallback? onAddToPractices; - /// Invoked after the sheet is dismissed so any feedback (snackbar / login - /// drawer) is shown on the underlying screen rather than behind the modal. - final VoidCallback? onBookmark; - @override ConsumerState createState() => _ReaderMoreBottomSheetState(); } class _ReaderMoreBottomSheetState extends ConsumerState { + bool _isBookmarking = false; + + BookmarkTarget get _bookmarkTarget => BookmarkTarget( + type: BookmarkType.text, + sourceId: widget.textId, + ); + + Future _toggleBookmark() async { + if (_isBookmarking) return; + setState(() => _isBookmarking = true); + try { + final nav = Navigator.of(context); + final didToggle = await BookmarkController( + ref: ref, + context: context, + ).toggleText(widget.textId); + if (mounted && didToggle) nav.pop(); + } finally { + if (mounted) setState(() => _isBookmarking = false); + } + } + // ── font size helpers ────────────────────────────────────────────────────── int _stepIndex(double fontSize) { @@ -55,6 +78,8 @@ class _ReaderMoreBottomSheetState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; + final isBookmarked = ref.watch(isBookmarkedProvider(_bookmarkTarget)); final fontSize = ref.watch(fontSizeProvider); final stepIndex = _stepIndex(fontSize); @@ -148,15 +173,26 @@ class _ReaderMoreBottomSheetState extends ConsumerState { // ── Bookmark ─────────────────────────────────────────────────── _SectionDivider(theme: theme), ListTile( - leading: Icon( - AppAssets.bookmarkSimple, - color: theme.colorScheme.onSurface, - ), - title: Text('Bookmark', style: theme.textTheme.bodyLarge), + leading: + _isBookmarking + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onSurface, + ), + ) + : Icon( + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, + color: theme.colorScheme.onSurface, + ), + title: Text(l10n.bookmark, style: theme.textTheme.bodyLarge), onTap: () { HapticFeedback.lightImpact(); - Navigator.of(context).pop(); - widget.onBookmark?.call(); + _toggleBookmark(); }, ), @@ -232,9 +268,9 @@ class _FontSizeButton extends StatelessWidget { /// Shows the reader "more" bottom sheet. void showReaderMoreBottomSheet( BuildContext context, { + required String textId, required bool showAddToPractices, VoidCallback? onAddToPractices, - VoidCallback? onBookmark, }) { showModalBottomSheet( context: context, @@ -244,9 +280,9 @@ void showReaderMoreBottomSheet( ), builder: (_) => ReaderMoreBottomSheet( + textId: textId, showAddToPractices: showAddToPractices, onAddToPractices: onAddToPractices, - onBookmark: onBookmark, ), ); } diff --git a/lib/features/timer/presentation/screens/preset_timers_screen.dart b/lib/features/timer/presentation/screens/preset_timers_screen.dart index 5f81a9e1..5a86491e 100644 --- a/lib/features/timer/presentation/screens/preset_timers_screen.dart +++ b/lib/features/timer/presentation/screens/preset_timers_screen.dart @@ -3,6 +3,8 @@ import 'package:flutter_pecha/core/config/router/app_routes.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; import 'package:flutter_pecha/core/extensions/context_ext.dart'; import 'package:flutter_pecha/core/widgets/error_state_widget.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; import 'package:flutter_pecha/features/timer/presentation/providers/timers_providers.dart'; import 'package:flutter_pecha/features/timer/presentation/widgets/preset_timer_card.dart'; @@ -124,13 +126,13 @@ class PresetTimersScreen extends ConsumerWidget { } } -class _PresetTimersGrid extends StatelessWidget { +class _PresetTimersGrid extends ConsumerWidget { const _PresetTimersGrid({required this.timers, required this.minLabel}); final List timers; final String minLabel; - void _openMoreSheet(BuildContext context, PresetTimer timer) { + void _openMoreSheet(BuildContext context, WidgetRef ref, PresetTimer timer) { showTimerMoreBottomSheet( context, timer: timer, @@ -142,7 +144,15 @@ class _PresetTimersGrid extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + for (final timer in timers) { + ref.watch( + prefetchBookmarkExistsProvider( + BookmarkTarget(type: BookmarkType.timer, sourceId: timer.id), + ), + ); + } + return Padding( padding: const EdgeInsets.all(PresetTimersScreen._horizontalPadding), child: GridView.builder( @@ -160,7 +170,7 @@ class _PresetTimersGrid extends StatelessWidget { timer: timer, minLabel: minLabel, onTap: () => context.push('/home/timers/active', extra: timer), - onMoreTap: () => _openMoreSheet(context, timer), + onMoreTap: () => _openMoreSheet(context, ref, timer), ); }, ), diff --git a/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart b/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart index 8efd55ef..47b55954 100644 --- a/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart +++ b/lib/features/timer/presentation/widgets/timer_more_bottom_sheet.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_pecha/core/constants/app_assets.dart'; +import 'package:flutter_pecha/core/extensions/context_ext.dart'; +import 'package:flutter_pecha/features/practice/data/datasource/bookmark_remote_datasource.dart'; import 'package:flutter_pecha/features/practice/presentation/controllers/bookmark_controller.dart'; +import 'package:flutter_pecha/features/practice/presentation/providers/bookmark_providers.dart'; import 'package:flutter_pecha/features/timer/domain/entities/preset_timer.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -9,7 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; /// /// Contains: /// • "+ Add to my practices" action -/// • Bookmark action +/// • Bookmark toggle action class TimerMoreBottomSheet extends ConsumerStatefulWidget { const TimerMoreBottomSheet({ super.key, @@ -28,14 +31,21 @@ class TimerMoreBottomSheet extends ConsumerStatefulWidget { class _TimerMoreBottomSheetState extends ConsumerState { bool _isBookmarking = false; - Future _bookmark() async { + BookmarkTarget get _bookmarkTarget => BookmarkTarget( + type: BookmarkType.timer, + sourceId: widget.timer.id, + ); + + Future _toggleBookmark() async { if (_isBookmarking) return; setState(() => _isBookmarking = true); try { - await BookmarkController( + final nav = Navigator.of(context); + final didToggle = await BookmarkController( ref: ref, context: context, - ).bookmarkTimer(widget.timer.id); + ).toggleTimer(widget.timer.id); + if (mounted && didToggle) nav.pop(); } finally { if (mounted) setState(() => _isBookmarking = false); } @@ -44,6 +54,8 @@ class _TimerMoreBottomSheetState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l10n = context.l10n; + final isBookmarked = ref.watch(isBookmarkedProvider(_bookmarkTarget)); return SafeArea( top: false, @@ -92,15 +104,15 @@ class _TimerMoreBottomSheetState extends ConsumerState { ), ) : Icon( - AppAssets.bookmarkSimple, + isBookmarked + ? AppAssets.bookmarkSimpleFill + : AppAssets.bookmarkSimple, color: theme.colorScheme.onSurface, ), - title: Text('Bookmark', style: theme.textTheme.bodyLarge), - onTap: () async { + title: Text(l10n.bookmark, style: theme.textTheme.bodyLarge), + onTap: () { HapticFeedback.lightImpact(); - final nav = Navigator.of(context); - await _bookmark(); - if (mounted) nav.pop(); + _toggleBookmark(); }, ), diff --git a/lib/main.dart b/lib/main.dart index 266947f5..908dc750 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ import 'package:flutter_pecha/features/notifications/application/notification_sy import 'package:flutter_pecha/features/notifications/data/services/notification_service.dart'; import 'package:flutter_pecha/features/home/data/datasource/home_local_datasource.dart'; import 'package:flutter_pecha/features/home/presentation/providers/use_case_providers.dart'; +import 'package:flutter_pecha/features/auth/presentation/providers/state_providers.dart'; import 'package:flutter_pecha/features/mala/data/datasources/mala_local_datasource.dart'; import 'package:flutter_pecha/features/mala/presentation/providers/mala_providers.dart'; import 'package:flutter_pecha/features/more/data/datasource/user_stats_local_datasource.dart'; @@ -202,6 +203,7 @@ class MyApp extends ConsumerStatefulWidget { class _MyAppState extends ConsumerState with WidgetsBindingObserver { bool _hasRegisteredDeepLinkRouters = false; + bool _hasDrainedInitialAppLink = false; @override void initState() { @@ -238,6 +240,7 @@ class _MyAppState extends ConsumerState with WidgetsBindingObserver { Widget build(BuildContext context) { final locale = ref.watch(localeProvider); final themeMode = ref.watch(themeModeProvider); + final authState = ref.watch(authProvider); // Get the singleton router instance - same instance is reused across rebuilds // final router = AppRouter().router; @@ -253,6 +256,14 @@ class _MyAppState extends ConsumerState with WidgetsBindingObserver { _hasRegisteredDeepLinkRouters = true; } + if (!authState.isLoading && !_hasDrainedInitialAppLink) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + AppLinksDeepLinkService.instance.drainPendingLink(); + _hasDrainedInitialAppLink = true; + }); + } + // Initialize services in background via providers ref.watch(audioHandlerProvider); ref.watch(notificationServiceProvider); diff --git a/pubspec.lock b/pubspec.lock index 852277f7..8feaa84c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1161,10 +1161,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1687,10 +1687,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" tibetan_calendar: dependency: "direct main" description: