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