Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01304572cb | ||
|
|
ee14b88bc4 | ||
|
|
f1f7de7b4d | ||
|
|
de2b9d22b4 | ||
|
|
0297701829 | ||
|
|
ee238b85c7 | ||
|
|
f0eff7dc7c | ||
|
|
517f7a6aa8 | ||
|
|
8ea5237991 | ||
|
|
1e5093b631 | ||
|
|
c1ee8ec1f4 | ||
|
|
7ce9eddabf | ||
|
|
8592bba9e3 |
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "SharedInbox Dev",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "../Dockerfile.dev",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"workspaceFolder": "/src",
|
||||||
|
"workspaceMount": "source=${localWorkspaceFolder},target=/src,type=bind,consistency=cached",
|
||||||
|
"remoteUser": "ci"
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ jobs:
|
|||||||
repo_labels = api_get("/labels")
|
repo_labels = api_get("/labels")
|
||||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||||
|
|
||||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
|
||||||
|
|
||||||
title = "Firebase Tests failed — find root cause and fix"
|
title = "Firebase Tests failed — find root cause and fix"
|
||||||
body = (
|
body = (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish Dev Container
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.dev'
|
||||||
|
- '.devcontainer/devcontainer.json'
|
||||||
|
- '.forgejo/workflows/publish-dev-container.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build & Push sharedinbox-dev
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
REGISTRY: codeberg.org
|
||||||
|
IMAGE: codeberg.org/guettli/sharedinbox-dev
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Codeberg container registry
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
echo "$FORGEJO_TOKEN" \
|
||||||
|
| docker login "$REGISTRY" -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker build \
|
||||||
|
-t "$IMAGE:latest" \
|
||||||
|
-t "$IMAGE:$SHORT_SHA" \
|
||||||
|
-f Dockerfile.dev \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||||
|
docker push "$IMAGE:latest"
|
||||||
|
docker push "$IMAGE:$SHORT_SHA"
|
||||||
@@ -123,3 +123,4 @@ dagger-certs
|
|||||||
/go
|
/go
|
||||||
.last_deployed_sha
|
.last_deployed_sha
|
||||||
.fail_count
|
.fail_count
|
||||||
|
/*.kubeconfig
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ repos:
|
|||||||
- id: forbidden-files-hook
|
- id: forbidden-files-hook
|
||||||
name: check for forbidden home-directory files
|
name: check for forbidden home-directory files
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: dart-check
|
- id: dart-check
|
||||||
name: dart format (autofix) + check-fast (parallel)
|
name: dart format (autofix) + check-fast (parallel)
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
always_run: true
|
always_run: true
|
||||||
- id: ci-no-direct-dagger
|
- id: ci-no-direct-dagger
|
||||||
@@ -50,6 +50,12 @@ repos:
|
|||||||
- id: ci-image-exists
|
- id: ci-image-exists
|
||||||
name: verify container images in ci/main.go are reachable
|
name: verify container images in ci/main.go are reachable
|
||||||
language: system
|
language: system
|
||||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
files: ^(ci/main\.go|\.fvmrc)$
|
files: ^(ci/main\.go|\.fvmrc)$
|
||||||
|
- id: dagger-versions-aligned
|
||||||
|
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
|
||||||
|
language: system
|
||||||
|
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && scripts/check_dagger_versions.sh'
|
||||||
|
pass_filenames: false
|
||||||
|
files: ^(ci/dagger\.json|\.forgejo/Dockerfile|DAGGER\.md)$
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Development and Testing Container for SharedInbox
|
||||||
|
# Replaces the Nix shell environment.
|
||||||
|
FROM ghcr.io/cirruslabs/flutter:3.44.0
|
||||||
|
|
||||||
|
# Install Linux desktop build and test dependencies, Go, NodeJS, python3, and utilities
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
clang \
|
||||||
|
cmake \
|
||||||
|
ninja-build \
|
||||||
|
pkg-config \
|
||||||
|
libgtk-3-dev \
|
||||||
|
liblzma-dev \
|
||||||
|
libsecret-1-dev \
|
||||||
|
libgcrypt20-dev \
|
||||||
|
libjsoncpp-dev \
|
||||||
|
sqlite3 \
|
||||||
|
iproute2 \
|
||||||
|
netcat-openbsd \
|
||||||
|
xvfb \
|
||||||
|
libosmesa6 \
|
||||||
|
libegl1 \
|
||||||
|
lld \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
jq \
|
||||||
|
python3-pip \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
hugo \
|
||||||
|
lcov \
|
||||||
|
rsync \
|
||||||
|
openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Task runner
|
||||||
|
RUN curl -fsSL https://taskfile.dev/install.sh \
|
||||||
|
| sh -s -- -b /usr/local/bin v3.48.0
|
||||||
|
|
||||||
|
# Install Dagger CLI
|
||||||
|
RUN curl -fsSL https://dl.dagger.io/dagger/install.sh \
|
||||||
|
| DAGGER_VERSION=0.20.8 BIN_DIR=/usr/local/bin sh
|
||||||
|
|
||||||
|
# Install python packages (Play Store API clients + pre-commit)
|
||||||
|
RUN pip install --break-system-packages --no-cache-dir \
|
||||||
|
google-api-python-client \
|
||||||
|
google-auth-httplib2 \
|
||||||
|
httplib2 \
|
||||||
|
pre-commit==4.5.1
|
||||||
|
|
||||||
|
# Install acpx CLI globally
|
||||||
|
RUN npm install -g acpx@0.10.0
|
||||||
|
|
||||||
|
# Setup user "ci"
|
||||||
|
RUN useradd -m -s /bin/bash ci
|
||||||
|
USER ci
|
||||||
|
ENV HOME=/home/ci
|
||||||
|
ENV PATH=/home/ci/.pub-cache/bin:$PATH
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
+6
-1
@@ -544,7 +544,7 @@ tasks:
|
|||||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||||
|
|
||||||
deploy-android-bundle:
|
deploy-android-bundle:
|
||||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
|
||||||
deps: [build-android-bundle-local]
|
deps: [build-android-bundle-local]
|
||||||
dotenv: [".env"]
|
dotenv: [".env"]
|
||||||
cmds:
|
cmds:
|
||||||
@@ -712,6 +712,11 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- scripts/check_ci_images.sh
|
- scripts/check_ci_images.sh
|
||||||
|
|
||||||
|
check-dagger-versions:
|
||||||
|
desc: Verify ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md pin the same Dagger version
|
||||||
|
cmds:
|
||||||
|
- scripts/check_dagger_versions.sh
|
||||||
|
|
||||||
_integrations:
|
_integrations:
|
||||||
internal: true
|
internal: true
|
||||||
run: once
|
run: once
|
||||||
|
|||||||
+9
-2
@@ -814,7 +814,14 @@ func (m *Ci) DeployApk(
|
|||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.firebaseBase().
|
built := m.firebaseBase().
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
// `flutter build apk` spawns a Gradle daemon. When this WithExec ends the
|
||||||
|
// container is torn down and the daemon is killed, but its journal-cache
|
||||||
|
// lock file on the persistent gradle-cache volume keeps its dead PID — the
|
||||||
|
// next gradlew invocation then times out waiting for that lock. `gradlew
|
||||||
|
// --stop` shuts the daemon down gracefully so the lock is released before
|
||||||
|
// Dagger snapshots the layer.
|
||||||
|
WithExec([]string{"/bin/bash", "-c",
|
||||||
|
`flutter build apk --debug --no-pub && (cd android && ./gradlew --stop)`}).
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||||
@@ -896,7 +903,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
|
|||||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
|
||||||
func (m *Ci) UploadToPlayStore(
|
func (m *Ci) UploadToPlayStore(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
aab *dagger.File,
|
aab *dagger.File,
|
||||||
|
|||||||
Generated
-82
@@ -1,82 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"dagger": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": [
|
|
||||||
"nixpkgs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778107833,
|
|
||||||
"narHash": "sha256-q5XQep2mpgTPiWwuYB1+L2dsFeACT6sHx8J939iM+HE=",
|
|
||||||
"owner": "dagger",
|
|
||||||
"repo": "nix",
|
|
||||||
"rev": "873cc22ba46b73d4a6c1aa6c102ef3aabc736496",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "dagger",
|
|
||||||
"repo": "nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778737229,
|
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "d7a713c0b7e47c908258e71cba7a2d77cc8d71d5",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-25.11",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"dagger": "dagger",
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
{
|
|
||||||
description = "SharedInbox — IMAP/SMTP Flutter client";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
dagger.url = "github:dagger/nix";
|
|
||||||
dagger.inputs.nixpkgs.follows = "nixpkgs";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils, dagger }:
|
|
||||||
flake-utils.lib.eachDefaultSystem (system:
|
|
||||||
let
|
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
|
||||||
|
|
||||||
# All Linux desktop runtime libraries needed by flutter build linux and
|
|
||||||
# the UI integration tests (xvfb-run). Kept as a list so we can reuse
|
|
||||||
# it for both buildInputs and LD_LIBRARY_PATH / PKG_CONFIG_PATH.
|
|
||||||
linuxDesktopLibs = with pkgs; [
|
|
||||||
gtk3
|
|
||||||
libsecret
|
|
||||||
fontconfig
|
|
||||||
libepoxy
|
|
||||||
mesa
|
|
||||||
libGL # libglvnd — vendor-neutral GL/EGL/GLX dispatch layer
|
|
||||||
at-spi2-core
|
|
||||||
glib
|
|
||||||
pango
|
|
||||||
cairo
|
|
||||||
gdk-pixbuf
|
|
||||||
harfbuzz
|
|
||||||
# Dagger remote setup dependencies
|
|
||||||
stunnel
|
|
||||||
netcat
|
|
||||||
];
|
|
||||||
|
|
||||||
fgj = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "fgj";
|
|
||||||
version = "0.4.0";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://codeberg.org/romaintb/fgj/releases/download/v0.4.0/fgj_linux_amd64";
|
|
||||||
sha256 = "07pia03facvvxq9i1dgl7p47ccv1iqj4drpkp45gvw26d4afkbj7";
|
|
||||||
};
|
|
||||||
dontUnpack = true;
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp $src $out/bin/fgj
|
|
||||||
chmod +x $out/bin/fgj
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# The dagger/nix flake pins 0.20.8, whose Nix wrapper is a broken self-exec
|
|
||||||
# loop. Fetch 0.21.4 directly so the pre-commit dart-check hook can run.
|
|
||||||
dagger021 = pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "dagger";
|
|
||||||
version = "0.21.4";
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
url = "https://dl.dagger.io/dagger/releases/0.21.4/dagger_v0.21.4_linux_amd64.tar.gz";
|
|
||||||
sha256 = "0wlnbr4g5069755131yjp2a6alacn64f1c8b27xn0cbynq3zicjd";
|
|
||||||
};
|
|
||||||
sourceRoot = ".";
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin
|
|
||||||
cp dagger $out/bin/dagger
|
|
||||||
chmod +x $out/bin/dagger
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
# Dagger CLI
|
|
||||||
dagger021
|
|
||||||
|
|
||||||
# Go compiler — for Dagger development
|
|
||||||
go
|
|
||||||
|
|
||||||
# Java JDK — required by Gradle for Android builds
|
|
||||||
|
|
||||||
# Task runner
|
|
||||||
go-task
|
|
||||||
|
|
||||||
# Flutter version manager — needed for host builds (task build-linux, task run)
|
|
||||||
fvm
|
|
||||||
|
|
||||||
# Git hooks
|
|
||||||
pre-commit
|
|
||||||
|
|
||||||
# Linux desktop build + runtime dependencies (flutter build linux / task run)
|
|
||||||
] ++ linuxDesktopLibs ++ (with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
clang
|
|
||||||
cmake
|
|
||||||
ninja
|
|
||||||
|
|
||||||
# Local IMAP/SMTP dev server for integration tests
|
|
||||||
stalwart-mail
|
|
||||||
|
|
||||||
# Headless display for UI integration tests
|
|
||||||
xvfb-run # wraps Xvfb; xvfb-run --auto-servernum ...
|
|
||||||
|
|
||||||
# Coverage merging (flutter test --merge-coverage requires lcov)
|
|
||||||
lcov
|
|
||||||
|
|
||||||
# Website
|
|
||||||
hugo
|
|
||||||
|
|
||||||
# Utilities
|
|
||||||
git
|
|
||||||
curl
|
|
||||||
jq
|
|
||||||
sqlite
|
|
||||||
# python3 base + Google Play API client (for scripts/deploy_playstore.py)
|
|
||||||
(python3.withPackages (ps: with ps; [
|
|
||||||
google-api-python-client
|
|
||||||
google-auth-httplib2
|
|
||||||
httplib2
|
|
||||||
])) # used by stalwart-dev/start and deploy_playstore.py
|
|
||||||
fgj # Codeberg/Forgejo CLI (like gh for GitHub)
|
|
||||||
skopeo # inspect OCI image manifests without pulling layers (used by check-ci-images)
|
|
||||||
librsvg # rsvg-convert — SVG→PNG for generate-icons task
|
|
||||||
]);
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
# nix develop --command does not set IN_NIX_SHELL; set it so _preflight passes in CI
|
|
||||||
export IN_NIX_SHELL=1
|
|
||||||
|
|
||||||
# Point Dagger client at the running engine socket
|
|
||||||
export DAGGER_HOST=unix:///run/dagger/engine.sock
|
|
||||||
|
|
||||||
# Disable Flutter telemetry inside dev shell
|
|
||||||
export FLUTTER_SUPPRESS_ANALYTICS=true
|
|
||||||
|
|
||||||
# Expose dev headers to cmake's FindPkgConfig.
|
|
||||||
# The nix pkg-config wrapper works in bash but cmake invokes pkg-config
|
|
||||||
# as a subprocess and needs PKG_CONFIG_PATH set explicitly.
|
|
||||||
export PKG_CONFIG_PATH="${pkgs.gtk3.dev}/lib/pkgconfig:${pkgs.glib.dev}/lib/pkgconfig:${pkgs.pango.dev}/lib/pkgconfig:${pkgs.cairo.dev}/lib/pkgconfig:${pkgs.gdk-pixbuf.dev}/lib/pkgconfig:${pkgs.at-spi2-core.dev}/lib/pkgconfig:${pkgs.harfbuzz.dev}/lib/pkgconfig:${pkgs.libsecret}/lib/pkgconfig:${pkgs.fontconfig.dev}/lib/pkgconfig:${pkgs.libepoxy}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
|
||||||
|
|
||||||
# Nix ld uses --no-copy-dt-needed-entries (strict mode): transitive shared-lib
|
|
||||||
# deps are not followed automatically, so link them explicitly.
|
|
||||||
export LDFLAGS="-L${pkgs.fontconfig.lib}/lib -lfontconfig $LDFLAGS"
|
|
||||||
|
|
||||||
# Make nix-built runtime libs visible to the dynamic linker so the
|
|
||||||
# Flutter Linux bundle and integration-ui tests can run.
|
|
||||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath linuxDesktopLibs}:$LD_LIBRARY_PATH"
|
|
||||||
|
|
||||||
# Wire the libglvnd dispatch to the nix mesa vendor ICDs so GTK/Flutter
|
|
||||||
# can create an OpenGL (EGL + GLX) context under Xvfb without a real GPU.
|
|
||||||
export __EGL_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/share/glvnd/egl_vendor.d"
|
|
||||||
export __GLX_VENDOR_LIBRARY_DIRS="${pkgs.mesa}/lib"
|
|
||||||
export LIBGL_ALWAYS_SOFTWARE=1
|
|
||||||
export MESA_LOADER_DRIVER_OVERRIDE=softpipe
|
|
||||||
|
|
||||||
echo "SharedInbox Flutter dev environment ready."
|
|
||||||
echo " Analyze : task analyze"
|
|
||||||
echo " Unit tests : task test"
|
|
||||||
echo " Integration : task integration"
|
|
||||||
echo " All checks : task check"
|
|
||||||
echo " Run (Linux) : task run"
|
|
||||||
echo " Start Stalwart : stalwart-dev/start"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,10 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||||
|
|
||||||
class AddressEmailsScreen extends ConsumerStatefulWidget {
|
class AddressEmailsScreen extends ConsumerStatefulWidget {
|
||||||
const AddressEmailsScreen({
|
const AddressEmailsScreen({
|
||||||
@@ -26,12 +26,27 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
|||||||
List<Email>? _emails;
|
List<Email>? _emails;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
|
late final EmailThreadListController _selection;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||||
unawaited(_load());
|
unawaited(_load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_selection
|
||||||
|
..removeListener(_onSelectionChange)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSelectionChange() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final emails = await ref
|
final emails = await ref
|
||||||
.read(emailRepositoryProvider)
|
.read(emailRepositoryProvider)
|
||||||
@@ -46,43 +61,35 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selecting = _selection.isSelecting;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(widget.address)),
|
appBar: selecting
|
||||||
|
? buildSelectionAppBar(_selection)
|
||||||
|
: AppBar(title: Text(widget.address)),
|
||||||
|
bottomNavigationBar: selecting
|
||||||
|
? buildSelectionBottomBar(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
_selection,
|
||||||
|
onAfterAction: _onAfterBatchAction,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _emails!.isEmpty
|
: EmailThreadList(
|
||||||
? const Center(child: Text('No emails'))
|
controller: _selection,
|
||||||
: ListView.builder(
|
items: _emails!.map(EmailThread.fromEmail).toList(),
|
||||||
itemCount: _emails!.length,
|
enableSwipe: false,
|
||||||
itemBuilder: (ctx, i) {
|
showLocationLabel: true,
|
||||||
final e = _emails![i];
|
),
|
||||||
final sender = e.from.isNotEmpty
|
|
||||||
? (e.from.first.name ?? e.from.first.email)
|
|
||||||
: '(unknown)';
|
|
||||||
return ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
|
||||||
color:
|
|
||||||
e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
|
||||||
),
|
|
||||||
title: Text(sender),
|
|
||||||
subtitle: Text(
|
|
||||||
e.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
trailing: Text(
|
|
||||||
e.mailboxPath,
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
onTap: () => context.push(
|
|
||||||
'/accounts/${widget.accountId}/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(e.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(e.id)}',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onAfterBatchAction(List<String> actedThreadIds) {
|
||||||
|
if (_emails == null || !mounted) return;
|
||||||
|
final actedSet = actedThreadIds.toSet();
|
||||||
|
final remaining =
|
||||||
|
_emails!.where((e) => !actedSet.contains(e.threadId ?? e.id)).toList();
|
||||||
|
setState(() => _emails = remaining);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||||
|
|
||||||
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||||
const CombinedInboxScreen({super.key});
|
const CombinedInboxScreen({super.key});
|
||||||
@@ -22,29 +20,24 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
int _limit = _pageSize;
|
int _limit = _pageSize;
|
||||||
|
|
||||||
// Thread-level selection (key = threadId).
|
late final EmailThreadListController _selection;
|
||||||
final Set<String> _selectedThreadIds = {};
|
|
||||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
|
||||||
List<EmailThread> _currentThreads = [];
|
|
||||||
|
|
||||||
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
@override
|
||||||
|
void initState() {
|
||||||
void _toggleThreadSelection(EmailThread thread) {
|
super.initState();
|
||||||
setState(() {
|
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||||
if (_selectedThreadIds.contains(thread.threadId)) {
|
|
||||||
_selectedThreadIds.remove(thread.threadId);
|
|
||||||
} else {
|
|
||||||
_selectedThreadIds.add(thread.threadId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_selection
|
||||||
|
..removeListener(_onSelectionChange)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _selectAll() {
|
void _onSelectionChange() {
|
||||||
setState(
|
if (mounted) setState(() {});
|
||||||
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -72,13 +65,18 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
for (final a in accounts) a.id: a.displayName,
|
for (final a in accounts) a.id: a.displayName,
|
||||||
};
|
};
|
||||||
final showAccount = accounts.length > 1;
|
final showAccount = accounts.length > 1;
|
||||||
|
final selecting = _selection.isSelecting;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(accounts),
|
appBar: selecting
|
||||||
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
? buildSelectionAppBar(_selection)
|
||||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
: _buildAppBar(accounts),
|
||||||
|
drawer: selecting ? null : _buildDrawer(context, accounts),
|
||||||
|
bottomNavigationBar: selecting
|
||||||
|
? buildSelectionBottomBar(context, ref, _selection)
|
||||||
|
: null,
|
||||||
body: _buildBody(accountNames, showAccount),
|
body: _buildBody(accountNames, showAccount),
|
||||||
floatingActionButton: _selecting
|
floatingActionButton: selecting
|
||||||
? null
|
? null
|
||||||
: FloatingActionButton(
|
: FloatingActionButton(
|
||||||
onPressed: () => context.push('/compose'),
|
onPressed: () => context.push('/compose'),
|
||||||
@@ -90,23 +88,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
PreferredSizeWidget _buildAppBar(List<Account> accounts) {
|
||||||
if (_selecting) {
|
|
||||||
return AppBar(
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: _clearSelection,
|
|
||||||
),
|
|
||||||
title: Text('${_selectedThreadIds.length} selected'),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.select_all),
|
|
||||||
tooltip: 'Select all',
|
|
||||||
onPressed: _selectAll,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
title: const Text('Combined Inbox'),
|
title: const Text('Combined Inbox'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -128,26 +109,6 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.archive),
|
|
||||||
tooltip: 'Archive',
|
|
||||||
onPressed: _batchArchive,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: _batchDelete,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
Widget _buildDrawer(BuildContext context, List<Account> accounts) {
|
||||||
return Drawer(
|
return Drawer(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
@@ -226,197 +187,14 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
|||||||
ref.read(syncManagerProvider).syncNow(a.id);
|
ref.read(syncManagerProvider).syncNow(a.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: StreamBuilder<List<EmailThread>>(
|
child: EmailThreadList(
|
||||||
|
controller: _selection,
|
||||||
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||||
builder: (ctx, snap) {
|
enablePagination: true,
|
||||||
if (!snap.hasData) {
|
showAccountLabel: showAccount,
|
||||||
return const Center(child: CircularProgressIndicator());
|
accountNames: accountNames,
|
||||||
}
|
onLoadMore: () => setState(() => _limit += _pageSize),
|
||||||
final threads = snap.data!;
|
|
||||||
_currentThreads = threads;
|
|
||||||
if (threads.isEmpty) {
|
|
||||||
return ListView(
|
|
||||||
children: const [
|
|
||||||
SizedBox(
|
|
||||||
height: 300,
|
|
||||||
child: Center(child: Text('No emails')),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _buildThreadList(threads, accountNames, showAccount);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildThreadList(
|
|
||||||
List<EmailThread> threads,
|
|
||||||
Map<String, String> accountNames,
|
|
||||||
bool showAccount,
|
|
||||||
) {
|
|
||||||
final hasMore = threads.length == _limit;
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: threads.length + (hasMore ? 1 : 0),
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
if (i == threads.length) {
|
|
||||||
return TextButton(
|
|
||||||
onPressed: () => setState(() => _limit += _pageSize),
|
|
||||||
child: const Text('Load more'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final t = threads[i];
|
|
||||||
return EmailThreadTile(
|
|
||||||
thread: t,
|
|
||||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
|
||||||
isSelecting: _selecting,
|
|
||||||
showAccount: showAccount,
|
|
||||||
accountName: accountNames[t.accountId],
|
|
||||||
onTap: _selecting
|
|
||||||
? () => _toggleThreadSelection(t)
|
|
||||||
: t.messageCount > 1
|
|
||||||
? () => context.push(
|
|
||||||
'/accounts/${t.accountId}/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
|
||||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
|
||||||
)
|
|
||||||
: () => context.push(
|
|
||||||
'/accounts/${t.accountId}/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(t.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
|
||||||
),
|
|
||||||
onLongPress: () => _toggleThreadSelection(t),
|
|
||||||
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSwipeDismissed(
|
|
||||||
EmailThread t,
|
|
||||||
DismissDirection direction,
|
|
||||||
) async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (direction == DismissDirection.startToEnd) {
|
|
||||||
final archive = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
|
||||||
.findMailboxByRole(t.accountId, 'archive');
|
|
||||||
if (!mounted || archive == null) return;
|
|
||||||
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
await repo.moveEmail(id, archive.path);
|
|
||||||
}
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: t.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: t.mailboxPath,
|
|
||||||
destinationMailboxPath: archive.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: t.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: t.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _batchArchive() async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
|
||||||
|
|
||||||
// Group selected threads by accountId so we look up each account's archive once.
|
|
||||||
final byAccount = <String, List<EmailThread>>{};
|
|
||||||
for (final t in _currentThreads) {
|
|
||||||
if (!_selectedThreadIds.contains(t.threadId)) continue;
|
|
||||||
(byAccount[t.accountId] ??= []).add(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearSelection();
|
|
||||||
|
|
||||||
for (final entry in byAccount.entries) {
|
|
||||||
final accountId = entry.key;
|
|
||||||
final threads = entry.value;
|
|
||||||
final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive');
|
|
||||||
if (!mounted || archive == null) continue;
|
|
||||||
|
|
||||||
for (final t in threads) {
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
await repo.moveEmail(id, archive.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: t.mailboxPath,
|
|
||||||
destinationMailboxPath: archive.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _batchDelete() async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
|
|
||||||
final selectedThreads = _currentThreads
|
|
||||||
.where((t) => _selectedThreadIds.contains(t.threadId))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
_clearSelection();
|
|
||||||
|
|
||||||
for (final t in selectedThreads) {
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: t.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: t.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/mailbox.dart';
|
import 'package:sharedinbox/core/models/mailbox.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
import 'package:sharedinbox/core/repositories/mailbox_repository.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
|
||||||
enum _MissingFolderChoice { chooseExisting, createNew }
|
enum _MissingFolderChoice { chooseExisting, createNew }
|
||||||
|
|
||||||
@@ -78,3 +87,288 @@ Future<Mailbox?> resolveMailboxByRole(
|
|||||||
|
|
||||||
return mailbox;
|
return mailbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared batch helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Single source of truth for batch actions across every email-list surface
|
||||||
|
// (folder, combined inbox, search, address). Threads are grouped by
|
||||||
|
// accountId so a multi-account selection still produces correctly scoped
|
||||||
|
// repository calls and undo actions.
|
||||||
|
|
||||||
|
/// Archives every thread in [threads], grouping by account so each account's
|
||||||
|
/// archive folder is resolved once. Prompts the user when an account has no
|
||||||
|
/// archive folder.
|
||||||
|
Future<void> batchArchive(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
}) =>
|
||||||
|
_batchMoveToRole(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: threads,
|
||||||
|
role: 'archive',
|
||||||
|
dialogTitle: 'No archive folder found',
|
||||||
|
createFolderName: 'Archive',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Moves every thread in [threads] to its account's junk folder.
|
||||||
|
Future<void> batchMarkSpam(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
}) =>
|
||||||
|
_batchMoveToRole(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: threads,
|
||||||
|
role: 'junk',
|
||||||
|
dialogTitle: 'No spam folder found',
|
||||||
|
createFolderName: 'Junk',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _batchMoveToRole(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
required String role,
|
||||||
|
required String dialogTitle,
|
||||||
|
required String createFolderName,
|
||||||
|
}) async {
|
||||||
|
if (threads.isEmpty) return;
|
||||||
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
|
|
||||||
|
final byAccount = _groupByAccount(threads);
|
||||||
|
for (final entry in byAccount.entries) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final accountId = entry.key;
|
||||||
|
final accountThreads = entry.value;
|
||||||
|
final mailbox = await resolveMailboxByRole(
|
||||||
|
context,
|
||||||
|
mailboxRepo,
|
||||||
|
accountId,
|
||||||
|
accountThreads.first.mailboxPath,
|
||||||
|
role,
|
||||||
|
dialogTitle: dialogTitle,
|
||||||
|
createFolderName: createFolderName,
|
||||||
|
);
|
||||||
|
if (mailbox == null) continue;
|
||||||
|
|
||||||
|
await _moveThreadsTo(ref, accountThreads, mailbox.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every thread in [threads]. Each thread becomes its own undo entry
|
||||||
|
/// so the destination path remains per-thread (e.g. each account's Trash).
|
||||||
|
Future<void> batchDelete(
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
}) async {
|
||||||
|
if (threads.isEmpty) return;
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
for (final t in threads) {
|
||||||
|
final originalEmails = await _fetchOriginals(repo, t.emailIds);
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the user pick a destination folder and moves every thread there.
|
||||||
|
/// Cross-account selections show one picker per account; cancelled accounts
|
||||||
|
/// are skipped.
|
||||||
|
Future<void> batchMove(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
}) async {
|
||||||
|
if (threads.isEmpty) return;
|
||||||
|
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||||
|
|
||||||
|
final byAccount = _groupByAccount(threads);
|
||||||
|
for (final entry in byAccount.entries) {
|
||||||
|
final accountId = entry.key;
|
||||||
|
final accountThreads = entry.value;
|
||||||
|
final currentPath = accountThreads.first.mailboxPath;
|
||||||
|
|
||||||
|
final mailboxes = await mailboxRepo.observeMailboxes(accountId).first;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final destinations = mailboxes.where((m) => m.path != currentPath).toList();
|
||||||
|
|
||||||
|
final chosen = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
const ListTile(
|
||||||
|
title: Text(
|
||||||
|
'Move to…',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final m in destinations)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.folder_outlined),
|
||||||
|
title: Text(m.name),
|
||||||
|
onTap: () => Navigator.pop(ctx, m.path),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (chosen == null || !context.mounted) continue;
|
||||||
|
|
||||||
|
await _moveThreadsTo(ref, accountThreads, chosen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> batchSnooze(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref, {
|
||||||
|
required List<EmailThread> threads,
|
||||||
|
}) async {
|
||||||
|
if (threads.isEmpty) return;
|
||||||
|
final until = await showModalBottomSheet<DateTime>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => const SnoozePicker(),
|
||||||
|
);
|
||||||
|
if (until == null || !context.mounted) return;
|
||||||
|
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
var totalCount = 0;
|
||||||
|
|
||||||
|
for (final t in threads) {
|
||||||
|
final originalEmails = await _fetchOriginals(repo, t.emailIds);
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.snoozeEmail(id, until);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.snooze,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
totalCount += t.emailIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
duration: const Duration(seconds: 5),
|
||||||
|
content: Text(
|
||||||
|
'Snoozed $totalCount email${totalCount == 1 ? '' : 's'} until '
|
||||||
|
'${DateFormat('MMM d, HH:mm').format(until)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a swipe-to-archive (start→end) or swipe-to-delete (end→start) on a
|
||||||
|
/// single [thread]. Shared between folder and combined inbox surfaces.
|
||||||
|
Future<void> swipeDismissThread(
|
||||||
|
WidgetRef ref,
|
||||||
|
EmailThread thread,
|
||||||
|
DismissDirection direction,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
|
||||||
|
final originalEmails = await _fetchOriginals(repo, thread.emailIds);
|
||||||
|
|
||||||
|
if (direction == DismissDirection.startToEnd) {
|
||||||
|
final archive = await ref
|
||||||
|
.read(mailboxRepositoryProvider)
|
||||||
|
.findMailboxByRole(thread.accountId, 'archive');
|
||||||
|
if (archive == null) return;
|
||||||
|
for (final id in thread.emailIds) {
|
||||||
|
await repo.moveEmail(id, archive.path);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: thread.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: thread.emailIds,
|
||||||
|
sourceMailboxPath: thread.mailboxPath,
|
||||||
|
destinationMailboxPath: archive.path,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? lastDestPath;
|
||||||
|
for (final id in thread.emailIds) {
|
||||||
|
lastDestPath = await repo.deleteEmail(id);
|
||||||
|
}
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: thread.accountId,
|
||||||
|
type: UndoType.delete,
|
||||||
|
emailIds: thread.emailIds,
|
||||||
|
sourceMailboxPath: thread.mailboxPath,
|
||||||
|
destinationMailboxPath: lastDestPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Email>> _fetchOriginals(
|
||||||
|
EmailRepository repo,
|
||||||
|
Iterable<String> ids,
|
||||||
|
) async =>
|
||||||
|
(await Future.wait(ids.map((id) => repo.getEmail(id))))
|
||||||
|
.whereType<Email>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<String, List<EmailThread>> _groupByAccount(List<EmailThread> threads) {
|
||||||
|
final byAccount = <String, List<EmailThread>>{};
|
||||||
|
for (final t in threads) {
|
||||||
|
(byAccount[t.accountId] ??= []).add(t);
|
||||||
|
}
|
||||||
|
return byAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _moveThreadsTo(
|
||||||
|
WidgetRef ref,
|
||||||
|
List<EmailThread> threads,
|
||||||
|
String destPath,
|
||||||
|
) async {
|
||||||
|
final repo = ref.read(emailRepositoryProvider);
|
||||||
|
for (final t in threads) {
|
||||||
|
final originalEmails = await _fetchOriginals(repo, t.emailIds);
|
||||||
|
|
||||||
|
for (final id in t.emailIds) {
|
||||||
|
await repo.moveEmail(id, destPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
final action = UndoAction(
|
||||||
|
id: DateTime.now().toIso8601String(),
|
||||||
|
accountId: t.accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: t.emailIds,
|
||||||
|
sourceMailboxPath: t.mailboxPath,
|
||||||
|
destinationMailboxPath: destPath,
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
);
|
||||||
|
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
|||||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
|
// SnackBar defaults to persist=true when an action
|
||||||
|
// is set, which disables the auto-dismiss timer.
|
||||||
|
// Explicitly opt back into duration-based dismiss.
|
||||||
|
persist: false,
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,19 +3,14 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/account.dart';
|
import 'package:sharedinbox/core/models/account.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
|
||||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
|
||||||
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
|
||||||
|
|
||||||
class EmailListScreen extends ConsumerStatefulWidget {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -40,12 +35,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Error banner — tracks the last error message that the user dismissed.
|
// Error banner — tracks the last error message that the user dismissed.
|
||||||
String? _dismissedError;
|
String? _dismissedError;
|
||||||
|
|
||||||
// Thread-level selection (key = threadId).
|
late final EmailThreadListController _selection;
|
||||||
final Set<String> _selectedThreadIds = {};
|
|
||||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
|
||||||
List<EmailThread> _currentThreads = [];
|
|
||||||
// Individual email selection used in search results.
|
|
||||||
final Set<String> _selectedSearchIds = {};
|
|
||||||
|
|
||||||
// Pagination: number of threads currently requested from the DB.
|
// Pagination: number of threads currently requested from the DB.
|
||||||
static const _pageSize = 50;
|
static const _pageSize = 50;
|
||||||
@@ -59,12 +49,11 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Used to skip redundant re-runs when the user presses Enter on an
|
// Used to skip redundant re-runs when the user presses Enter on an
|
||||||
// already-settled search (issue #473).
|
// already-settled search (issue #473).
|
||||||
String? _lastSettledQuery;
|
String? _lastSettledQuery;
|
||||||
bool get _selecting =>
|
|
||||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||||
_searchController.addListener(() {
|
_searchController.addListener(() {
|
||||||
if (_searchController.text.isEmpty) {
|
if (_searchController.text.isEmpty) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -78,52 +67,15 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_selection
|
||||||
|
..removeListener(_onSelectionChange)
|
||||||
|
..dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleThreadSelection(EmailThread thread) {
|
void _onSelectionChange() {
|
||||||
setState(() {
|
if (mounted) setState(() {});
|
||||||
if (_selectedThreadIds.contains(thread.threadId)) {
|
|
||||||
_selectedThreadIds.remove(thread.threadId);
|
|
||||||
} else {
|
|
||||||
_selectedThreadIds.add(thread.threadId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _clearSelection() => setState(() {
|
|
||||||
_selectedThreadIds.clear();
|
|
||||||
_selectedSearchIds.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
void _selectAll() {
|
|
||||||
setState(() {
|
|
||||||
if (_searching) {
|
|
||||||
_selectedSearchIds.addAll(_searchResults?.map((e) => e.id) ?? []);
|
|
||||||
} else {
|
|
||||||
_selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggleSearchSelection(String emailId) {
|
|
||||||
setState(() {
|
|
||||||
if (_selectedSearchIds.contains(emailId)) {
|
|
||||||
_selectedSearchIds.remove(emailId);
|
|
||||||
} else {
|
|
||||||
_selectedSearchIds.add(emailId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// All email IDs for the current selection context.
|
|
||||||
List<String> get _selectedEmailIds {
|
|
||||||
if (_searching) return _selectedSearchIds.toList();
|
|
||||||
return _currentThreads
|
|
||||||
.where((t) => _selectedThreadIds.contains(t.threadId))
|
|
||||||
.expand((t) => t.emailIds)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runSearch(String query) async {
|
Future<void> _runSearch(String query) async {
|
||||||
@@ -170,17 +122,23 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
final prefs =
|
final prefs =
|
||||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||||
|
final selecting = _selection.isSelecting;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
||||||
drawer: _selecting
|
drawer: selecting
|
||||||
? null
|
? null
|
||||||
: FolderDrawer(
|
: FolderDrawer(
|
||||||
accountId: widget.accountId,
|
accountId: widget.accountId,
|
||||||
currentMailboxPath: widget.mailboxPath,
|
currentMailboxPath: widget.mailboxPath,
|
||||||
),
|
),
|
||||||
bottomNavigationBar: _selecting
|
bottomNavigationBar: selecting
|
||||||
? _selectionBottomBar()
|
? buildSelectionBottomBar(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
_selection,
|
||||||
|
onAfterAction: _onAfterBatchAction,
|
||||||
|
)
|
||||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
: (menuAtBottom ? _folderNavBottomBar() : null),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -200,67 +158,52 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
AsyncValue<Account?> accountAsync, {
|
AsyncValue<Account?> accountAsync, {
|
||||||
required bool menuAtBottom,
|
required bool menuAtBottom,
|
||||||
}) {
|
}) {
|
||||||
final selectionCount =
|
if (_selection.isSelecting) {
|
||||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
return buildSelectionAppBar(_selection);
|
||||||
|
}
|
||||||
|
|
||||||
return AppBar(
|
return AppBar(
|
||||||
automaticallyImplyLeading: !menuAtBottom,
|
automaticallyImplyLeading: !menuAtBottom,
|
||||||
leading: _selecting
|
title: Text(widget.mailboxPath),
|
||||||
? IconButton(
|
actions: [
|
||||||
icon: const Icon(Icons.close),
|
accountAsync.when(
|
||||||
onPressed: _clearSelection,
|
loading: () => const SizedBox.shrink(),
|
||||||
)
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
: null,
|
data: (account) => Padding(
|
||||||
title: _selecting
|
padding: const EdgeInsets.only(right: 4),
|
||||||
? Text('$selectionCount selected')
|
child: Center(
|
||||||
: Text(widget.mailboxPath),
|
child: Text(
|
||||||
actions: _selecting
|
account?.displayName ?? '',
|
||||||
? [
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.select_all),
|
|
||||||
tooltip: 'Select all',
|
|
||||||
onPressed: _selectAll,
|
|
||||||
),
|
),
|
||||||
]
|
),
|
||||||
: [
|
),
|
||||||
accountAsync.when(
|
),
|
||||||
loading: () => const SizedBox.shrink(),
|
_buildSyncButton(emailRepo),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
IconButton(
|
||||||
data: (account) => Padding(
|
icon: const Icon(Icons.edit),
|
||||||
padding: const EdgeInsets.only(right: 4),
|
onPressed: () => context.push(
|
||||||
child: Center(
|
'/compose',
|
||||||
child: Text(
|
extra: {'accountId': widget.accountId},
|
||||||
account?.displayName ?? '',
|
),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
),
|
||||||
),
|
PopupMenuButton<String>(
|
||||||
),
|
onSelected: (value) async {
|
||||||
),
|
if (value == 'mark_all_read') {
|
||||||
),
|
await emailRepo.markAllAsRead(
|
||||||
_buildSyncButton(emailRepo),
|
widget.accountId,
|
||||||
IconButton(
|
widget.mailboxPath,
|
||||||
icon: const Icon(Icons.edit),
|
);
|
||||||
onPressed: () => context.push(
|
}
|
||||||
'/compose',
|
},
|
||||||
extra: {'accountId': widget.accountId},
|
itemBuilder: (_) => const [
|
||||||
),
|
PopupMenuItem(
|
||||||
),
|
value: 'mark_all_read',
|
||||||
PopupMenuButton<String>(
|
child: Text('Mark all as read'),
|
||||||
onSelected: (value) async {
|
),
|
||||||
if (value == 'mark_all_read') {
|
],
|
||||||
await emailRepo.markAllAsRead(
|
),
|
||||||
widget.accountId,
|
],
|
||||||
widget.mailboxPath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
itemBuilder: (_) => const [
|
|
||||||
PopupMenuItem(
|
|
||||||
value: 'mark_all_read',
|
|
||||||
child: Text('Mark all as read'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -269,9 +212,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
hintText: 'Search…',
|
hintText: 'Search…',
|
||||||
leading: const Icon(Icons.search),
|
leading: const Icon(Icons.search),
|
||||||
enabled: !_selecting,
|
|
||||||
trailing: [
|
trailing: [
|
||||||
if (_searchController.text.isNotEmpty && !_selecting)
|
if (_searchController.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () => _searchController.clear(),
|
onPressed: () => _searchController.clear(),
|
||||||
@@ -350,41 +292,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _selectionBottomBar() {
|
|
||||||
return BottomAppBar(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.archive),
|
|
||||||
tooltip: 'Archive',
|
|
||||||
onPressed: _batchArchive,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: 'Delete',
|
|
||||||
onPressed: _batchDelete,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.report),
|
|
||||||
tooltip: 'Mark as spam',
|
|
||||||
onPressed: _batchMarkSpam,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.drive_file_move),
|
|
||||||
tooltip: 'Move to folder',
|
|
||||||
onPressed: _batchMove,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.access_time),
|
|
||||||
tooltip: 'Snooze',
|
|
||||||
onPressed: _batchSnooze,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSearchBody() {
|
Widget _buildSearchBody() {
|
||||||
if (_searchLoading) {
|
if (_searchLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -395,7 +302,13 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
if (_searchResults!.isEmpty) {
|
if (_searchResults!.isEmpty) {
|
||||||
return const Center(child: Text('No results'));
|
return const Center(child: Text('No results'));
|
||||||
}
|
}
|
||||||
return _buildEmailList(_searchResults!);
|
final threads = _searchResults!.map(EmailThread.fromEmail).toList();
|
||||||
|
return EmailThreadList(
|
||||||
|
controller: _selection,
|
||||||
|
items: threads,
|
||||||
|
enableSwipe: false,
|
||||||
|
onTap: (t) => unawaited(_openSearchResultAndRefresh(t.latestEmailId)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSyncErrorBanner() {
|
Widget _buildSyncErrorBanner() {
|
||||||
@@ -440,100 +353,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
// Also wait for this specific mailbox to sync for immediate feedback.
|
// Also wait for this specific mailbox to sync for immediate feedback.
|
||||||
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
||||||
},
|
},
|
||||||
child: StreamBuilder<List<EmailThread>>(
|
child: EmailThreadList(
|
||||||
|
controller: _selection,
|
||||||
stream: emailRepo.observeThreads(
|
stream: emailRepo.observeThreads(
|
||||||
widget.accountId,
|
widget.accountId,
|
||||||
widget.mailboxPath,
|
widget.mailboxPath,
|
||||||
limit: _limit,
|
limit: _limit,
|
||||||
),
|
),
|
||||||
builder: (ctx, snap) {
|
enablePagination: true,
|
||||||
if (!snap.hasData) {
|
onLoadMore: () => setState(() => _limit += _pageSize),
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
final threads = snap.data!;
|
|
||||||
_currentThreads = threads;
|
|
||||||
if (threads.isEmpty) {
|
|
||||||
return ListView(
|
|
||||||
children: const [
|
|
||||||
SizedBox(height: 300, child: Center(child: Text('No emails'))),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return _buildThreadList(threads);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchMoveToRole(
|
|
||||||
String role, {
|
|
||||||
required String dialogTitle,
|
|
||||||
required String createFolderName,
|
|
||||||
}) async {
|
|
||||||
final ids = _selectedEmailIds;
|
|
||||||
_clearSelection();
|
|
||||||
|
|
||||||
final mailbox = await resolveMailboxByRole(
|
|
||||||
context,
|
|
||||||
ref.read(mailboxRepositoryProvider),
|
|
||||||
widget.accountId,
|
|
||||||
widget.mailboxPath,
|
|
||||||
role,
|
|
||||||
dialogTitle: dialogTitle,
|
|
||||||
createFolderName: createFolderName,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted || mailbox == null) return;
|
|
||||||
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
|
|
||||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
ids.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final id in ids) {
|
|
||||||
await repo.moveEmail(id, mailbox.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: ids,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: mailbox.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _batchArchive() => _batchMoveToRole(
|
|
||||||
'archive',
|
|
||||||
dialogTitle: 'No archive folder found',
|
|
||||||
createFolderName: 'Archive',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _refreshSearchAndPopIfEmpty() async {
|
|
||||||
if (!mounted || !_searching) return;
|
|
||||||
final query = _searchController.text.trim();
|
|
||||||
final remaining = await ref
|
|
||||||
.read(emailRepositoryProvider)
|
|
||||||
.searchEmails(widget.accountId, widget.mailboxPath, query);
|
|
||||||
if (!mounted) return;
|
|
||||||
if (remaining.isEmpty) {
|
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
} else {
|
|
||||||
_searchController.clear();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setState(() => _searchResults = remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openSearchResultAndRefresh(String emailId) async {
|
Future<void> _openSearchResultAndRefresh(String emailId) async {
|
||||||
await context.push(
|
await context.push(
|
||||||
'/accounts/${widget.accountId}/mailboxes'
|
'/accounts/${widget.accountId}/mailboxes'
|
||||||
@@ -543,279 +375,42 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
await _refreshSearchAndPopIfEmpty();
|
await _refreshSearchAndPopIfEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchDelete() async {
|
Future<void> _refreshSearchAndPopIfEmpty() async {
|
||||||
final ids = _selectedEmailIds;
|
if (!mounted || !_searching) return;
|
||||||
final wasSearching = _searching;
|
final query = _searchController.text.trim();
|
||||||
_clearSelection();
|
final remaining = await ref
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
.read(emailRepositoryProvider)
|
||||||
|
.searchEmails(widget.accountId, widget.mailboxPath, query);
|
||||||
// Fetch full email data before deleting so we can restore them if user clicks Undo.
|
|
||||||
// This is especially important for IMAP where we hard-delete the row locally.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
ids.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in ids) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: UndoType.delete,
|
|
||||||
emailIds: ids,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
|
|
||||||
if (wasSearching && mounted) {
|
|
||||||
// Filter deleted emails out of the local results immediately.
|
|
||||||
// Calling searchEmails here would still return deleted rows because the
|
|
||||||
// delete is only enqueued — not yet applied to the local DB.
|
|
||||||
final deletedIds = ids.toSet();
|
|
||||||
final remaining = (_searchResults ?? [])
|
|
||||||
.where((e) => !deletedIds.contains(e.id))
|
|
||||||
.toList();
|
|
||||||
if (remaining.isEmpty) {
|
|
||||||
if (context.canPop()) {
|
|
||||||
context.pop();
|
|
||||||
} else {
|
|
||||||
_searchController.clear();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setState(() => _searchResults = remaining);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _batchMarkSpam() => _batchMoveToRole(
|
|
||||||
'junk',
|
|
||||||
dialogTitle: 'No spam folder found',
|
|
||||||
createFolderName: 'Junk',
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _batchMove() async {
|
|
||||||
final ids = _selectedEmailIds;
|
|
||||||
final mailboxes = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
|
||||||
.observeMailboxes(widget.accountId)
|
|
||||||
.first;
|
|
||||||
final destinations =
|
|
||||||
mailboxes.where((m) => m.path != widget.mailboxPath).toList();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (remaining.isEmpty) {
|
||||||
final chosen = await showModalBottomSheet<String>(
|
if (context.canPop()) {
|
||||||
context: context,
|
context.pop();
|
||||||
builder: (ctx) => ListView(
|
return;
|
||||||
shrinkWrap: true,
|
|
||||||
children: [
|
|
||||||
const ListTile(
|
|
||||||
title: Text(
|
|
||||||
'Move to…',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final m in destinations)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.folder_outlined),
|
|
||||||
title: Text(m.name),
|
|
||||||
onTap: () => Navigator.pop(ctx, m.path),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (chosen == null || !mounted) return;
|
|
||||||
_clearSelection();
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
|
|
||||||
// Fetch full email data before moving so we can restore them if user clicks Undo.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
ids.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final id in ids) {
|
|
||||||
await repo.moveEmail(id, chosen);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: UndoType.move,
|
|
||||||
emailIds: ids,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: chosen,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _batchSnooze() async {
|
|
||||||
final ids = _selectedEmailIds;
|
|
||||||
final until = await showModalBottomSheet<DateTime>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => const SnoozePicker(),
|
|
||||||
);
|
|
||||||
if (until == null || !mounted) return;
|
|
||||||
|
|
||||||
_clearSelection();
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
// Fetch full email data before snoozing so we can restore them if user clicks Undo.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
ids.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
for (final id in ids) {
|
|
||||||
await repo.snoozeEmail(id, until);
|
|
||||||
}
|
|
||||||
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: UndoType.snooze,
|
|
||||||
emailIds: ids,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
duration: const Duration(seconds: 5),
|
|
||||||
content: Text(
|
|
||||||
'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildThreadList(List<EmailThread> threads) {
|
|
||||||
final hasMore = threads.length == _limit;
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: threads.length + (hasMore ? 1 : 0),
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
if (i == threads.length) {
|
|
||||||
return TextButton(
|
|
||||||
onPressed: () => setState(() => _limit += _pageSize),
|
|
||||||
child: const Text('Load more'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final t = threads[i];
|
|
||||||
return EmailThreadTile(
|
|
||||||
thread: t,
|
|
||||||
isSelected: _selectedThreadIds.contains(t.threadId),
|
|
||||||
isSelecting: _selecting,
|
|
||||||
onTap: _selecting
|
|
||||||
? () => _toggleThreadSelection(t)
|
|
||||||
: t.messageCount > 1
|
|
||||||
? () => context.push(
|
|
||||||
'/accounts/${widget.accountId}/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
|
||||||
'/threads/${Uri.encodeComponent(t.threadId)}',
|
|
||||||
)
|
|
||||||
: () => context.push(
|
|
||||||
'/accounts/${widget.accountId}/mailboxes'
|
|
||||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
|
||||||
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
|
||||||
),
|
|
||||||
onLongPress: () => _toggleThreadSelection(t),
|
|
||||||
onDismissed: (direction) => _onSwipeDismissed(t, direction),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSwipeDismissed(
|
|
||||||
EmailThread t,
|
|
||||||
DismissDirection direction,
|
|
||||||
) async {
|
|
||||||
final repo = ref.read(emailRepositoryProvider);
|
|
||||||
final type = direction == DismissDirection.startToEnd
|
|
||||||
? UndoType.move
|
|
||||||
: UndoType.delete;
|
|
||||||
|
|
||||||
// Fetch full email data before moving/deleting.
|
|
||||||
final originalEmails = (await Future.wait(
|
|
||||||
t.emailIds.map((id) => repo.getEmail(id)),
|
|
||||||
))
|
|
||||||
.whereType<Email>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
if (direction == DismissDirection.startToEnd) {
|
|
||||||
final archive = await ref
|
|
||||||
.read(mailboxRepositoryProvider)
|
|
||||||
.findMailboxByRole(widget.accountId, 'archive');
|
|
||||||
if (!mounted || archive == null) return;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
await repo.moveEmail(id, archive.path);
|
|
||||||
}
|
}
|
||||||
final action = UndoAction(
|
_searchController.clear();
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: archive.path,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setState(() => _searchResults = remaining);
|
||||||
String? lastDestPath;
|
|
||||||
for (final id in t.emailIds) {
|
|
||||||
lastDestPath = await repo.deleteEmail(id);
|
|
||||||
}
|
|
||||||
final action = UndoAction(
|
|
||||||
id: DateTime.now().toIso8601String(),
|
|
||||||
accountId: widget.accountId,
|
|
||||||
type: type,
|
|
||||||
emailIds: t.emailIds,
|
|
||||||
sourceMailboxPath: widget.mailboxPath,
|
|
||||||
destinationMailboxPath: lastDestPath,
|
|
||||||
originalEmails: originalEmails,
|
|
||||||
);
|
|
||||||
unawaited(ref.read(undoServiceProvider.notifier).pushAction(action));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used for search results, which are individual emails.
|
void _onAfterBatchAction(List<String> actedThreadIds) {
|
||||||
Widget _buildEmailList(List<Email> emails) {
|
if (!_searching || !mounted) return;
|
||||||
return ListView.builder(
|
|
||||||
itemCount: emails.length,
|
// Filter acted-on emails out of the local results immediately. Calling
|
||||||
itemBuilder: (ctx, i) {
|
// searchEmails would still return them because the delete is only
|
||||||
final e = emails[i];
|
// enqueued — not yet applied to the local DB.
|
||||||
final t = EmailThread.fromEmail(e);
|
final actedSet = actedThreadIds.toSet();
|
||||||
final isSelected = _selectedSearchIds.contains(e.id);
|
final remaining = (_searchResults ?? [])
|
||||||
return ThreadTile(
|
.where((e) => !actedSet.contains(e.threadId ?? e.id))
|
||||||
thread: t,
|
.toList();
|
||||||
selected: isSelected,
|
if (remaining.isEmpty) {
|
||||||
leading: SizedBox(
|
if (context.canPop()) {
|
||||||
width: 40,
|
context.pop();
|
||||||
child: _selecting
|
return;
|
||||||
? Checkbox(
|
}
|
||||||
value: isSelected,
|
_searchController.clear();
|
||||||
onChanged: (_) => _toggleSearchSelection(e.id),
|
return;
|
||||||
)
|
}
|
||||||
: null,
|
setState(() => _searchResults = remaining);
|
||||||
),
|
|
||||||
onTap: _selecting
|
|
||||||
? () => _toggleSearchSelection(e.id)
|
|
||||||
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
|
||||||
onLongPress: () => _toggleSearchSelection(e.id),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
|
// SnackBar defaults to persist=true when an
|
||||||
|
// action is set, which disables auto-dismiss.
|
||||||
|
// Explicitly opt into duration-based dismiss.
|
||||||
|
persist: false,
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'Images will be loaded automatically for this sender.',
|
'Images will be loaded automatically for this sender.',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
@@ -93,7 +94,9 @@ class UndoLogDetailScreen extends ConsumerWidget {
|
|||||||
style: theme.textTheme.bodySmall,
|
style: theme.textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
...action.originalEmails.map(
|
||||||
|
(email) => _EmailTile(email: email, accountId: action.accountId),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,13 +123,14 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmailTile extends StatelessWidget {
|
class _EmailTile extends ConsumerWidget {
|
||||||
const _EmailTile({required this.email});
|
const _EmailTile({required this.email, required this.accountId});
|
||||||
|
|
||||||
final Email email;
|
final Email email;
|
||||||
|
final String accountId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sender = email.from.isNotEmpty
|
final sender = email.from.isNotEmpty
|
||||||
? (email.from.first.name ?? email.from.first.email)
|
? (email.from.first.name ?? email.from.first.email)
|
||||||
: '(Unknown Sender)';
|
: '(Unknown Sender)';
|
||||||
@@ -134,6 +138,43 @@ class _EmailTile extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.email_outlined),
|
leading: const Icon(Icons.email_outlined),
|
||||||
title: Text(email.subject ?? '(No Subject)'),
|
title: Text(email.subject ?? '(No Subject)'),
|
||||||
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
subtitle: Text(sender, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => _openEmail(context, ref),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openEmail(BuildContext context, WidgetRef ref) async {
|
||||||
|
final messageId = email.messageId;
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
if (messageId == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text('Cannot locate this email — no Message-ID.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final found = await ref
|
||||||
|
.read(emailRepositoryProvider)
|
||||||
|
.findEmailByMessageId(accountId, messageId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (found == null) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
content: Text(
|
||||||
|
'Email no longer exists at its previous location. '
|
||||||
|
'Use Undo to restore it.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.go(
|
||||||
|
'/accounts/$accountId'
|
||||||
|
'/mailboxes/${Uri.encodeComponent(found.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(found.id)}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show listEquals;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
|
||||||
|
|
||||||
|
/// Controller for [EmailThreadList].
|
||||||
|
///
|
||||||
|
/// Holds the current selection set and the last-seen thread list, so the host
|
||||||
|
/// screen can listen for selection-mode changes (to swap AppBars, hide the
|
||||||
|
/// drawer, etc.) and read [selectedThreads] when wiring batch-action buttons.
|
||||||
|
class EmailThreadListController extends ChangeNotifier {
|
||||||
|
final Set<String> _selected = <String>{};
|
||||||
|
List<EmailThread> _threads = const [];
|
||||||
|
|
||||||
|
/// All threads currently rendered (latest stream emission or static input).
|
||||||
|
List<EmailThread> get visibleThreads => List.unmodifiable(_threads);
|
||||||
|
|
||||||
|
/// Threads whose `threadId` is selected (preserving the list's order).
|
||||||
|
List<EmailThread> get selectedThreads =>
|
||||||
|
_threads.where((t) => _selected.contains(t.threadId)).toList();
|
||||||
|
|
||||||
|
Set<String> get selectedIds => Set.unmodifiable(_selected);
|
||||||
|
|
||||||
|
bool get isSelecting => _selected.isNotEmpty;
|
||||||
|
int get selectionCount => _selected.length;
|
||||||
|
|
||||||
|
bool isSelected(EmailThread t) => _selected.contains(t.threadId);
|
||||||
|
|
||||||
|
void toggle(EmailThread t) {
|
||||||
|
if (!_selected.add(t.threadId)) {
|
||||||
|
_selected.remove(t.threadId);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
if (_selected.isEmpty) return;
|
||||||
|
_selected.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectAll() {
|
||||||
|
final before = _selected.length;
|
||||||
|
_selected.addAll(_threads.map((t) => t.threadId));
|
||||||
|
if (_selected.length != before) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by [EmailThreadList] whenever the visible threads change. Drops
|
||||||
|
/// any selected ids that no longer appear in the list. Hosts should not
|
||||||
|
/// call this directly.
|
||||||
|
void updateThreads(List<EmailThread> threads) {
|
||||||
|
_threads = threads;
|
||||||
|
final visibleIds = threads.map((t) => t.threadId).toSet();
|
||||||
|
final before = _selected.length;
|
||||||
|
_selected.retainAll(visibleIds);
|
||||||
|
if (_selected.length != before) notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unified list of email threads used by folder, combined-inbox, search and
|
||||||
|
/// address-emails views.
|
||||||
|
///
|
||||||
|
/// Renders selection-mode checkboxes, optional swipe-to-archive/delete and
|
||||||
|
/// optional pagination. Selection state lives in [controller]; the host screen
|
||||||
|
/// listens to it to swap its AppBar / BottomBar for selection-mode equivalents
|
||||||
|
/// (see [buildSelectionAppBar] / [buildSelectionBottomBar]).
|
||||||
|
///
|
||||||
|
/// Provide exactly one of [stream] (live data) or [items] (static list, used
|
||||||
|
/// for search / by-address results).
|
||||||
|
class EmailThreadList extends ConsumerStatefulWidget {
|
||||||
|
const EmailThreadList({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.stream,
|
||||||
|
this.items,
|
||||||
|
this.enableSwipe = true,
|
||||||
|
this.enablePagination = false,
|
||||||
|
this.pageSize = 50,
|
||||||
|
this.showAccountLabel = false,
|
||||||
|
this.showLocationLabel = false,
|
||||||
|
this.accountNames = const {},
|
||||||
|
this.onTap,
|
||||||
|
this.onLoadMore,
|
||||||
|
this.emptyMessage = 'No emails',
|
||||||
|
}) : assert(
|
||||||
|
(stream == null) != (items == null),
|
||||||
|
'Provide exactly one of stream or items',
|
||||||
|
);
|
||||||
|
|
||||||
|
final EmailThreadListController controller;
|
||||||
|
|
||||||
|
/// Live thread source (folder view, combined inbox). Mutually exclusive with
|
||||||
|
/// [items].
|
||||||
|
final Stream<List<EmailThread>>? stream;
|
||||||
|
|
||||||
|
/// Static thread list (search results, by-address). Mutually exclusive with
|
||||||
|
/// [stream].
|
||||||
|
final List<EmailThread>? items;
|
||||||
|
|
||||||
|
/// When true, threads can be swiped to archive (start→end) or delete
|
||||||
|
/// (end→start). Disabled for search-result lists where a swipe would
|
||||||
|
/// silently drop an item from a filtered view.
|
||||||
|
final bool enableSwipe;
|
||||||
|
|
||||||
|
/// When true, the list shows a "Load more" button once the visible count
|
||||||
|
/// equals the current page size.
|
||||||
|
final bool enablePagination;
|
||||||
|
|
||||||
|
/// Page size for [enablePagination].
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
/// Show an extra subtitle line with the account name (combined inbox).
|
||||||
|
/// Looked up in [accountNames] keyed by `accountId`.
|
||||||
|
final bool showAccountLabel;
|
||||||
|
final Map<String, String> accountNames;
|
||||||
|
|
||||||
|
/// Show a per-tile location label ("accountId • mailboxPath"). Used by
|
||||||
|
/// global search results.
|
||||||
|
final bool showLocationLabel;
|
||||||
|
|
||||||
|
/// Optional tap handler. When null, the default navigates to the email or
|
||||||
|
/// thread detail route based on `messageCount`.
|
||||||
|
final ValueChanged<EmailThread>? onTap;
|
||||||
|
|
||||||
|
/// Notification fired when the user taps "Load more". Hosts that use a
|
||||||
|
/// stream can grow their `limit` here.
|
||||||
|
final VoidCallback? onLoadMore;
|
||||||
|
|
||||||
|
/// Message shown when the list is empty.
|
||||||
|
final String emptyMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EmailThreadList> createState() => _EmailThreadListState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmailThreadListState extends ConsumerState<EmailThreadList> {
|
||||||
|
int _limit = 50;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_limit = widget.pageSize;
|
||||||
|
widget.controller.addListener(_onControllerChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(EmailThreadList oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!identical(oldWidget.controller, widget.controller)) {
|
||||||
|
oldWidget.controller.removeListener(_onControllerChange);
|
||||||
|
widget.controller.addListener(_onControllerChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onControllerChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onControllerChange() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _publishThreads(List<EmailThread> threads) {
|
||||||
|
if (listEquals(threads, widget.controller.visibleThreads)) return;
|
||||||
|
// Defer so we don't notifyListeners during a build phase.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.controller.updateThreads(threads);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (widget.items != null) {
|
||||||
|
return _buildList(widget.items!);
|
||||||
|
}
|
||||||
|
return StreamBuilder<List<EmailThread>>(
|
||||||
|
stream: widget.stream,
|
||||||
|
builder: (ctx, snap) {
|
||||||
|
if (!snap.hasData) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return _buildList(snap.data!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(List<EmailThread> threads) {
|
||||||
|
_publishThreads(threads);
|
||||||
|
if (threads.isEmpty) {
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 300,
|
||||||
|
child: Center(child: Text(widget.emptyMessage)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final hasMore = widget.enablePagination && threads.length == _limit;
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: threads.length + (hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == threads.length) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setState(() => _limit += widget.pageSize);
|
||||||
|
widget.onLoadMore?.call();
|
||||||
|
},
|
||||||
|
child: const Text('Load more'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _tileFor(threads[i]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _tileFor(EmailThread t) {
|
||||||
|
final isSelected = widget.controller.isSelected(t);
|
||||||
|
final isSelecting = widget.controller.isSelecting;
|
||||||
|
final accountName = widget.accountNames[t.accountId];
|
||||||
|
final locationLabel = widget.showLocationLabel
|
||||||
|
? '${t.accountId} • ${t.mailboxPath}'
|
||||||
|
: widget.showAccountLabel
|
||||||
|
? accountName
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final tile = ThreadTile(
|
||||||
|
thread: t,
|
||||||
|
selected: isSelected,
|
||||||
|
locationLabel: locationLabel,
|
||||||
|
leading: isSelecting
|
||||||
|
? SizedBox(
|
||||||
|
width: 40,
|
||||||
|
child: Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (_) => widget.controller.toggle(t),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => _onTileTap(t),
|
||||||
|
onLongPress: () => widget.controller.toggle(t),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!widget.enableSwipe) return tile;
|
||||||
|
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('${t.accountId}:${t.threadId}'),
|
||||||
|
direction:
|
||||||
|
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
||||||
|
background: _swipeBackground(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
color: Colors.green,
|
||||||
|
icon: Icons.archive,
|
||||||
|
label: 'Archive',
|
||||||
|
),
|
||||||
|
secondaryBackground: _swipeBackground(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
color: Colors.red,
|
||||||
|
icon: Icons.delete,
|
||||||
|
label: 'Delete',
|
||||||
|
),
|
||||||
|
onDismissed: (direction) =>
|
||||||
|
unawaited(swipeDismissThread(ref, t, direction)),
|
||||||
|
child: tile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTileTap(EmailThread t) {
|
||||||
|
if (widget.controller.isSelecting) {
|
||||||
|
widget.controller.toggle(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (widget.onTap != null) {
|
||||||
|
widget.onTap!(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t.messageCount > 1) {
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/threads/${Uri.encodeComponent(t.threadId)}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(
|
||||||
|
context.push(
|
||||||
|
'/accounts/${t.accountId}/mailboxes'
|
||||||
|
'/${Uri.encodeComponent(t.mailboxPath)}'
|
||||||
|
'/emails/${Uri.encodeComponent(t.latestEmailId)}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget _swipeBackground({
|
||||||
|
required AlignmentGeometry alignment,
|
||||||
|
required Color color,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
color: color,
|
||||||
|
alignment: alignment,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label, style: const TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard "N selected" AppBar with X-close and select-all actions.
|
||||||
|
PreferredSizeWidget buildSelectionAppBar(EmailThreadListController controller) {
|
||||||
|
return AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: controller.clear,
|
||||||
|
),
|
||||||
|
title: Text('${controller.selectionCount} selected'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.select_all),
|
||||||
|
tooltip: 'Select all',
|
||||||
|
onPressed: controller.selectAll,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Standard batch-action BottomAppBar.
|
||||||
|
///
|
||||||
|
/// [onAfterAction] runs after the helper finishes and the selection is
|
||||||
|
/// cleared. It receives the list of thread IDs that were targeted so the host
|
||||||
|
/// can refresh static result lists (e.g. search results) and pop if empty.
|
||||||
|
Widget buildSelectionBottomBar(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
EmailThreadListController controller, {
|
||||||
|
bool includeArchive = true,
|
||||||
|
bool includeDelete = true,
|
||||||
|
bool includeSpam = true,
|
||||||
|
bool includeMove = true,
|
||||||
|
bool includeSnooze = true,
|
||||||
|
void Function(List<String> actedThreadIds)? onAfterAction,
|
||||||
|
}) {
|
||||||
|
void run(Future<void> Function() body) {
|
||||||
|
final actedIds = controller.selectedThreads.map((t) => t.threadId).toList();
|
||||||
|
unawaited(() async {
|
||||||
|
await body();
|
||||||
|
controller.clear();
|
||||||
|
onAfterAction?.call(actedIds);
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
return BottomAppBar(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
if (includeArchive)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.archive),
|
||||||
|
tooltip: 'Archive',
|
||||||
|
onPressed: () => run(
|
||||||
|
() => batchArchive(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: controller.selectedThreads,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (includeDelete)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: () => run(
|
||||||
|
() => batchDelete(ref, threads: controller.selectedThreads),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (includeSpam)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.report),
|
||||||
|
tooltip: 'Mark as spam',
|
||||||
|
onPressed: () => run(
|
||||||
|
() => batchMarkSpam(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: controller.selectedThreads,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (includeMove)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_move),
|
||||||
|
tooltip: 'Move to folder',
|
||||||
|
onPressed: () => run(
|
||||||
|
() => batchMove(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: controller.selectedThreads,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (includeSnooze)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.access_time),
|
||||||
|
tooltip: 'Snooze',
|
||||||
|
onPressed: () => run(
|
||||||
|
() => batchSnooze(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
threads: controller.selectedThreads,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
|
|
||||||
import 'package:sharedinbox/core/models/email.dart';
|
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
|
||||||
final _formattedDates = <int, String>{};
|
|
||||||
|
|
||||||
int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day;
|
|
||||||
|
|
||||||
String _fmtDate(DateTime dt) =>
|
|
||||||
_formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt);
|
|
||||||
|
|
||||||
/// A swipeable list tile for an [EmailThread].
|
|
||||||
///
|
|
||||||
/// Handles the [Dismissible] wrapper (archive left, delete right) and
|
|
||||||
/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle
|
|
||||||
/// line with the account name — used in the combined-inbox view.
|
|
||||||
class EmailThreadTile extends StatelessWidget {
|
|
||||||
const EmailThreadTile({
|
|
||||||
super.key,
|
|
||||||
required this.thread,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.isSelecting,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onLongPress,
|
|
||||||
required this.onDismissed,
|
|
||||||
this.showAccount = false,
|
|
||||||
this.accountName,
|
|
||||||
});
|
|
||||||
|
|
||||||
final EmailThread thread;
|
|
||||||
final bool isSelected;
|
|
||||||
final bool isSelecting;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback onLongPress;
|
|
||||||
final Future<void> Function(DismissDirection) onDismissed;
|
|
||||||
|
|
||||||
/// When true, renders an extra subtitle line with [accountName].
|
|
||||||
final bool showAccount;
|
|
||||||
final String? accountName;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final t = thread;
|
|
||||||
final senderNames =
|
|
||||||
t.participants.map((a) => a.name ?? a.email).take(3).join(', ');
|
|
||||||
|
|
||||||
final tile = ListTile(
|
|
||||||
leading: SizedBox(
|
|
||||||
width: 40,
|
|
||||||
child: isSelecting
|
|
||||||
? Checkbox(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (_) => onTap(),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
t.hasUnread ? Icons.mail : Icons.mail_outline,
|
|
||||||
color:
|
|
||||||
t.hasUnread ? Theme.of(context).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
senderNames.isEmpty ? '(unknown)' : senderNames,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (t.messageCount > 1)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4),
|
|
||||||
child: Text(
|
|
||||||
'[${t.messageCount}]',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.subject ?? '(no subject)',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: t.hasUnread
|
|
||||||
? const TextStyle(fontWeight: FontWeight.bold)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (t.preview != null && t.preview!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
t.preview!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
if (showAccount && accountName != null)
|
|
||||||
Text(
|
|
||||||
accountName!,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
selected: isSelected,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (t.isFlagged)
|
|
||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
_fmtDate(t.latestDate),
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Dismissible(
|
|
||||||
key: ValueKey('${t.accountId}:${t.threadId}'),
|
|
||||||
direction:
|
|
||||||
isSelecting ? DismissDirection.none : DismissDirection.horizontal,
|
|
||||||
background: _swipeBackground(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
color: Colors.green,
|
|
||||||
icon: Icons.archive,
|
|
||||||
label: 'Archive',
|
|
||||||
),
|
|
||||||
secondaryBackground: _swipeBackground(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
color: Colors.red,
|
|
||||||
icon: Icons.delete,
|
|
||||||
label: 'Delete',
|
|
||||||
),
|
|
||||||
onDismissed: onDismissed,
|
|
||||||
child: tile,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Widget _swipeBackground({
|
|
||||||
required AlignmentGeometry alignment,
|
|
||||||
required Color color,
|
|
||||||
required IconData icon,
|
|
||||||
required String label,
|
|
||||||
}) {
|
|
||||||
return Container(
|
|
||||||
color: color,
|
|
||||||
alignment: alignment,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: Colors.white),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(label, style: const TextStyle(color: Colors.white)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,11 +84,11 @@ const _excluded = {
|
|||||||
'lib/data/repositories/user_preferences_repository_impl.dart',
|
'lib/data/repositories/user_preferences_repository_impl.dart',
|
||||||
'lib/ui/screens/user_preferences_screen.dart',
|
'lib/ui/screens/user_preferences_screen.dart',
|
||||||
'lib/core/services/update_service.dart',
|
'lib/core/services/update_service.dart',
|
||||||
'lib/ui/widgets/email_thread_tile.dart',
|
|
||||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||||
'lib/data/repositories/note_repository_impl.dart',
|
'lib/data/repositories/note_repository_impl.dart',
|
||||||
'lib/ui/widgets/filter_builder.dart',
|
'lib/ui/widgets/filter_builder.dart',
|
||||||
'lib/ui/widgets/thread_tile.dart',
|
'lib/ui/widgets/thread_tile.dart',
|
||||||
|
'lib/ui/widgets/email_thread_list.dart',
|
||||||
};
|
};
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that the Dagger version is consistent across the project.
|
||||||
|
#
|
||||||
|
# The Dagger CLI must speak the same protocol as the engine it talks to. We
|
||||||
|
# pin the version in four places (engine image in DAGGER.md, the CLI in
|
||||||
|
# flake.nix, the CLI in the Forgejo runner Dockerfile, and the module
|
||||||
|
# engineVersion in ci/dagger.json). This script fails if any of them drift.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT=$(git rev-parse --show-toplevel)
|
||||||
|
|
||||||
|
# ci/dagger.json — strip leading "v" for comparison.
|
||||||
|
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
|
||||||
|
| sed -E 's/.*"v?([^"]+)"$/\1/')
|
||||||
|
|
||||||
|
# .forgejo/Dockerfile — DAGGER_VERSION env on the install line.
|
||||||
|
dockerfile=$(grep -oE 'DAGGER_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/.forgejo/Dockerfile" \
|
||||||
|
| head -n1 \
|
||||||
|
| cut -d= -f2)
|
||||||
|
|
||||||
|
# DAGGER.md — engine image tag in the example systemd unit.
|
||||||
|
dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
|
||||||
|
| head -n1 \
|
||||||
|
| sed -E 's@.*/v@@')
|
||||||
|
|
||||||
|
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
|
||||||
|
printf '.forgejo/Dockerf. DAGGER_VERSION= %s\n' "$dockerfile"
|
||||||
|
printf 'DAGGER.md engine tag = v%s\n' "$dagger_md"
|
||||||
|
|
||||||
|
for v in "$dockerfile" "$dagger_md"; do
|
||||||
|
if [ -z "$v" ]; then
|
||||||
|
echo "ERROR: failed to parse a Dagger version reference." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$v" != "$dagger_json" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo "ERROR: Dagger versions are out of sync." >&2
|
||||||
|
echo " Align ci/dagger.json, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Dagger versions aligned (v$dagger_json)."
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Upload an Android App Bundle to the Google Play Store internal track."""
|
"""Upload an Android App Bundle to the Google Play Store.
|
||||||
|
|
||||||
|
The bundle is published to every track in ``TRACKS`` within a single Play edit,
|
||||||
|
so internal testing and closed testing share the same version code. ``alpha``
|
||||||
|
is what the Play Console labels "Closed testing"; publishing there removes the
|
||||||
|
need to manually drag-and-drop the AAB into the closed-testing release form.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -11,7 +17,7 @@ from google.oauth2 import service_account
|
|||||||
|
|
||||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||||
TRACK = "internal"
|
TRACKS = ("internal", "alpha")
|
||||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||||
_MAX_UPLOAD_ATTEMPTS = 3
|
_MAX_UPLOAD_ATTEMPTS = 3
|
||||||
@@ -94,19 +100,20 @@ def main():
|
|||||||
version_code = bundle["versionCode"]
|
version_code = bundle["versionCode"]
|
||||||
print(f"Uploaded AAB, version code: {version_code}")
|
print(f"Uploaded AAB, version code: {version_code}")
|
||||||
|
|
||||||
track_resp = session.put(
|
for track in TRACKS:
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
track_resp = session.put(
|
||||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
|
||||||
timeout=30,
|
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||||
)
|
timeout=30,
|
||||||
track_resp.raise_for_status()
|
)
|
||||||
|
track_resp.raise_for_status()
|
||||||
|
|
||||||
commit_resp = session.post(
|
commit_resp = session.post(
|
||||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
commit_resp.raise_for_status()
|
commit_resp.raise_for_status()
|
||||||
print(f"Deployed version {version_code} to {TRACK} track")
|
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ class TestMainHappyPath(unittest.TestCase):
|
|||||||
track_call = session.put.call_args_list[0]
|
track_call = session.put.call_args_list[0]
|
||||||
self.assertIn("/tracks/", track_call[0][0])
|
self.assertIn("/tracks/", track_call[0][0])
|
||||||
|
|
||||||
|
def test_updates_all_configured_tracks(self):
|
||||||
|
session = self._run_main()
|
||||||
|
track_urls = [c[0][0] for c in session.put.call_args_list]
|
||||||
|
self.assertEqual(len(track_urls), len(deploy_playstore.TRACKS))
|
||||||
|
for track in deploy_playstore.TRACKS:
|
||||||
|
self.assertTrue(
|
||||||
|
any(url.endswith(f"/tracks/{track}") for url in track_urls),
|
||||||
|
f"no PUT to /tracks/{track} (saw {track_urls})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_commits_after_all_track_updates(self):
|
||||||
|
session = self._run_main()
|
||||||
|
# All PUTs are track updates; commit is the second POST after the
|
||||||
|
# initial edit-create. Verify PUTs precede the commit by checking
|
||||||
|
# mock_calls order across both methods.
|
||||||
|
method_order = [c[0] for c in session.method_calls]
|
||||||
|
commit_idx = next(
|
||||||
|
i for i, m in enumerate(method_order)
|
||||||
|
if m == "post" and ":commit" in session.method_calls[i][1][0]
|
||||||
|
)
|
||||||
|
put_indices = [i for i, m in enumerate(method_order) if m == "put"]
|
||||||
|
self.assertEqual(len(put_indices), len(deploy_playstore.TRACKS))
|
||||||
|
self.assertTrue(all(i < commit_idx for i in put_indices))
|
||||||
|
|
||||||
|
|
||||||
class TestUploadRetry(unittest.TestCase):
|
class TestUploadRetry(unittest.TestCase):
|
||||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||||
|
|||||||
@@ -582,6 +582,54 @@ void main() {
|
|||||||
|
|
||||||
expect(find.textContaining('Structure not available'), findsOneWidget);
|
expect(find.textContaining('Structure not available'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Load remote images snack bar auto-dismisses after 3 seconds',
|
||||||
|
(tester) async {
|
||||||
|
const body = EmailBody(
|
||||||
|
emailId: 'acc-1:42',
|
||||||
|
htmlBody: '<p>Hello <img src="https://example.com/x.png"/></p>',
|
||||||
|
attachments: [],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation:
|
||||||
|
'/accounts/acc-1/mailboxes/INBOX/emails/acc-1%3A42',
|
||||||
|
overrides: _overrides(body: body),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The "Load remote images" button is visible because the sender is
|
||||||
|
// not yet trusted.
|
||||||
|
expect(find.text('Load remote images'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Load remote images'));
|
||||||
|
// Settle the snack bar enter animation and the setState rebuild
|
||||||
|
// that swaps in the image-loading WebView.
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Snack bar must be visible.
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// After 3 seconds (the snack bar's duration) plus the reverse
|
||||||
|
// animation, the snack bar must be gone.
|
||||||
|
// Regression test for #484: SnackBar with an action defaults to
|
||||||
|
// persist=true, which disables auto-dismiss — explicit persist:false
|
||||||
|
// restores duration-based dismissal.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||||
|
|
||||||
|
EmailThread _t(String id, {String accountId = 'acc-1'}) => EmailThread(
|
||||||
|
threadId: id,
|
||||||
|
subject: id,
|
||||||
|
participants: const [],
|
||||||
|
latestDate: DateTime(2024, 6),
|
||||||
|
messageCount: 1,
|
||||||
|
hasUnread: false,
|
||||||
|
isFlagged: false,
|
||||||
|
latestEmailId: id,
|
||||||
|
emailIds: [id],
|
||||||
|
accountId: accountId,
|
||||||
|
mailboxPath: 'INBOX',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('EmailThreadListController', () {
|
||||||
|
test('toggle adds then removes a thread id and fires notifications', () {
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([_t('a'), _t('b')]);
|
||||||
|
var notifications = 0;
|
||||||
|
ctrl.addListener(() => notifications++);
|
||||||
|
|
||||||
|
expect(ctrl.isSelecting, isFalse);
|
||||||
|
|
||||||
|
ctrl.toggle(_t('a'));
|
||||||
|
expect(ctrl.isSelecting, isTrue);
|
||||||
|
expect(ctrl.selectionCount, 1);
|
||||||
|
expect(ctrl.isSelected(_t('a')), isTrue);
|
||||||
|
expect(notifications, 1);
|
||||||
|
|
||||||
|
ctrl.toggle(_t('a'));
|
||||||
|
expect(ctrl.isSelecting, isFalse);
|
||||||
|
expect(ctrl.selectionCount, 0);
|
||||||
|
expect(notifications, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectAll selects every visible thread', () {
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([_t('a'), _t('b'), _t('c')]);
|
||||||
|
ctrl.selectAll();
|
||||||
|
expect(ctrl.selectionCount, 3);
|
||||||
|
expect(ctrl.selectedIds, {'a', 'b', 'c'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear empties the selection and notifies once', () {
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([_t('a'), _t('b')])
|
||||||
|
..toggle(_t('a'))
|
||||||
|
..toggle(_t('b'));
|
||||||
|
var notifications = 0;
|
||||||
|
ctrl.addListener(() => notifications++);
|
||||||
|
|
||||||
|
ctrl.clear();
|
||||||
|
expect(ctrl.isSelecting, isFalse);
|
||||||
|
expect(notifications, 1);
|
||||||
|
|
||||||
|
// Clearing an already-empty selection does not notify again.
|
||||||
|
ctrl.clear();
|
||||||
|
expect(notifications, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateThreads drops selections that are no longer visible', () {
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([_t('a'), _t('b'), _t('c')])
|
||||||
|
..toggle(_t('a'))
|
||||||
|
..toggle(_t('c'));
|
||||||
|
expect(ctrl.selectionCount, 2);
|
||||||
|
|
||||||
|
ctrl.updateThreads([_t('a'), _t('b')]);
|
||||||
|
// 'c' is no longer visible, so it gets dropped.
|
||||||
|
expect(ctrl.selectionCount, 1);
|
||||||
|
expect(ctrl.selectedIds, {'a'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selectedThreads preserves the visible-list order', () {
|
||||||
|
final a = _t('a');
|
||||||
|
final b = _t('b');
|
||||||
|
final c = _t('c');
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([a, b, c])
|
||||||
|
..toggle(c)
|
||||||
|
..toggle(a);
|
||||||
|
// Selection order is insertion (c, a), but selectedThreads must follow
|
||||||
|
// the visible-list order (a, c).
|
||||||
|
expect(ctrl.selectedThreads.map((t) => t.threadId), ['a', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-account threads are kept independent in the selection', () {
|
||||||
|
final ctrl = EmailThreadListController()
|
||||||
|
..updateThreads([
|
||||||
|
_t('a'),
|
||||||
|
_t('b', accountId: 'acc-2'),
|
||||||
|
]);
|
||||||
|
ctrl.selectAll();
|
||||||
|
final byAccount = <String, int>{};
|
||||||
|
for (final t in ctrl.selectedThreads) {
|
||||||
|
byAccount[t.accountId] = (byAccount[t.accountId] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
expect(byAccount, {'acc-1': 1, 'acc-2': 1});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
@@ -249,5 +249,59 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Body content here'), findsOneWidget);
|
expect(find.text('Body content here'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Load remote images snack bar auto-dismisses after 3 seconds',
|
||||||
|
(tester) async {
|
||||||
|
final email = _threadEmail();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/mailboxes/INBOX/threads/thread-1',
|
||||||
|
overrides: [
|
||||||
|
accountRepositoryProvider.overrideWithValue(
|
||||||
|
FakeAccountRepository([kTestAccount]),
|
||||||
|
),
|
||||||
|
mailboxRepositoryProvider.overrideWithValue(
|
||||||
|
FakeMailboxRepository(),
|
||||||
|
),
|
||||||
|
emailRepositoryProvider.overrideWithValue(
|
||||||
|
FakeEmailRepository(
|
||||||
|
emails: [email],
|
||||||
|
emailBody: const EmailBody(
|
||||||
|
emailId: 'acc-1:10',
|
||||||
|
htmlBody:
|
||||||
|
'<p>Hi <img src="https://example.com/x.png"/></p>',
|
||||||
|
attachments: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Load remote images'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Load remote images'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression test for #484: SnackBar with an action defaults to
|
||||||
|
// persist=true, which disables auto-dismiss — explicit persist:false
|
||||||
|
// restores duration-based dismissal.
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text('Images will be loaded automatically for this sender.'),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:sharedinbox/core/models/email.dart';
|
||||||
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
|
import 'package:sharedinbox/di.dart';
|
||||||
|
import 'package:sharedinbox/ui/screens/undo_log_detail_screen.dart';
|
||||||
|
|
||||||
|
import 'helpers.dart';
|
||||||
|
|
||||||
|
// FakeEmailRepository subclass that returns a pre-configured email from
|
||||||
|
// findEmailByMessageId, so the tap handler in UndoLogDetailScreen can be
|
||||||
|
// exercised without a real database.
|
||||||
|
class _LookupEmailRepository extends FakeEmailRepository {
|
||||||
|
_LookupEmailRepository(this._lookup);
|
||||||
|
|
||||||
|
final Email? _lookup;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Email?> findEmailByMessageId(
|
||||||
|
String accountId,
|
||||||
|
String messageId,
|
||||||
|
) async =>
|
||||||
|
_lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
UndoAction _action({
|
||||||
|
required List<Email> originalEmails,
|
||||||
|
String accountId = 'acc-1',
|
||||||
|
}) =>
|
||||||
|
UndoAction(
|
||||||
|
id: 'undo-1',
|
||||||
|
accountId: accountId,
|
||||||
|
type: UndoType.move,
|
||||||
|
emailIds: originalEmails.map((e) => e.id).toList(),
|
||||||
|
sourceMailboxPath: 'INBOX',
|
||||||
|
destinationMailboxPath: 'Archive',
|
||||||
|
originalEmails: originalEmails,
|
||||||
|
timestamp: DateTime(2024, 6),
|
||||||
|
);
|
||||||
|
|
||||||
|
Email _emailWith({
|
||||||
|
String id = 'acc-1:42',
|
||||||
|
String mailboxPath = 'INBOX',
|
||||||
|
String? messageId = '<msg-1@example.com>',
|
||||||
|
}) =>
|
||||||
|
Email(
|
||||||
|
id: id,
|
||||||
|
accountId: 'acc-1',
|
||||||
|
mailboxPath: mailboxPath,
|
||||||
|
uid: 42,
|
||||||
|
subject: 'Hello world',
|
||||||
|
receivedAt: DateTime(2024, 6),
|
||||||
|
sentAt: DateTime(2024, 6),
|
||||||
|
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
|
||||||
|
to: const [EmailAddress(email: 'alice@example.com')],
|
||||||
|
cc: const [],
|
||||||
|
isSeen: false,
|
||||||
|
isFlagged: false,
|
||||||
|
hasAttachment: false,
|
||||||
|
messageId: messageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Builds a minimal app whose initial location is the undo log detail screen
|
||||||
|
// for [action]. A placeholder email-detail route records its visit so the
|
||||||
|
// test can assert which path the tap navigated to.
|
||||||
|
Widget _buildApp({
|
||||||
|
required UndoAction action,
|
||||||
|
required FakeEmailRepository emailRepo,
|
||||||
|
ValueNotifier<String?>? lastEmailRoute,
|
||||||
|
}) {
|
||||||
|
final router = GoRouter(
|
||||||
|
initialLocation: '/undo-detail',
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/undo-detail',
|
||||||
|
builder: (ctx, state) => UndoLogDetailScreen(action: action),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/accounts/:accountId/mailboxes/:mailboxPath/emails/:emailId',
|
||||||
|
builder: (ctx, state) {
|
||||||
|
lastEmailRoute?.value = state.uri.toString();
|
||||||
|
return const Scaffold(body: Text('email-detail-route'));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
emailRepositoryProvider.overrideWithValue(emailRepo),
|
||||||
|
],
|
||||||
|
child: MaterialApp.router(routerConfig: router),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('UndoLogDetailScreen email row tap', () {
|
||||||
|
testWidgets('navigates to the current location returned by lookup', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// Original row recorded INBOX/42; after the move it now lives in
|
||||||
|
// Archive with a fresh UID — the lookup is what bridges that gap.
|
||||||
|
final original = _emailWith();
|
||||||
|
final current = _emailWith(id: 'acc-1:77', mailboxPath: 'Archive');
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
emailRepo: _LookupEmailRepository(current),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('email-detail-route'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
lastRoute.value,
|
||||||
|
'/accounts/acc-1/mailboxes/Archive/emails/acc-1%3A77',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows snackbar when lookup returns null', (tester) async {
|
||||||
|
final original = _emailWith();
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
emailRepo: _LookupEmailRepository(null),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('Email no longer exists'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(lastRoute.value, isNull);
|
||||||
|
expect(find.text('email-detail-route'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows snackbar when email has no Message-ID', (tester) async {
|
||||||
|
final original = _emailWith(messageId: null);
|
||||||
|
final lastRoute = ValueNotifier<String?>(null);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_buildApp(
|
||||||
|
action: _action(originalEmails: [original]),
|
||||||
|
// Lookup would succeed if called, but with no Message-ID the
|
||||||
|
// tap handler must short-circuit before reaching it.
|
||||||
|
emailRepo: _LookupEmailRepository(_emailWith()),
|
||||||
|
lastEmailRoute: lastRoute,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.text('Hello world'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining('no Message-ID'), findsOneWidget);
|
||||||
|
expect(lastRoute.value, isNull);
|
||||||
|
expect(find.text('email-detail-route'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user