diff --git a/.github/workflows/analyze-test.yaml b/.github/workflows/analyze-test.yaml index 199df6f10b..9abb042b1d 100644 --- a/.github/workflows/analyze-test.yaml +++ b/.github/workflows/analyze-test.yaml @@ -29,36 +29,55 @@ 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@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 }} channel: "stable" cache: true cache-key: "deps-${{ hashFiles('**/pubspec.lock') }}" - cache-path: ${{ runner.tool_cache }}/flutter # optional, change this to specify the cache path + cache-path: ${{ runner.tool_cache }}/flutter + # ๐Ÿ”ฅ 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 + # ๐Ÿงฉ Run Flutter static analysis + - name: Analyze Dart code uses: zgosalvez/github-actions-analyze-dart@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: success() || failure() 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..96f3935f56 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -19,22 +19,41 @@ jobs: - os: android runner: ubuntu-latest - os: ios - runner: macos-14 # Use macos-14 runners because Xcode 16 only exists on macOS 14+. + runner: macos-14 # Xcode 16 is only available on macOS 14+ environment: dev steps: + # ๐Ÿงฐ Setup SSH (required because some dependencies use git@ URLs) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@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 }} 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 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..3961668e98 100644 --- a/.github/workflows/gh-pages.yaml +++ b/.github/workflows/gh-pages.yaml @@ -3,7 +3,7 @@ on: paths: - "**/*.dart" -name: Deploy PR on Github Pages +name: Deploy PR on GitHub Pages env: FLUTTER_VERSION: 3.32.8 @@ -20,7 +20,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 +33,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@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 +89,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 +98,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 +107,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 +116,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.yaml b/.github/workflows/image.yaml index 1909af4581..1592221bf7 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -8,6 +8,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' @@ -15,9 +16,23 @@ jobs: environment: dev steps: + # ๐Ÿงฐ Setup SSH (needed for private git@ dependencies inside Docker build) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@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 @@ -28,12 +43,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: @@ -41,18 +58,19 @@ 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 - platforms: "linux/amd64,linux/arm64" - cache-from: | - type=gha - cache-to: | - type=gha + ssh: default # โœ… Forward SSH key into Docker for private git@ dependencies + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha 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') @@ -60,9 +78,23 @@ jobs: environment: prod steps: + # ๐Ÿงฐ Setup SSH (needed for private git@ dependencies inside Docker build) + - name: Set up SSH for private Git dependencies + uses: webfactory/ssh-agent@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 @@ -74,12 +106,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: @@ -87,14 +121,14 @@ 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 - platforms: "linux/amd64,linux/arm64" - cache-from: | - type=gha - cache-to: | - type=gha + ssh: default # โœ… Enable SSH forwarding during Docker build + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/patrol-integration-test.yaml b/.github/workflows/patrol-integration-test.yaml index e9a89f7440..4057c6b2a4 100644 --- a/.github/workflows/patrol-integration-test.yaml +++ b/.github/workflows/patrol-integration-test.yaml @@ -17,40 +17,63 @@ 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@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" + uses: google-github-actions/auth@v2 with: project_id: ${{ secrets.GOOGLE_CLOUD_PROJECT_ID }} 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: flutter-version: ${{ env.FLUTTER_VERSION }} channel: "stable" cache: true + cache-key: deps-${{ hashFiles('**/pubspec.lock') }} + cache-path: ${{ runner.tool_cache }}/flutter + # โ˜•๏ธ 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..8d60b0fe54 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,29 +20,45 @@ jobs: - os: android runner: ubuntu-latest - os: ios - runner: macos-14 # Use macos-14 runners because Xcode 16 only exists on macOS 14+. + runner: macos-14 # Xcode 16 is only available on macOS 14+ fail-fast: false environment: prod steps: + # ๐Ÿ”„ Checkout repository - 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@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 }} 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 + # ๐Ÿ”ฅ 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 +66,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 +74,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 +90,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/.github/workflows/test-reports.yaml b/.github/workflows/test-reports.yaml index c45766c2e0..13a050b20f 100644 --- a/.github/workflows/test-reports.yaml +++ b/.github/workflows/test-reports.yaml @@ -16,7 +16,9 @@ jobs: reports: name: Upload test reports runs-on: ubuntu-latest + steps: + # ๐Ÿงฉ Generate GitHub Checks summary from uploaded test artifacts - uses: dorny/test-reporter@v1 with: artifact: /test-reports-(.*)/ diff --git a/Dockerfile b/Dockerfile index fa35979b02..593b236246 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,48 @@ +# syntax=docker/dockerfile:1.4 +# โ†‘ Required for BuildKit features like --mount=type=ssh + ARG FLUTTER_VERSION=3.32.8 -# Stage 1 - Install dependencies and build the app -# This matches the flutter version on our CI/CD pipeline on Github + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 1 โ€“ Build Flutter Web App +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ FROM --platform=amd64 ghcr.io/instrumentisto/flutter:${FLUTTER_VERSION} AS build-env -# Set directory to Copy App +# Enable SSH forwarding for private git dependencies (git@github.com) WORKDIR /app - COPY . . -# Precompile tmail flutter +# 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 && \ + # Fetch dependencies for each module + for mod in core model contact forward rule_filter fcm email_recovery server_settings cozy; do \ + cd /app/$mod && flutter pub get; \ + done && \ + # Fetch dependencies for the main project + cd /app && flutter pub get + +# Run code generation and localization steps RUN ./scripts/prebuild.sh -# Build flutter for web + +# Build Flutter Web in release mode RUN flutter build web --release -# Stage 2 - Create the run-time image +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Stage 2 โ€“ Runtime Image +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ FROM nginx:alpine -RUN apk add gzip + +# Install gzip for pre-compression +RUN apk add --no-cache gzip + +# Copy Nginx configuration and compiled web assets COPY --from=build-env /app/server/nginx.conf /etc/nginx COPY --from=build-env /app/build/web /usr/share/nginx/html -# Record the exposed port EXPOSE 80 -# Before stating NGinx, re-zip all the content to ensure customizations are propagated +# Re-compress assets before starting Nginx CMD gzip -k -r -f /usr/share/nginx/html/ && nginx -g 'daemon off;' 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 93557e81e8..a4900baf30 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 2a5d2cb7e9..4bd73b8602 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -255,6 +255,8 @@ class ImagePaths { String get icMessage => _getImagePath('ic_message.svg'); String get icNavigation => _getImagePath('ic_navigation.svg'); String get icReading => _getImagePath('ic_reading.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/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..6730183b33 --- /dev/null +++ b/lib/features/base/widget/emoji/emoji_button.dart @@ -0,0 +1,287 @@ +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; + 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 { + if (widget.onRecentEmojiSelected != null) { + final category = await widget.onRecentEmojiSelected!(); + if (category != null) { + _recentEmoji = Future.value(category); + return; + } + } + _recentEmoji = Future.value(null); + } + + void _toggleEmojiDialog() { + if (_isDialogVisible) { + _closeDialog(); + } else { + _openDialog(); + } + } + + Future _openDialog() async { + widget.onPickerOpen(); + + if (!mounted || _isDialogVisible) return; + + 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 = screenSize.height - 32; + final double dialogHeight = + availableHeight < _dialogHeight ? availableHeight : _dialogHeight; + + double start = buttonPosition.dx + buttonSize.width / 2 - _dialogWidth / 2; + double top = buttonPosition.dy - dialogHeight - 8; + + if (start < 8) start = 8; + if (start + _dialogWidth > screenSize.width - 8) { + start = screenSize.width - _dialogWidth - 8; + } + if (top < 8) { + top = buttonPosition.dy + buttonSize.height + 8; + + if (top + dialogHeight > screenSize.height - 8) { + top = 8; + } + } + + await _loadRecentEmoji(); + + _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, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + + if (mounted) { + Overlay.maybeOf(context, rootOverlay: true)?.insert(_overlayEntry!); + } + _animationController.forward(from: 0); + if (mounted) setState(() => _isDialogVisible = true); + } + + Future _closeDialog() async { + if (!_isDialogVisible) return; + 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 27cc6b6ad9..98468e9bf7 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'; @@ -309,6 +310,10 @@ class ComposerBindings extends BaseBindings { Get.find(tag: composerId), Get.find(tag: composerId), ), tag: composerId); + + if (PlatformInfo.isWeb) { + ReactionsInteractorBindings(composerId: composerId).dependencies(); + } } @override @@ -404,5 +409,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 72e3361acd..73a620fa98 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -11,6 +11,7 @@ import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wra import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; 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_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'; @@ -511,6 +512,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, @@ -529,6 +531,9 @@ class ComposerView extends GetWidget { toggleRequestReadReceiptAction: () => controller.toggleRequestReadReceipt(context), toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context), saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context), + onEmojiSelected: controller.insertEmojiToEditor, + onPickerOpen: controller.handleOpenEmojiPicker, + onRecentEmojiSelected: controller.getRecentReactions, )), ], ), @@ -778,6 +783,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, @@ -796,6 +802,9 @@ class ComposerView extends GetWidget { toggleRequestReadReceiptAction: () => controller.toggleRequestReadReceipt(context), toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context), saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context), + 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 0f1e8325b0..7b170c4817 100644 --- a/lib/features/composer/presentation/controller/rich_text_web_controller.dart +++ b/lib/features/composer/presentation/controller/rich_text_web_controller.dart @@ -317,6 +317,11 @@ class RichTextWebController extends GetxController { } } + void insertEmoji(String emoji) { + editorController.setFocus(); + editorController.insertHtml(emoji); + } + bool get isFormattingOptionsEnabled => formattingOptionsState.value == FormattingOptionsState.enabled; @override 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..a3e9a22ec4 --- /dev/null +++ b/lib/features/composer/presentation/extensions/handle_insert_emoji_to_editor_extension.dart @@ -0,0 +1,53 @@ +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); + storeRecentReactions(emoji); + } + + void handleOpenEmojiPicker() { + log('$runtimeType::handleOpenEmojiPicker:'); + clearFocusRecipients(); + clearFocusSubject(); + richTextWebController?.editorController.setFocus(); + } + + void storeRecentReactions(String emoji) async { + 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!)); + } + } + } + + 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 c1f23d552d..0538974ddb 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,18 +1,22 @@ 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: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; @@ -30,11 +34,15 @@ class BottomBarComposerWidget extends StatelessWidget { final VoidCallback printDraftAction; final VoidCallback toggleMarkAsImportantAction; final VoidCallback saveAsTemplateAction; + final OnEmojiSelected onEmojiSelected; + final VoidCallback onPickerOpen; final OnMenuChanged? onPopupMenuChanged; + final OnRecentEmojiSelected? onRecentEmojiSelected; const BottomBarComposerWidget({ super.key, required this.imagePaths, + required this.responsiveUtils, required this.isCodeViewEnabled, required this.isEmailChanged, required this.isFormattingOptionsEnabled, @@ -52,7 +60,10 @@ class BottomBarComposerWidget extends StatelessWidget { required this.printDraftAction, required this.toggleMarkAsImportantAction, required this.saveAsTemplateAction, + required this.onEmojiSelected, + required this.onPickerOpen, this.onPopupMenuChanged, + this.onRecentEmojiSelected, }); @override @@ -109,6 +120,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), PopupMenuOverlayWidget( controller: menuMoreOptionController, 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_extesion.dart b/lib/features/reactions/domain/extensions/list_reactions_extesion.dart new file mode 100644 index 0000000000..ed0b060403 --- /dev/null +++ b/lib/features/reactions/domain/extensions/list_reactions_extesion.dart @@ -0,0 +1,16 @@ +extension ListReactionsExtension on List { + static const int maxRecentReactionsSize = 12; + + List combineRecentReactions(String emojiId) { + final result = List.from(this); + + result.remove(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..8ab8a91b69 --- /dev/null +++ b/lib/features/reactions/domain/usecase/store_recent_reactions_interactor.dart @@ -0,0 +1,29 @@ +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_extesion.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 = + List.from(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 ec8801bf9d..acf4a3ed57 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-10-29T10:59:38.089168", + "@@last_modified": "2025-11-03T13:15:47.046616", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -5051,5 +5051,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.dart b/lib/main.dart index dcdb4d04ac..79108b9847 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,7 +16,7 @@ import 'package:tmail_ui_user/main/pages/app_pages.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; import 'package:tmail_ui_user/main/utils/app_utils.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'; @@ -37,7 +37,7 @@ Future runTmail() async { Executor().warmUp(log: BuildUtils.isDebugMode), AppUtils.loadEnvFile(), if (PlatformInfo.isWeb) - AssetPreloader.preloadHtmlEditorAssets(), + AssetManager().preloadAllAssets(), ]); await CozyIntegration.integrateCozy(); await HiveCacheConfig.instance.initializeEncryptionKey(); diff --git a/lib/main/bindings/local/local_bindings.dart b/lib/main/bindings/local/local_bindings.dart index 5d9fd1ea18..fb6bc85780 100644 --- a/lib/main/bindings/local/local_bindings.dart +++ b/lib/main/bindings/local/local_bindings.dart @@ -43,6 +43,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'; @@ -94,6 +95,7 @@ class LocalBindings extends Bindings { Get.put(SessionHiveCacheClient()); Get.put(SessionCacheManager(Get.find())); Get.put(LocalSortOrderManager(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 33def3be68..9bc4f1da57 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5345,4 +5345,11 @@ class AppLocalizations { name: 'deleteMessage', ); } + + String get emoji { + return Intl.message( + 'Emoji', + name: 'emoji', + ); + } } diff --git a/lib/main/utils/asset_manager.dart b/lib/main/utils/asset_manager.dart new file mode 100644 index 0000000000..9822b8c927 --- /dev/null +++ b/lib/main/utils/asset_manager.dart @@ -0,0 +1,61 @@ +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; + + 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) { + return; + } + + try { + final rawData = await EmojiData.builtIn(); + _emojiData = rawData.filterByVersion(version); + _isEmojiDataLoaded = true; + 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 e2ee5a61f9..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) { - logError('AssetPreloader::preloadHtmlEditorAssets:Exception = $e'); - } - } -} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 4fb694a5a5..0a80675824 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -496,6 +496,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: @@ -790,6 +798,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: @@ -1107,6 +1124,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: @@ -1153,10 +1178,10 @@ packages: dependency: "direct main" description: name: focus_detector_v2 - sha256: "4fed0ad4ef4996711880e26bd8450eb86199acc4f25eafbf49a17d4758f9d139" + sha256: d4abc4c755ba894238ab92f42f6eee7ade78aa285199e112f45926c7053f90c6 url: "https://pub.dev" source: hosted - version: "3.0.0+2" + version: "3.1.0+1" focused_menu_custom: dependency: "direct main" description: @@ -1265,8 +1290,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: "33c6f89b1a2cf47f8c148939268488b2bbc19144" + ref: upgrade-version-visibility-detector-dependecy + resolved-ref: "0963234a271d564749190de16d9eed6037e8930e" url: "https://github.com/linagora/html-editor-enhanced.git" source: git version: "3.3.0" @@ -1430,7 +1455,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: @@ -2002,6 +2027,14 @@ packages: url: "https://github.com/linagora/dart-neats.git" source: git version: "2.1.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" server_settings: dependency: "direct main" description: @@ -2110,6 +2143,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: @@ -2367,6 +2408,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: @@ -2403,10 +2452,10 @@ packages: dependency: transitive description: name: visibility_detector - sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 url: "https://pub.dev" source: hosted - version: "0.3.3" + version: "0.4.0+2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9c19513328..e298ae8caf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,7 +63,7 @@ dependencies: html_editor_enhanced: git: url: https://github.com/linagora/html-editor-enhanced.git - ref: main + ref: upgrade-version-visibility-detector-dependecy jmap_dart_client: git: @@ -99,7 +99,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 @@ -200,7 +205,7 @@ dependencies: focused_menu_custom: 1.2.0 - focus_detector_v2: 3.0.0+2 + focus_detector_v2: 3.1.0+1 universal_html: 2.2.4 @@ -404,6 +409,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