diff --git a/.github/workflows/analyze-test.yaml b/.github/workflows/analyze-test.yaml index 199df6f10b..3c0f50d7c9 100644 --- a/.github/workflows/analyze-test.yaml +++ b/.github/workflows/analyze-test.yaml @@ -8,11 +8,15 @@ on: name: Analyze and test +permissions: + contents: read + env: FLUTTER_VERSION: 3.32.8 jobs: analyze-test: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest strategy: matrix: @@ -29,10 +33,24 @@ jobs: fail-fast: false steps: + # ๐Ÿ”„ Checkout repository - name: Checkout repository uses: actions/checkout@v4 - - name: Setup flutter + # ๐Ÿงฐ Setup SSH (required for private git@ dependencies) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts (avoid "Host key verification failed") + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # ๐Ÿš€ Setup Flutter SDK + - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} @@ -41,24 +59,29 @@ jobs: cache-key: "deps-${{ hashFiles('**/pubspec.lock') }}" cache-path: ${{ runner.tool_cache }}/flutter # optional, change this to specify the cache path + # ๐Ÿ”ฅ Setup Firebase environment (if required by tests) - name: Setup Firebase env env: FIREBASE_ENV: ${{ secrets.FIREBASE_ENV }} run: ./scripts/setup-firebase.sh + # ๐Ÿงฑ Prebuild step (runs flutter pub get + build_runner + intl generation) - name: Run prebuild run: ./scripts/prebuild.sh - - name: Analyze - uses: zgosalvez/github-actions-analyze-dart@v1 + # ๐Ÿงฉ Run Flutter static analysis + - name: Analyze Dart code + uses: zgosalvez/github-actions-analyze-dart@d9d6a56518f87b85b5f6ea3664c360e3c361a54f # v1 - - name: Test + # ๐Ÿงช Run tests for each module in matrix + - name: Run tests env: MODULES: ${{ matrix.modules }} run: ./scripts/test.sh + # ๐Ÿ“ค Upload test reports (always, even on failure) - name: Upload test reports - if: success() || failure() # Always upload report + if: always() # Always upload report, including cancelled uses: actions/upload-artifact@v4 with: name: test-reports-${{ matrix.modules }} diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d32d939aba..48654a77d1 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,10 +23,24 @@ jobs: environment: dev steps: + # ๐Ÿงฐ Setup SSH (required because some dependencies use git@ URLs) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts to avoid "Host key verification failed" + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # ๐Ÿ“ฆ Checkout the repository (uses HTTPS by default, SSH key not needed) - name: Checkout repository uses: actions/checkout@v4 - - name: Setup flutter + # ๐Ÿš€ Setup Flutter environment + - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} @@ -35,6 +49,11 @@ jobs: cache-key: deps-${{ hashFiles('**/pubspec.lock') }} # optional, change this to force refresh cache cache-path: ${{ runner.tool_cache }}/flutter # optional, change this to specify the cache path + # ๐Ÿงน Clean Flutter pub cache to avoid stale SSH clones + - name: Clean pub cache + run: flutter pub cache clean || true + + # ๐Ÿ’Ž Setup Fastlane (for both Android and iOS builds) - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: @@ -42,11 +61,13 @@ jobs: bundler-cache: true working-directory: ${{ matrix.os }} + # ๐Ÿ”ฅ Setup Firebase environment variables - name: Setup Firebase env env: FIREBASE_ENV: ${{ secrets.FIREBASE_ENV }} run: ./scripts/setup-firebase.sh + # โ˜•๏ธ Setup Java for Android builds - name: Setup Java if: matrix.os == 'android' uses: actions/setup-java@v4 @@ -54,20 +75,24 @@ jobs: distribution: "temurin" java-version: "17" + # ๐Ÿ Select the required Xcode version for iOS builds - name: Select Xcode version if: matrix.os == 'ios' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ env.XCODE_VERSION }} + # โš™๏ธ Setup iOS environment (Fastlane match, certificates, etc.) - name: Setup iOS environment if: matrix.os == 'ios' run: ../scripts/setup-ios.sh working-directory: ${{ matrix.os }} + # ๐Ÿ› ๏ธ Run prebuild tasks (code generation, assets, etc.) - name: Run prebuild run: ./scripts/prebuild.sh + # ๐Ÿงฑ Build development binaries (Android .apk / iOS .ipa) - name: Build env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} @@ -75,6 +100,7 @@ jobs: run: ../scripts/build-dev.sh working-directory: ${{ matrix.os }} + # ๐Ÿ“ค Upload build artifacts (APK or IPA) - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/gh-pages.yaml b/.github/workflows/gh-pages.yaml index b00085d004..5a084e28ed 100644 --- a/.github/workflows/gh-pages.yaml +++ b/.github/workflows/gh-pages.yaml @@ -3,13 +3,14 @@ on: paths: - "**/*.dart" -name: Deploy PR on Github Pages +name: Deploy PR on GitHub Pages env: FLUTTER_VERSION: 3.32.8 concurrency: group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: deploy: @@ -20,7 +21,7 @@ jobs: url: ${{ steps.configure.outputs.URL }} steps: - # ๐Ÿงน Free up space before building + # ๐Ÿงน Free up disk space before building to avoid "No space left" errors - name: Free up disk space before build run: | echo "=== Disk space before cleanup ===" @@ -33,36 +34,52 @@ jobs: echo "=== Disk space after cleanup ===" df -h - # ๐Ÿ”„ Checkout code + # ๐Ÿ”„ Checkout repository - name: Checkout repository uses: actions/checkout@v4 - # ๐Ÿงฐ Setup Flutter + # ๐Ÿงฐ Setup SSH for private Git dependencies (required for git@github.com) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts to prevent "Host key verification failed" + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # ๐Ÿš€ Setup Flutter SDK - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: "stable" cache: true - cache-key: deps-${{ hashFiles('**/pubspec.lock') }} # optional, change this to force refresh cache - cache-path: ${{ runner.tool_cache }}/flutter # optional, change this to specify the cache path + cache-key: deps-${{ hashFiles('**/pubspec.lock') }} + cache-path: ${{ runner.tool_cache }}/flutter # ๐Ÿงน Clean Flutter cache before building - name: Flutter clean run: flutter clean - # ๐Ÿ“ฆ Run prebuild (if any) + # ๐Ÿงน Optionally clean pub cache to avoid stale SSH clones + - name: Clean pub cache + run: flutter pub cache clean || true + + # ๐Ÿ“ฆ Run prebuild script (if any, e.g. code generation, assets) - name: Run prebuild run: ./scripts/prebuild.sh - # โš™๏ธ Configure environment for PR + # โš™๏ธ Configure web environment for PR deployment - name: Configure environments id: configure env: FOLDER: ${{ github.event.pull_request.number }} run: ./scripts/configure-web-environment.sh - # ๐Ÿงฑ Build Flutter Web (release) + # ๐Ÿงฑ Build Flutter Web (release mode) - name: Build Web (Release) env: FOLDER: ${{ github.event.pull_request.number }} @@ -73,7 +90,7 @@ jobs: echo "=== Disk usage after build ===" df -h - # ๐Ÿš€ Deploy to GitHub Pages + # ๐Ÿš€ Deploy to GitHub Pages (each PR has its own subfolder) - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: @@ -82,7 +99,7 @@ jobs: keep_files: true publish_dir: "build/web" - # ๐Ÿงน Clean up after build to save space + # ๐Ÿงน Cleanup after build to free up disk space - name: Cleanup after deploy if: always() run: | @@ -91,7 +108,7 @@ jobs: echo "=== Disk usage after cleanup ===" df -h - # ๐Ÿ’ฌ Create or update comments on PR + # ๐Ÿ’ฌ Find existing deployment comment on PR (if exists) - name: Find deployment comment uses: peter-evans/find-comment@v3 id: fc @@ -100,6 +117,7 @@ jobs: issue-number: ${{ github.event.pull_request.number }} body-includes: "This PR has been deployed to" + # ๐Ÿ’ฌ Create or update the comment with the PR deployment URL - name: Create or update deployment comment uses: peter-evans/create-or-update-comment@v4 with: diff --git a/.github/workflows/image-sentry.yaml b/.github/workflows/image-sentry.yaml index 5fa20eae72..1d6335e7e7 100644 --- a/.github/workflows/image-sentry.yaml +++ b/.github/workflows/image-sentry.yaml @@ -24,6 +24,18 @@ jobs: with: fetch-depth: 0 + # ๐Ÿงฐ Setup SSH (needed for private git@ dependencies inside Docker build) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts (avoid host verification errors) + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -63,6 +75,7 @@ jobs: context: . file: ./Dockerfile push: true + ssh: default platforms: linux/amd64,linux/arm64 cache-from: | type=gha diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 376461dfb8..6b9b77a57e 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -6,6 +6,7 @@ on: name: Build Docker images jobs: + # ๐Ÿงฉ Build and push the development Docker image (triggered on master branch) build-dev-image: name: Build development image if: github.ref_type == 'branch' && github.ref_name == 'master' @@ -13,9 +14,26 @@ jobs: environment: dev steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ๐Ÿงฐ Setup SSH (needed for private git@ dependencies inside Docker build) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts (avoid host verification errors) + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # โš™๏ธ Setup Docker Buildx (multi-platform builder) - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # ๐Ÿงฉ Generate Docker image metadata (tags, labels) - name: Docker metadata id: meta uses: docker/metadata-action@v5 @@ -26,12 +44,14 @@ jobs: tags: | type=ref,event=branch + # ๐Ÿ” Login to Docker Hub - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # ๐Ÿ” Login to GitHub Container Registry (GHCR) - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -39,10 +59,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # ๐Ÿ—๏ธ Build and push the development image (with SSH forwarding) - name: Build and push image uses: docker/build-push-action@v5 with: push: true + ssh: default # โœ… Forward SSH key into Docker for private git@ dependencies platforms: "linux/amd64,linux/arm64" cache-from: | type=gha @@ -51,6 +73,7 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # ๐Ÿš€ Build and push the production (release) Docker image (triggered on version tags) build-release-image: name: Build release image if: github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') @@ -58,9 +81,26 @@ jobs: environment: prod steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ๐Ÿงฐ Setup SSH (needed for private git@ dependencies inside Docker build) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # โš™๏ธ Setup Docker Buildx - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + # ๐Ÿงฉ Generate Docker image metadata for release tags - name: Docker metadata id: meta uses: docker/metadata-action@v5 @@ -72,12 +112,14 @@ jobs: type=ref,event=tag type=raw,value=release + # ๐Ÿ” Login to Docker Hub - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # ๐Ÿ” Login to GitHub Container Registry (GHCR) - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: @@ -85,10 +127,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # ๐Ÿ—๏ธ Build and push the release image (with SSH forwarding) - name: Build and push image uses: docker/build-push-action@v5 with: push: true + ssh: default # โœ… Enable SSH forwarding during Docker build platforms: "linux/amd64,linux/arm64" cache-from: | type=gha diff --git a/.github/workflows/patrol-integration-test.yaml b/.github/workflows/patrol-integration-test.yaml index e9a89f7440..90aee24dea 100644 --- a/.github/workflows/patrol-integration-test.yaml +++ b/.github/workflows/patrol-integration-test.yaml @@ -17,13 +17,29 @@ jobs: name: Run integration tests for mobile apps runs-on: ubuntu-latest + concurrency: group: ngrok cancel-in-progress: false + steps: + # ๐Ÿ”„ Checkout repository - name: Checkout repository uses: actions/checkout@v4 + # ๐Ÿงฐ Setup SSH (required for private git@ dependencies during prebuild) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts (avoid host verification failures) + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # โ˜๏ธ Authenticate to Google Cloud for emulator or test infra - name: Authenticate to Google Cloud uses: "google-github-actions/auth@v2" with: @@ -31,9 +47,11 @@ jobs: workload_identity_provider: ${{ secrets.GOOGLE_CLOUD_WORKLOAD_IDENTITY_PROVIDER_ID }} service_account: ${{ secrets.GOOGLE_CLOUD_SERVICE_ACCOUNT }} + # โ˜๏ธ Setup Google Cloud SDK - name: Setup Cloud SDK uses: google-github-actions/setup-gcloud@v2 + # ๐Ÿš€ Setup Flutter SDK - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -41,16 +59,19 @@ jobs: channel: "stable" cache: true + # โ˜•๏ธ Setup Java (required for Android build tools) - name: Set up Java uses: actions/setup-java@v4 with: java-version: ${{ env.JAVA_VERSION }} distribution: "temurin" + # ๐Ÿงฑ Prebuild (runs flutter pub get for all modules, build_runner, intl) - name: Run prebuild run: ./scripts/prebuild.sh - - name: Test + # ๐Ÿงช Run Patrol integration tests with Docker + - name: Run Patrol integration tests env: NGROK_AUTHTOKEN: ${{ secrets.NGROK_AUTHTOKEN }} run: ./scripts/patrol-integration-test-with-docker.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 65ec1bb0a2..1e0fca270f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,7 +29,20 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Setup flutter + # ๐Ÿงฐ Setup SSH (required for private git@ dependencies during prebuild) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 + with: + ssh-private-key: ${{ secrets.SSH_KEY }} + + # โš™๏ธ Add GitHub to known hosts (avoid "Host key verification failed") + - name: Add GitHub to known hosts + run: | + mkdir -p ~/.ssh + ssh-keyscan github.com >> ~/.ssh/known_hosts + + # ๐Ÿš€ Setup Flutter SDK + - name: Setup Flutter uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} @@ -38,11 +51,13 @@ jobs: cache-key: deps-${{ hashFiles('**/pubspec.lock') }} # optional, change this to force refresh cache cache-path: ${{ runner.tool_cache }}/flutter # optional, change this to specify the cache path + # ๐Ÿ”ฅ Setup Firebase environment (required by setup-firebase.sh) - name: Setup Firebase env env: FIREBASE_ENV: ${{ secrets.FIREBASE_ENV }} run: ./scripts/setup-firebase.sh + # ๐Ÿ’Ž Setup Fastlane (used for release and deployment) - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: @@ -50,6 +65,7 @@ jobs: bundler-cache: true working-directory: ${{ matrix.os }} + # โ˜•๏ธ Setup Java (only for Android) - name: Setup Java if: matrix.os == 'android' uses: actions/setup-java@v4 @@ -57,12 +73,14 @@ jobs: distribution: "temurin" java-version: "17" + # ๐Ÿ Select Xcode version (only for iOS) - name: Select Xcode version if: matrix.os == 'ios' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ env.XCODE_VERSION }} + # ๐Ÿค– Setup Android environment (Play Store credentials) - name: Setup Android environment if: matrix.os == 'android' env: @@ -71,14 +89,17 @@ jobs: run: ../scripts/setup-android.sh working-directory: ${{ matrix.os }} + # ๐ŸŽ Setup iOS environment (certificates and provisioning) - name: Setup iOS environment if: matrix.os == 'ios' run: ../scripts/setup-ios.sh working-directory: ${{ matrix.os }} + # ๐Ÿงฑ Prebuild step (fetch deps, run build_runner, intl generation) - name: Run prebuild run: ./scripts/prebuild.sh + # ๐Ÿš€ Build and deploy release (App Store / Play Store) - name: Build and deploy env: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} diff --git a/Dockerfile b/Dockerfile index f4be0e43a5..9b1c7e3cc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,16 @@ ENV GITHUB_SHA=$GITHUB_SHA \ SENTRY_URL=$SENTRY_URL \ SENTRY_RELEASE=$SENTRY_RELEASE +# Fetch pub dependencies for all modules defined in prebuild.sh +# The SSH mount allows access to private repos during flutter pub get +RUN --mount=type=ssh \ + mkdir -p /root/.ssh && \ + ssh-keyscan github.com >> /root/.ssh/known_hosts && \ + for mod in core model contact forward rule_filter fcm email_recovery server_settings cozy scribe labels; do \ + cd /app/$mod && flutter pub get; \ + done && \ + cd /app && flutter pub get \ + RUN ./scripts/prebuild.sh && \ flutter build web --release --source-maps --dart-define=SENTRY_RELEASE=$SENTRY_RELEASE && \ if [ -n "$SENTRY_AUTH_TOKEN" ] && [ -n "$SENTRY_ORG" ] && [ -n "$SENTRY_PROJECT" ] && [ -n "$SENTRY_RELEASE" ]; then \ diff --git a/assets/fonts/fallback/NotoColorEmoji-Regular.ttf b/assets/fonts/fallback/NotoColorEmoji-Regular.ttf new file mode 100644 index 0000000000..05b42fdc78 Binary files /dev/null and b/assets/fonts/fallback/NotoColorEmoji-Regular.ttf differ diff --git a/assets/images/ic_emoji.svg b/assets/images/ic_emoji.svg new file mode 100644 index 0000000000..440e5c5f89 --- /dev/null +++ b/assets/images/ic_emoji.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/ic_search_emoji_empty.svg b/assets/images/ic_search_emoji_empty.svg new file mode 100644 index 0000000000..6693062404 --- /dev/null +++ b/assets/images/ic_search_emoji_empty.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/lib/presentation/constants/constants_ui.dart b/core/lib/presentation/constants/constants_ui.dart index 7eae78c6c0..27707879b5 100644 --- a/core/lib/presentation/constants/constants_ui.dart +++ b/core/lib/presentation/constants/constants_ui.dart @@ -16,6 +16,7 @@ class ConstantsUI { 'NotoSansSC', // Simplified Chinese (zh_Hans) 'NotoSansMath', // Math symbols (โˆ‘, โˆซ, โˆš, etc.) 'NotoSansEgyptianHieroglyphs', // Egyptian Hieroglyphs (๐“€€) + 'NotoColorEmoji', // Color emoji 'NotoEmoji', // Monochrome emoji 'NotoSansSymbols', // Miscellaneous symbols and arrows 'NotoSansSymbols2', // Extended symbol support diff --git a/core/lib/presentation/extensions/color_extension.dart b/core/lib/presentation/extensions/color_extension.dart index d0297f4094..820d5bd582 100644 --- a/core/lib/presentation/extensions/color_extension.dart +++ b/core/lib/presentation/extensions/color_extension.dart @@ -235,6 +235,7 @@ extension AppColor on Color { static const blue400 = Color(0xFF80BDFF); static const blue900 = Color(0xFF0F76E7); static const m3Tertiary = Color(0xFF8C9CAF); + static const m3Tertiary30 = Color(0xFF99A0A9); static const m3Tertiary60 = Color(0xFFD8E1EB); static const m3Tertiary70 = Color(0xFFE5ECF3); static const m3Tertiary20 = Color(0xFF71767C); @@ -246,6 +247,7 @@ extension AppColor on Color { static const m3SysOutline = Color(0xFFAEAEC0); static const grayBackgroundColor = Color(0xFFF3F6F9); static const m3SurfaceBackground = Color(0xFF1C1B1F); + static const m3LightSurfaceTint = Color(0xFF6750A4); static const warningColor = Color(0xFFFFC107); static const primaryMain = Color(0xFF0A84FF); static const m3LayerDarkOutline = Color(0xFF938F99); diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 2faff10718..1588597be2 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -280,6 +280,8 @@ class ImagePaths { String get icLabel => _getImagePath('ic_label.svg'); String get icColorPicker => _getImagePath('ic_color_picker.svg'); String get icThumbsUp => _getImagePath('ic_thumbs_up.svg'); + String get icEmoji => _getImagePath('ic_emoji.svg'); + String get icSearchEmojiEmpty => _getImagePath('ic_search_emoji_empty.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/core/lib/presentation/utils/theme_utils.dart b/core/lib/presentation/utils/theme_utils.dart index ad685fcfcb..9f2c875ff8 100644 --- a/core/lib/presentation/utils/theme_utils.dart +++ b/core/lib/presentation/utils/theme_utils.dart @@ -245,6 +245,14 @@ class ThemeUtils { color: AppColor.m3Tertiary, ); + static TextStyle get textStyleM3LabelMedium => defaultTextStyleInterFont.copyWith( + fontWeight: FontWeight.w500, + letterSpacing: 0.5, + fontSize: 12, + height: 16 / 12, + color: AppColor.m3Tertiary30, + ); + static TextStyle get textStyleM3TitleSmall => defaultTextStyleInterFont.copyWith( fontWeight: FontWeight.w500, letterSpacing: 0.1, diff --git a/core/lib/utils/html/html_template.dart b/core/lib/utils/html/html_template.dart index 03733628eb..b9161bdc71 100644 --- a/core/lib/utils/html/html_template.dart +++ b/core/lib/utils/html/html_template.dart @@ -77,8 +77,15 @@ class HtmlTemplate { font-style: bold; } - body { - font-family: 'Inter', sans-serif; + @font-face { + font-family: 'NotoColorEmoji'; + src: url('/assets/fonts/fallback/NotoColorEmoji-Regular.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + } + + body, [contenteditable="true"], textarea, input { + font-family: 'Inter', 'NotoColorEmoji', sans-serif; } '''; diff --git a/cozy/pubspec.lock b/cozy/pubspec.lock index 3fd5249570..0a607260d1 100644 --- a/cozy/pubspec.lock +++ b/cozy/pubspec.lock @@ -198,7 +198,7 @@ packages: path: "." ref: master resolved-ref: "66d09a271f20243badd92352a8bb5866b430020d" - url: "https://github.com/linagora/linagora-design-flutter.git" + url: "git@github.com:linagora/linagora-design-flutter.git" source: git version: "0.0.1" lints: diff --git a/cozy/pubspec.yaml b/cozy/pubspec.yaml index 22730da58c..ffefda58ac 100644 --- a/cozy/pubspec.yaml +++ b/cozy/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: linagora_design_flutter: git: - url: https://github.com/linagora/linagora-design-flutter.git + url: git@github.com:linagora/linagora-design-flutter.git ref: master dev_dependencies: diff --git a/lib/features/base/widget/emoji/emoji_button.dart b/lib/features/base/widget/emoji/emoji_button.dart new file mode 100644 index 0000000000..d87fd832e1 --- /dev/null +++ b/lib/features/base/widget/emoji/emoji_button.dart @@ -0,0 +1,311 @@ +import 'dart:math' as math; +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/utils/theme_utils.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +typedef OnEmojiSelected = Function(String emoji); +typedef OnRecentEmojiSelected = Future Function(); + +class EmojiButton extends StatefulWidget { + final EmojiData emojiData; + final String? emojiSvgAssetPath; + final String? emojiSearchEmptySvgAssetPath; + final OnEmojiSelected onEmojiSelected; + final VoidCallback onPickerOpen; + final double? iconSize; + final Color? iconColor; + final String? iconTooltipMessage; + final EdgeInsetsGeometry? iconPadding; + final OnRecentEmojiSelected? onRecentEmojiSelected; + + const EmojiButton({ + Key? key, + required this.emojiData, + required this.onEmojiSelected, + required this.onPickerOpen, + this.emojiSvgAssetPath, + this.emojiSearchEmptySvgAssetPath, + this.iconSize, + this.iconColor, + this.iconPadding, + this.iconTooltipMessage, + this.onRecentEmojiSelected, + }) : super(key: key); + + @override + State createState() => _EmojiButtonState(); +} + +class _EmojiButtonState extends State + with SingleTickerProviderStateMixin { + static const double _dialogWidth = 400.0; + static const double _dialogHeight = 360.0; + + final GlobalKey _buttonKey = GlobalKey(); + OverlayEntry? _overlayEntry; + bool _isDialogVisible = false; + bool _isOpeningDialog = false; + Future? _recentEmoji; + + late final AnimationController _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 150), + ); + + late final Animation _scaleAnimation = + Tween(begin: 0.9, end: 1.0).animate(_animationController); + late final Animation _fadeAnimation = + Tween(begin: 0.0, end: 1.0).animate(_animationController); + + Future _loadRecentEmoji() async { + try { + final category = await widget.onRecentEmojiSelected?.call(); + _recentEmoji = Future.value(category); + } catch (_) { + _recentEmoji = Future.value(null); + } + } + + void _toggleEmojiDialog() { + if (_isDialogVisible) { + _closeDialog(); + } else { + _openDialog(); + } + } + + Future _openDialog() async { + if (!mounted || + _isDialogVisible || + _isOpeningDialog || + _overlayEntry != null) { + return; + } + _isOpeningDialog = true; + + try { + final ctx = _buttonKey.currentContext; + if (ctx == null) return; + + final renderBox = ctx.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + + final buttonSize = renderBox.size; + final buttonPosition = renderBox.localToGlobal(Offset.zero); + final screenSize = MediaQuery.sizeOf(context); + + final double availableHeight = math.max(0.0, screenSize.height - 32); + final double dialogHeight = math.min(_dialogHeight, availableHeight); + final double availableWidth = math.max(0.0, screenSize.width - 16); + final dialogWidth = math.min(_dialogWidth, availableWidth); + + if (dialogWidth == 0 || dialogHeight == 0) return; + + double start = buttonPosition.dx + buttonSize.width / 2 - dialogWidth / 2; + double top = buttonPosition.dy - dialogHeight - 8; + + start = + start.clamp(8.0, math.max(8.0, screenSize.width - dialogWidth - 8)); + if (top < 8) { + top = buttonPosition.dy + buttonSize.height + 8; + + if (top + dialogHeight > screenSize.height - 8) { + top = 8; + } + } + + await _loadRecentEmoji(); + if (!mounted || _isDialogVisible || _overlayEntry != null) return; + + _overlayEntry = OverlayEntry( + builder: (context) { + return PointerInterceptor( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: _closeDialog, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ), + ), + PositionedDirectional( + start: start, + top: top, + child: FadeTransition( + opacity: _fadeAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + alignment: Alignment.topCenter, + child: Container( + width: dialogWidth, + height: dialogHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + const BorderRadius.all(Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 24, + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 2, + ), + ], + ), + padding: const EdgeInsets.all(12), + child: EmojiPicker( + emojiData: widget.emojiData, + configuration: EmojiPickerConfiguration( + showRecentTab: true, + emojiStyle: ThemeUtils.textStyleInter600().copyWith( + fontSize: 32, + height: 1, + ), + mainAxisSpacing: 4, + crossAxisSpacing: 4, + perLine: 8, + stickyHeaderTextStyle: + ThemeUtils.textStyleM3LabelMedium, + searchEmptyTextStyle: + ThemeUtils.textStyleM3BodyMedium.copyWith( + color: AppColor.m3Tertiary30, + ), + searchEmptyWidget: + widget.emojiSearchEmptySvgAssetPath != null + ? SvgPicture.asset( + widget.emojiSearchEmptySvgAssetPath!, + ) + : null, + ), + itemBuilder: (context, emojiId, emoji, callback) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => callback(emojiId, emoji), + borderRadius: const BorderRadius.all( + Radius.circular(4), + ), + hoverColor: AppColor.m3LightSurfaceTint + .withValues(alpha: 0.08), + child: Padding( + padding: const EdgeInsetsDirectional.only( + top: 6, + ), + child: Text( + emoji, + style: + ThemeUtils.textStyleInter600().copyWith( + fontSize: 32, + height: 1, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); + }, + onEmojiSelected: (emojiId, emoji) { + widget.onEmojiSelected(emoji); + }, + recentEmoji: _recentEmoji, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + + final overlay = Overlay.maybeOf(context, rootOverlay: true); + + if (!mounted || overlay == null) return; + + overlay.insert(_overlayEntry!); + + try { + widget.onPickerOpen(); + } catch (_) { + _overlayEntry?.remove(); + _overlayEntry = null; + return; + } + + await _animationController.forward(from: 0); + + if (mounted) setState(() => _isDialogVisible = true); + } finally { + _isOpeningDialog = false; + } + } + + Future _closeDialog() async { + if (_overlayEntry == null) return; + if (_isDialogVisible) { + await _animationController.reverse(); + } + _overlayEntry?.remove(); + _overlayEntry = null; + if (mounted) setState(() => _isDialogVisible = false); + } + + @override + void dispose() { + _overlayEntry?.remove(); + _overlayEntry = null; + _animationController.dispose(); + _recentEmoji = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.emojiSvgAssetPath != null) { + return TMailButtonWidget.fromIcon( + key: _buttonKey, + onTapActionCallback: _toggleEmojiDialog, + icon: widget.emojiSvgAssetPath!, + backgroundColor: + _isDialogVisible ? AppColor.m3Primary95 : Colors.transparent, + borderRadius: 10, + iconColor: _isDialogVisible + ? AppColor.primaryLinShare + : widget.iconColor ?? Colors.grey.shade700, + iconSize: widget.iconSize ?? 24, + padding: widget.iconPadding, + tooltipMessage: + widget.iconTooltipMessage ?? AppLocalizations.of(context).emoji, + ); + } + + return IconButton( + key: _buttonKey, + onPressed: _toggleEmojiDialog, + icon: Icon( + Icons.emoji_emotions, + color: _isDialogVisible + ? AppColor.primaryLinShare + : widget.iconColor ?? Colors.grey.shade600, + ), + style: IconButton.styleFrom( + backgroundColor: + _isDialogVisible ? AppColor.m3Primary95 : Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + ), + padding: widget.iconPadding, + tooltip: widget.iconTooltipMessage ?? AppLocalizations.of(context).emoji, + ); + } +} diff --git a/lib/features/composer/presentation/composer_bindings.dart b/lib/features/composer/presentation/composer_bindings.dart index bd2da13c69..9af27172f8 100644 --- a/lib/features/composer/presentation/composer_bindings.dart +++ b/lib/features/composer/presentation/composer_bindings.dart @@ -62,6 +62,7 @@ import 'package:tmail_ui_user/features/offline_mode/manager/new_email_cache_work import 'package:tmail_ui_user/features/offline_mode/manager/opened_email_cache_manager.dart'; import 'package:tmail_ui_user/features/offline_mode/manager/opened_email_cache_worker_queue.dart'; import 'package:tmail_ui_user/features/offline_mode/manager/sending_email_cache_manager.dart'; +import 'package:tmail_ui_user/features/reactions/presentation/reactions_interactor_bindings.dart'; import 'package:tmail_ui_user/features/server_settings/domain/usecases/get_server_setting_interactor.dart'; import 'package:tmail_ui_user/features/thread/data/local/email_cache_manager.dart'; import 'package:tmail_ui_user/features/upload/data/datasource/attachment_upload_datasource.dart'; @@ -308,6 +309,10 @@ class ComposerBindings extends BaseBindings { Get.find(tag: composerId), Get.find(tag: composerId), ), tag: composerId); + + if (PlatformInfo.isWeb) { + ReactionsInteractorBindings(composerId: composerId).dependencies(); + } } @override @@ -403,5 +408,9 @@ class ComposerBindings extends BaseBindings { IdentityInteractorsBindings(composerId: composerId).dispose(); PreferencesInteractorsBindings(composerId: composerId).dispose(); + + if (PlatformInfo.isWeb) { + ReactionsInteractorBindings(composerId: composerId).dispose(); + } } } \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 41c3682f62..9c666742cc 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -14,8 +14,9 @@ import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scrib import 'package:tmail_ui_user/features/composer/presentation/extensions/composer_print_draft_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_insert_link_composer_extension.dart'; -import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_insert_emoji_to_editor_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_recipients_collapsed_extensions.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/mark_as_important_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/preview_upload_file_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart'; @@ -543,6 +544,7 @@ class ComposerView extends GetWidget { ), Obx(() => BottomBarComposerWidget( imagePaths: controller.imagePaths, + responsiveUtils: controller.responsiveUtils, isCodeViewEnabled: controller.richTextWebController!.codeViewEnabled, isFormattingOptionsEnabled: controller.richTextWebController!.isFormattingOptionsEnabled, hasReadReceipt: controller.hasRequestReadReceipt.value, @@ -565,6 +567,9 @@ class ComposerView extends GetWidget { onOpenAiAssistantModal: controller.isAIScribeAvailable ? controller.openAIAssistantModal : null, + onEmojiSelected: controller.insertEmojiToEditor, + onPickerOpen: controller.handleOpenEmojiPicker, + onRecentEmojiSelected: controller.getRecentReactions, )), ], ), @@ -820,6 +825,7 @@ class ComposerView extends GetWidget { ), Obx(() => BottomBarComposerWidget( imagePaths: controller.imagePaths, + responsiveUtils: controller.responsiveUtils, isCodeViewEnabled: controller.richTextWebController!.codeViewEnabled, isFormattingOptionsEnabled: controller.richTextWebController!.isFormattingOptionsEnabled, hasReadReceipt: controller.hasRequestReadReceipt.value, @@ -842,6 +848,9 @@ class ComposerView extends GetWidget { onOpenAiAssistantModal: controller.isAIScribeAvailable ? controller.openAIAssistantModal : null, + onEmojiSelected: controller.insertEmojiToEditor, + onPickerOpen: controller.handleOpenEmojiPicker, + onRecentEmojiSelected: controller.getRecentReactions, )), ], ), diff --git a/lib/features/composer/presentation/controller/rich_text_web_controller.dart b/lib/features/composer/presentation/controller/rich_text_web_controller.dart index 77d67b39b5..b9e797815b 100644 --- a/lib/features/composer/presentation/controller/rich_text_web_controller.dart +++ b/lib/features/composer/presentation/controller/rich_text_web_controller.dart @@ -323,6 +323,11 @@ class RichTextWebController extends GetxController { : FormattingOptionsState.disabled; } + void insertEmoji(String emoji) { + editorController.setFocus(); + editorController.insertHtml(emoji); + } + bool get isFormattingOptionsEnabled => formattingOptionsState.value == FormattingOptionsState.enabled; void openInsertLink() { diff --git a/lib/features/composer/presentation/extensions/handle_insert_emoji_to_editor_extension.dart b/lib/features/composer/presentation/extensions/handle_insert_emoji_to_editor_extension.dart new file mode 100644 index 0000000000..522baad26b --- /dev/null +++ b/lib/features/composer/presentation/extensions/handle_insert_emoji_to_editor_extension.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:core/utils/app_logger.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/reactions/domain/state/get_recent_reactions_state.dart'; +import 'package:tmail_ui_user/features/reactions/domain/usecase/get_recent_reactions_interactor.dart'; +import 'package:tmail_ui_user/features/reactions/domain/usecase/store_recent_reactions_interactor.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +import 'package:tmail_ui_user/main/utils/asset_manager.dart'; + +extension HandleInsertEmojiToEditorExtension on ComposerController { + void insertEmojiToEditor(String emoji) { + log('$runtimeType::insertEmojiToEditor: Emoji is $emoji'); + richTextWebController?.insertEmoji(emoji); + unawaited(storeRecentReactions(emoji)); + } + + void handleOpenEmojiPicker() { + log('$runtimeType::handleOpenEmojiPicker:'); + clearFocusRecipients(); + clearFocusSubject(); + richTextWebController?.editorController.setFocus(); + } + + Future storeRecentReactions(String emoji) async { + try { + final assetManager = AssetManager(); + await assetManager.loadEmojiData(); + final emojiId = assetManager.emojiData?.getIdByEmoji(emoji); + log('$runtimeType::storeRecentReactions: EmojiId is $emojiId'); + if (emojiId?.trim().isNotEmpty == true) { + final interactor = getBinding( + tag: composerId, + ); + if (interactor != null) { + consumeState(interactor.execute(emojiId: emojiId!)); + } + } + } catch (e) { + log('$runtimeType::storeRecentReactions: failed: $e'); + } + } + + Future getRecentReactions() async { + final interactor = getBinding( + tag: composerId, + ); + if (interactor == null) return Future.value(null); + + final result = await interactor.execute(); + + final category = result.fold( + (failure) => null, + (success) => + success is GetRecentReactionsSuccess ? success.category : null, + ); + log('$runtimeType::getRecentReactions: Category is $category'); + return category; + } +} diff --git a/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart b/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart index bdd09a662b..d7201c8de3 100644 --- a/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart @@ -1,19 +1,23 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/utils/platform_info.dart'; import 'package:custom_pop_up_menu/custom_pop_up_menu.dart'; import 'package:flutter/material.dart'; import 'package:scribe/scribe/ai/presentation/widgets/button/ai_assistant_button.dart'; +import 'package:tmail_ui_user/features/base/widget/emoji/emoji_button.dart'; import 'package:tmail_ui_user/features/base/widget/highlight_svg_icon_on_hover.dart'; import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart'; import 'package:tmail_ui_user/features/base/widget/popup_menu_overlay_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/styles/web/bottom_bar_composer_widget_style.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/utils/asset_manager.dart'; class BottomBarComposerWidget extends StatelessWidget { final ImagePaths imagePaths; + final ResponsiveUtils responsiveUtils; final bool isCodeViewEnabled; final bool isEmailChanged; final bool isFormattingOptionsEnabled; @@ -32,12 +36,16 @@ class BottomBarComposerWidget extends StatelessWidget { final VoidCallback toggleMarkAsImportantAction; final VoidCallback saveAsTemplateAction; final VoidCallback onOpenInsertLink; + final OnEmojiSelected onEmojiSelected; + final VoidCallback onPickerOpen; final OnMenuChanged? onPopupMenuChanged; final OnOpenAiAssistantModal? onOpenAiAssistantModal; + final OnRecentEmojiSelected? onRecentEmojiSelected; const BottomBarComposerWidget({ super.key, required this.imagePaths, + required this.responsiveUtils, required this.isCodeViewEnabled, required this.isEmailChanged, required this.isFormattingOptionsEnabled, @@ -56,8 +64,11 @@ class BottomBarComposerWidget extends StatelessWidget { required this.toggleMarkAsImportantAction, required this.saveAsTemplateAction, required this.onOpenInsertLink, + required this.onEmojiSelected, + required this.onPickerOpen, this.onPopupMenuChanged, this.onOpenAiAssistantModal, + this.onRecentEmojiSelected, }); @override @@ -114,6 +125,24 @@ class BottomBarComposerWidget extends StatelessWidget { onTapActionCallback: insertImageAction, ), ), + if (PlatformInfo.isWeb && + !responsiveUtils.isMobile(context) && + AssetManager().emojiData != null) + ...[ + const SizedBox(width: BottomBarComposerWidgetStyle.space), + EmojiButton( + emojiData: AssetManager().emojiData!, + emojiSvgAssetPath: imagePaths.icEmoji, + emojiSearchEmptySvgAssetPath: imagePaths.icSearchEmojiEmpty, + iconColor: BottomBarComposerWidgetStyle.iconColor, + iconSize: BottomBarComposerWidgetStyle.iconSize, + iconTooltipMessage: AppLocalizations.of(context).emoji, + iconPadding: BottomBarComposerWidgetStyle.iconPadding, + onEmojiSelected: onEmojiSelected, + onPickerOpen: onPickerOpen, + onRecentEmojiSelected: onRecentEmojiSelected, + ), + ], const SizedBox(width: BottomBarComposerWidgetStyle.space), AbsorbPointer( absorbing: isCodeViewEnabled, diff --git a/lib/features/reactions/data/datasource/reactions_datasource.dart b/lib/features/reactions/data/datasource/reactions_datasource.dart new file mode 100644 index 0000000000..a949b3744a --- /dev/null +++ b/lib/features/reactions/data/datasource/reactions_datasource.dart @@ -0,0 +1,5 @@ +abstract class ReactionsDatasource { + Future storeRecentReactions(List recentReactions); + + Future> getRecentReactions(); +} diff --git a/lib/features/reactions/data/datasource_impl/reactions_datasource_impl.dart b/lib/features/reactions/data/datasource_impl/reactions_datasource_impl.dart new file mode 100644 index 0000000000..8f26a83629 --- /dev/null +++ b/lib/features/reactions/data/datasource_impl/reactions_datasource_impl.dart @@ -0,0 +1,30 @@ +import 'package:tmail_ui_user/features/reactions/data/datasource/reactions_datasource.dart'; +import 'package:tmail_ui_user/features/reactions/data/local/reaction_cache_manager.dart'; +import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; + +class ReactionsDatasourceImpl implements ReactionsDatasource { + final ReactionsCacheManager _reactionCacheManager; + final ExceptionThrower _exceptionThrower; + + ReactionsDatasourceImpl(this._reactionCacheManager, this._exceptionThrower); + + @override + Future> getRecentReactions() async { + return Future.sync(() { + return _reactionCacheManager.getRecentReactions() ?? []; + }).catchError((error, stackTrace) async { + await _exceptionThrower.throwException(error, stackTrace); + throw error; + }); + } + + @override + Future storeRecentReactions(List recentReactions) { + return Future.sync(() async { + return await _reactionCacheManager.storeRecentReactions(recentReactions); + }).catchError((error, stackTrace) async { + await _exceptionThrower.throwException(error, stackTrace); + throw error; + }); + } +} diff --git a/lib/features/reactions/data/local/reaction_cache_manager.dart b/lib/features/reactions/data/local/reaction_cache_manager.dart new file mode 100644 index 0000000000..d657cc2bfb --- /dev/null +++ b/lib/features/reactions/data/local/reaction_cache_manager.dart @@ -0,0 +1,17 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class ReactionsCacheManager { + static const keyRecentReactions = 'RECENT_REACTIONS'; + + final SharedPreferences _sharedPreferences; + + ReactionsCacheManager(this._sharedPreferences); + + Future storeRecentReactions(List recentReactions) async { + await _sharedPreferences.setStringList(keyRecentReactions, recentReactions); + } + + List? getRecentReactions() { + return _sharedPreferences.getStringList(keyRecentReactions); + } +} diff --git a/lib/features/reactions/data/repository/reactions_repository_impl.dart b/lib/features/reactions/data/repository/reactions_repository_impl.dart new file mode 100644 index 0000000000..e1686f2852 --- /dev/null +++ b/lib/features/reactions/data/repository/reactions_repository_impl.dart @@ -0,0 +1,18 @@ +import 'package:tmail_ui_user/features/reactions/data/datasource/reactions_datasource.dart'; +import 'package:tmail_ui_user/features/reactions/domain/repository/reactions_repository.dart'; + +class ReactionsRepositoryImpl implements ReactionsRepository { + final ReactionsDatasource _dataSource; + + ReactionsRepositoryImpl(this._dataSource); + + @override + Future> getRecentReactions() { + return _dataSource.getRecentReactions(); + } + + @override + Future storeRecentReactions(List recentReactions) { + return _dataSource.storeRecentReactions(recentReactions); + } +} diff --git a/lib/features/reactions/domain/extensions/list_reactions_extension.dart b/lib/features/reactions/domain/extensions/list_reactions_extension.dart new file mode 100644 index 0000000000..27b3d9596b --- /dev/null +++ b/lib/features/reactions/domain/extensions/list_reactions_extension.dart @@ -0,0 +1,16 @@ +extension ListReactionsExtension on List { + static const int maxRecentReactionsSize = 12; + + List combineRecentReactions(String emojiId) { + final result = List.from(this); + + result.removeWhere((id) => id == emojiId); + result.insert(0, emojiId); + + if (result.length > maxRecentReactionsSize) { + result.length = maxRecentReactionsSize; + } + + return result; + } +} diff --git a/lib/features/reactions/domain/repository/reactions_repository.dart b/lib/features/reactions/domain/repository/reactions_repository.dart new file mode 100644 index 0000000000..5dc964bdfe --- /dev/null +++ b/lib/features/reactions/domain/repository/reactions_repository.dart @@ -0,0 +1,5 @@ +abstract class ReactionsRepository { + Future storeRecentReactions(List recentReactions); + + Future> getRecentReactions(); +} diff --git a/lib/features/reactions/domain/state/get_recent_reactions_state.dart b/lib/features/reactions/domain/state/get_recent_reactions_state.dart new file mode 100644 index 0000000000..21d5ad46c0 --- /dev/null +++ b/lib/features/reactions/domain/state/get_recent_reactions_state.dart @@ -0,0 +1,16 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; + +class GetRecentReactionsSuccess extends UIState { + final Category category; + + GetRecentReactionsSuccess(this.category); + + @override + List get props => [category]; +} + +class GetRecentReactionsFailure extends FeatureFailure { + GetRecentReactionsFailure(dynamic exception) : super(exception: exception); +} diff --git a/lib/features/reactions/domain/state/store_recent_reactions_state.dart b/lib/features/reactions/domain/state/store_recent_reactions_state.dart new file mode 100644 index 0000000000..aa2696f26a --- /dev/null +++ b/lib/features/reactions/domain/state/store_recent_reactions_state.dart @@ -0,0 +1,10 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; + +class StoreRecentReactionsLoading extends LoadingState {} + +class StoreRecentReactionsSuccess extends UIState {} + +class StoreRecentReactionsFailure extends FeatureFailure { + StoreRecentReactionsFailure(dynamic exception) : super(exception: exception); +} diff --git a/lib/features/reactions/domain/usecase/get_recent_reactions_interactor.dart b/lib/features/reactions/domain/usecase/get_recent_reactions_interactor.dart new file mode 100644 index 0000000000..6a7914bc5e --- /dev/null +++ b/lib/features/reactions/domain/usecase/get_recent_reactions_interactor.dart @@ -0,0 +1,27 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:tmail_ui_user/features/reactions/domain/repository/reactions_repository.dart'; +import 'package:tmail_ui_user/features/reactions/domain/state/get_recent_reactions_state.dart'; + +class GetRecentReactionsInteractor { + final ReactionsRepository _repository; + + GetRecentReactionsInteractor(this._repository); + + Future> execute() async { + try { + final reactions = await _repository.getRecentReactions(); + + return Right(GetRecentReactionsSuccess( + Category( + id: EmojiPickerConfiguration.recentCategoryId, + emojiIds: reactions, + ), + )); + } catch (exception) { + return Left(GetRecentReactionsFailure(exception)); + } + } +} diff --git a/lib/features/reactions/domain/usecase/store_recent_reactions_interactor.dart b/lib/features/reactions/domain/usecase/store_recent_reactions_interactor.dart new file mode 100644 index 0000000000..fc6e429daf --- /dev/null +++ b/lib/features/reactions/domain/usecase/store_recent_reactions_interactor.dart @@ -0,0 +1,28 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:tmail_ui_user/features/reactions/domain/extensions/list_reactions_extension.dart'; +import 'package:tmail_ui_user/features/reactions/domain/repository/reactions_repository.dart'; +import 'package:tmail_ui_user/features/reactions/domain/state/store_recent_reactions_state.dart'; + +class StoreRecentReactionsInteractor { + final ReactionsRepository _repository; + + StoreRecentReactionsInteractor(this._repository); + + Stream> execute({required String emojiId}) async* { + try { + yield Right(StoreRecentReactionsLoading()); + + final reactions = await _repository.getRecentReactions(); + + final updatedReactions = reactions.combineRecentReactions(emojiId); + + await _repository.storeRecentReactions(updatedReactions); + + yield Right(StoreRecentReactionsSuccess()); + } catch (exception) { + yield Left(StoreRecentReactionsFailure(exception)); + } + } +} diff --git a/lib/features/reactions/presentation/reactions_interactor_bindings.dart b/lib/features/reactions/presentation/reactions_interactor_bindings.dart new file mode 100644 index 0000000000..5bc60ce954 --- /dev/null +++ b/lib/features/reactions/presentation/reactions_interactor_bindings.dart @@ -0,0 +1,78 @@ +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/base/interactors_bindings.dart'; +import 'package:tmail_ui_user/features/reactions/data/datasource/reactions_datasource.dart'; +import 'package:tmail_ui_user/features/reactions/data/datasource_impl/reactions_datasource_impl.dart'; +import 'package:tmail_ui_user/features/reactions/data/local/reaction_cache_manager.dart'; +import 'package:tmail_ui_user/features/reactions/data/repository/reactions_repository_impl.dart'; +import 'package:tmail_ui_user/features/reactions/domain/repository/reactions_repository.dart'; +import 'package:tmail_ui_user/features/reactions/domain/usecase/get_recent_reactions_interactor.dart'; +import 'package:tmail_ui_user/features/reactions/domain/usecase/store_recent_reactions_interactor.dart'; +import 'package:tmail_ui_user/main/exceptions/cache_exception_thrower.dart'; + +class ReactionsInteractorBindings extends InteractorsBindings { + final String? composerId; + + ReactionsInteractorBindings({this.composerId}); + + @override + void bindingsDataSource() { + Get.lazyPut( + () => Get.find(tag: composerId), + tag: composerId, + ); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut( + () => ReactionsDatasourceImpl( + Get.find(), + Get.find(), + ), + tag: composerId, + ); + } + + @override + void bindingsInteractor() { + Get.lazyPut( + () => GetRecentReactionsInteractor(Get.find( + tag: composerId, + )), + tag: composerId, + ); + Get.lazyPut( + () => StoreRecentReactionsInteractor(Get.find( + tag: composerId, + )), + tag: composerId, + ); + } + + @override + void bindingsRepository() { + Get.lazyPut( + () => Get.find(tag: composerId), + tag: composerId, + ); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut( + () => ReactionsRepositoryImpl( + Get.find(tag: composerId), + ), + tag: composerId, + ); + } + + void dispose() { + Get.delete(tag: composerId); + Get.delete(tag: composerId); + Get.delete(tag: composerId); + Get.delete(tag: composerId); + Get.delete(tag: composerId); + Get.delete(tag: composerId); + } +} diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index e7ebcb0b20..606b64871b 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -5453,5 +5453,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "emoji": "Emoji", + "@emoji": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/bindings/local/local_bindings.dart b/lib/main/bindings/local/local_bindings.dart index f6fd8ea53f..83b58e66e1 100644 --- a/lib/main/bindings/local/local_bindings.dart +++ b/lib/main/bindings/local/local_bindings.dart @@ -44,6 +44,7 @@ import 'package:tmail_ui_user/features/offline_mode/manager/opened_email_cache_w import 'package:tmail_ui_user/features/offline_mode/manager/sending_email_cache_manager.dart'; import 'package:tmail_ui_user/features/push_notification/data/keychain/keychain_sharing_manager.dart'; import 'package:tmail_ui_user/features/push_notification/data/local/fcm_cache_manager.dart'; +import 'package:tmail_ui_user/features/reactions/data/local/reaction_cache_manager.dart'; import 'package:tmail_ui_user/features/thread/data/local/email_cache_manager.dart'; import 'package:tmail_ui_user/main/exceptions/cache_exception_thrower.dart'; @@ -96,6 +97,7 @@ class LocalBindings extends Bindings { Get.put(SessionCacheManager(Get.find())); Get.put(LocalSortOrderManager(Get.find())); Get.put(SettingCacheManager(Get.find())); + Get.put(ReactionsCacheManager(Get.find())); Get.put(CachingManager( Get.find(), Get.find(), diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 5a687fb1e8..eea19d01ca 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5769,4 +5769,11 @@ class AppLocalizations { name: 'deleteALabelFailure', ); } + + String get emoji { + return Intl.message( + 'Emoji', + name: 'emoji', + ); + } } diff --git a/lib/main/main_entry.dart b/lib/main/main_entry.dart index c251152f44..de9c450d5c 100644 --- a/lib/main/main_entry.dart +++ b/lib/main/main_entry.dart @@ -7,7 +7,7 @@ import 'package:get/get.dart'; import 'package:tmail_ui_user/features/caching/config/hive_cache_config.dart'; import 'package:tmail_ui_user/main.dart'; import 'package:tmail_ui_user/main/bindings/main_bindings.dart'; -import 'package:tmail_ui_user/main/utils/asset_preloader.dart'; +import 'package:tmail_ui_user/main/utils/asset_manager.dart'; import 'package:tmail_ui_user/main/utils/cozy_integration.dart'; import 'package:url_strategy/url_strategy.dart'; import 'package:worker_manager/worker_manager.dart'; @@ -24,7 +24,7 @@ Future runTmailPreload() async { MainBindings().dependencies(), HiveCacheConfig.instance.setUp(), EnvLoader.loadEnvFile(), - if (PlatformInfo.isWeb) AssetPreloader.preloadHtmlEditorAssets(), + if (PlatformInfo.isWeb) AssetManager().preloadAllAssets(), ], eagerError: false); await Get.find().warmUp(log: BuildUtils.isDebugMode); diff --git a/lib/main/utils/asset_manager.dart b/lib/main/utils/asset_manager.dart new file mode 100644 index 0000000000..76989382d3 --- /dev/null +++ b/lib/main/utils/asset_manager.dart @@ -0,0 +1,65 @@ +import 'package:core/utils/app_logger.dart'; +import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; +import 'package:html_editor_enhanced/utils/html_editor_constants.dart'; +import 'package:html_editor_enhanced/utils/html_editor_utils.dart'; + +/// A singleton class responsible for preloading and caching assets +/// such as HTML editor files and emoji data. +class AssetManager { + static final AssetManager _instance = AssetManager._internal(); + + factory AssetManager() => _instance; + + AssetManager._internal(); + + EmojiData? _emojiData; + bool _isEmojiDataLoaded = false; + double? _emojiDataVersion; + + Future preloadAllAssets() async { + await Future.wait([ + preloadHtmlEditorAssets(), + loadEmojiData(), + ]); + } + + /// Loads all required HTML editor assets concurrently. + Future preloadHtmlEditorAssets() async { + try { + await Future.wait([ + HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteHtmlAssetPath), + HtmlEditorUtils.loadAsset(HtmlEditorConstants.jqueryAssetPath), + HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteCSSAssetPath), + HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteJSAssetPath), + HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteFontEOTAssetPath), + HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteFontTTFAssetPath), + ]); + log('AssetManager::preloadHtmlEditorAssets:โœ… HtmlEditor assets preloaded successfully.'); + } catch (e, s) { + logError('AssetManager::preloadHtmlEditorAssets: failed + $e, $s'); + } + } + + /// Loads emoji data once and caches it in memory. + /// If data has already been loaded, returns immediately. + Future loadEmojiData({double version = 13.5}) async { + if (_isEmojiDataLoaded && + _emojiData != null && + _emojiDataVersion == version) { + return; + } + + try { + final rawData = await EmojiData.builtIn(); + _emojiData = rawData.filterByVersion(version); + _isEmojiDataLoaded = true; + _emojiDataVersion = version; + log('AssetManager::loadEmojiData:โœ… EmojiData (v$version) loaded successfully.'); + } catch (e, s) { + logError('AssetManager::loadEmojiData: failed + $e, $s'); + } + } + + /// Returns the cached emoji data, or null if not yet loaded. + EmojiData? get emojiData => _emojiData; +} \ No newline at end of file diff --git a/lib/main/utils/asset_preloader.dart b/lib/main/utils/asset_preloader.dart deleted file mode 100644 index c438b3b091..0000000000 --- a/lib/main/utils/asset_preloader.dart +++ /dev/null @@ -1,23 +0,0 @@ - -import 'package:core/utils/app_logger.dart'; -import 'package:html_editor_enhanced/utils/html_editor_constants.dart'; -import 'package:html_editor_enhanced/utils/html_editor_utils.dart'; - -class AssetPreloader { - const AssetPreloader._(); - - static Future preloadHtmlEditorAssets() async { - try { - await Future.wait([ - HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteHtmlAssetPath), - HtmlEditorUtils.loadAsset(HtmlEditorConstants.jqueryAssetPath), - HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteCSSAssetPath), - HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteJSAssetPath), - HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteFontEOTAssetPath), - HtmlEditorUtils.loadAsset(HtmlEditorConstants.summernoteFontTTFAssetPath), - ]); - } catch (e) { - logWarning('AssetPreloader::preloadHtmlEditorAssets:Exception = $e'); - } - } -} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9a29566f68..a6d1991bc8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -487,6 +487,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" + easy_debounce: + dependency: transitive + description: + name: easy_debounce + sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236 + url: "https://pub.dev" + source: hosted + version: "2.0.3" email_recovery: dependency: "direct main" description: @@ -781,6 +789,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + flutter_emoji_mart: + dependency: "direct main" + description: + path: "." + ref: master + resolved-ref: "120bc036ea15045c686c2b1b3d399ac290c4465d" + url: "git@github.com:linagora/emoji_mart.git" + source: git + version: "1.0.2" flutter_file_dialog: dependency: "direct main" description: @@ -1106,6 +1123,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.2" + flutter_sticky_header: + dependency: transitive + description: + name: flutter_sticky_header + sha256: fb4fda6164ef3e5fc7ab73aba34aad253c17b7c6ecf738fa26f1a905b7d2d1e2 + url: "https://pub.dev" + source: hosted + version: "0.8.0" flutter_svg: dependency: "direct main" description: @@ -1444,7 +1469,7 @@ packages: path: "." ref: master resolved-ref: "66d09a271f20243badd92352a8bb5866b430020d" - url: "https://github.com/linagora/linagora-design-flutter.git" + url: "git@github.com:linagora/linagora-design-flutter.git" source: git version: "0.0.1" linkify: @@ -2055,6 +2080,14 @@ packages: relative: true source: path version: "0.0.1" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" sentry: dependency: transitive description: @@ -2187,6 +2220,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_gen: dependency: transitive description: @@ -2476,6 +2517,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + value_layout_builder: + dependency: transitive + description: + name: value_layout_builder + sha256: ab4b7d98bac8cefeb9713154d43ee0477490183f5aa23bb4ffa5103d9bbf6275 + url: "https://pub.dev" + source: hosted + version: "0.5.0" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 19efcea761..b1816acc4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,7 +98,12 @@ dependencies: linagora_design_flutter: git: - url: https://github.com/linagora/linagora-design-flutter.git + url: git@github.com:linagora/linagora-design-flutter.git + ref: master + + flutter_emoji_mart: + git: + url: git@github.com:linagora/emoji_mart.git ref: master ### Original dependency is abandoned, so we use fork @@ -409,6 +414,10 @@ flutter: fonts: - asset: assets/fonts/fallback/NotoSansEgyptianHieroglyphs-Regular.ttf + - family: NotoColorEmoji + fonts: + - asset: assets/fonts/fallback/NotoColorEmoji-Regular.ttf + - family: NotoEmoji fonts: - asset: assets/fonts/fallback/NotoEmoji-Regular.ttf