Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c0334532 |
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"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")
|
||||
label_map = {l["name"]: l["id"] for l in repo_labels}
|
||||
|
||||
label_ids = [label_map["loop/code"]] if "loop/code" in label_map else []
|
||||
label_ids = [label_map["Ready"]] if "Ready" in label_map else []
|
||||
|
||||
title = "Firebase Tests failed — find root cause and fix"
|
||||
body = (
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,4 +123,3 @@ dagger-certs
|
||||
/go
|
||||
.last_deployed_sha
|
||||
.fail_count
|
||||
/*.kubeconfig
|
||||
|
||||
@@ -26,13 +26,13 @@ repos:
|
||||
- id: forbidden-files-hook
|
||||
name: check for forbidden home-directory files
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-hygiene'
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-hygiene'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: dart-check
|
||||
name: dart format (autofix) + check-fast (parallel)
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command dagger call --progress=plain -q -m ci --source=. check-fast'
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
- id: ci-no-direct-dagger
|
||||
@@ -50,12 +50,12 @@ repos:
|
||||
- id: ci-image-exists
|
||||
name: verify container images in ci/main.go are reachable
|
||||
language: system
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && task check-ci-images'
|
||||
entry: bash -c 'cd "$(git rev-parse --show-toplevel)" && nix develop --command task check-ci-images'
|
||||
pass_filenames: false
|
||||
files: ^(ci/main\.go|\.fvmrc)$
|
||||
- id: dagger-versions-aligned
|
||||
name: verify Dagger version is consistent across dagger.json, Dockerfile and DAGGER.md
|
||||
name: verify Dagger version is consistent across dagger.json, flake.nix, 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)$
|
||||
files: ^(ci/dagger\.json|flake\.nix|\.forgejo/Dockerfile|DAGGER\.md)$
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
## Root cause analysis
|
||||
|
||||
The "Load remote images" button is rendered in two places: `lib/ui/screens/email_detail_screen.dart:228-262` (single mail view) and `lib/ui/screens/thread_detail_screen.dart:203-237` (thread view). Both call the same pattern:
|
||||
|
||||
```dart
|
||||
onPressed: () {
|
||||
setState(() => _loadRemoteImages = true); // 1. schedule rebuild
|
||||
if (senderEmail != null) {
|
||||
unawaited(...addTrustedImageSender(senderEmail)); // 2. fire-and-forget DB write
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(SnackBar( // 3. queue snack bar
|
||||
duration: const Duration(seconds: 3),
|
||||
...
|
||||
));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Although `duration: 3s` is already set, the snack bar fails to auto-dismiss. This mirrors the bug fixed in PR #401 (issue #399): there, a snack bar fired during a navigation transition and the duration timer "didn't start correctly" because the snack bar was queued on an unstable scaffold.
|
||||
|
||||
Here, the analogous instability comes from three rebuilds that all land between `showSnackBar` and the moment the SnackBar's enter-animation would normally complete and start its dismiss timer:
|
||||
|
||||
1. The synchronous `setState` flips `_loadRemoteImages` → `true`, which immediately removes the "Load remote images" button (the very widget whose `onPressed` was running) and swaps the `SecureEmailWebView` into the rebuilt subtree with `loadRemoteImages: true`. The WebView's `didUpdateWidget` then triggers an async `loadHtmlString` reload (see `lib/ui/widgets/secure_email_webview.dart:100-106`), which subsequently calls `setState(() => _height = h)` inside `_measureHeight`.
|
||||
2. The fire-and-forget `addTrustedImageSender` write resolves a moment later, the `trustedImageSendersProvider` stream emits, and `ref.watch(trustedImageSendersProvider)` in `email_detail_screen.dart:197` causes another rebuild of the whole screen body — including the `Scaffold`'s body subtree that hosts the snack bar overlay's host context.
|
||||
3. These rebuilds happen during the SnackBar's enter animation, so the `_SnackBarState` ends up holding stale animation state and the per-snack-bar timer that schedules `hideCurrentSnackBar` after `duration` never fires.
|
||||
|
||||
## Plan
|
||||
|
||||
### Fix
|
||||
|
||||
Queue the snack bar **before** mutating state, so it reaches `ScaffoldMessenger` while the Scaffold subtree is still stable, and defer the state change to a post-frame callback so the snack bar's enter-animation can finish before the WebView reload and the provider-driven rebuild run.
|
||||
|
||||
In `lib/ui/screens/email_detail_screen.dart`, replace the body of `OutlinedButton.icon.onPressed` at lines 231-261 with:
|
||||
|
||||
```dart
|
||||
onPressed: () {
|
||||
if (senderEmail != null) {
|
||||
unawaited(
|
||||
ref
|
||||
.read(userPreferencesRepositoryProvider)
|
||||
.addTrustedImageSender(senderEmail),
|
||||
);
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
duration: const Duration(seconds: 3),
|
||||
content: const Text(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'View',
|
||||
onPressed: () {
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
context.push(
|
||||
'/accounts/trusted-senders',
|
||||
extra: senderEmail,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _loadRemoteImages = true);
|
||||
});
|
||||
},
|
||||
```
|
||||
|
||||
Apply the same reordering to `lib/ui/screens/thread_detail_screen.dart:206-236`.
|
||||
|
||||
The key changes:
|
||||
- `showSnackBar` runs first, on the still-stable scaffold subtree.
|
||||
- `setState` (which triggers WebView swap-in and subsequent rebuilds) is deferred to a post-frame callback.
|
||||
- When `senderEmail == null` (no trusted-sender to register, so no snack bar), the post-frame callback still flips `_loadRemoteImages` to true — preserving existing behavior of the button working even for unknown senders.
|
||||
|
||||
### Tests
|
||||
|
||||
Add a widget test in `test/widget/email_detail_screen_test.dart` that:
|
||||
|
||||
1. Pumps an `EmailDetailScreen` with an HTML body and a non-empty `From` header.
|
||||
2. Taps the "Load remote images" button.
|
||||
3. Verifies the snack bar with text "Images will be loaded automatically for this sender." appears.
|
||||
4. Calls `tester.pump(const Duration(seconds: 4))` (or uses `tester.pumpAndSettle` after a 3.5s pump).
|
||||
5. Verifies the snack bar is gone (`expect(find.byType(SnackBar), findsNothing)`).
|
||||
6. Verifies `_loadRemoteImages` did flip, by checking that the "Load remote images" button is no longer present.
|
||||
|
||||
Add an analogous test in `test/widget/thread_detail_screen_test.dart` (or wherever thread tests live; create the file if it does not exist yet — use the email_detail test as a template).
|
||||
|
||||
### Out of scope
|
||||
|
||||
- The "First update agent loop, fix search bug" line in the issue body is two unrelated todo notes the reporter jotted down (the search bug is tracked separately). This plan does not address them.
|
||||
- Other `showSnackBar` call sites in `email_detail_screen.dart` (download success/failure, copy-to-clipboard, raw-email errors, etc.) are not affected by the same rebuild pattern and stay unchanged.
|
||||
|
||||
### Verification checklist
|
||||
|
||||
- [ ] `dart test` (or the project's `task test` equivalent) passes, including the two new widget tests.
|
||||
- [ ] Manual: open a single mail in `EmailDetailScreen` with HTML body from a sender not yet trusted; tap "Load remote images"; verify snack bar appears, images load, and snack bar disappears after ~3 seconds.
|
||||
- [ ] Manual: tap "View" on the snack bar before it dismisses; verify it navigates to `/accounts/trusted-senders` and that the snack bar is dismissed by the navigation as expected.
|
||||
- [ ] Manual: repeat in `ThreadDetailScreen`.
|
||||
+1
-1
@@ -544,7 +544,7 @@ tasks:
|
||||
- sops exec-env secrets.enc.yaml 'bash scripts/build_android_bundle_local.sh'
|
||||
|
||||
deploy-android-bundle:
|
||||
desc: Build release AAB and upload to Play Store internal + closed-testing tracks (local/fvm)
|
||||
desc: Build release AAB and upload to Play Store internal track (local/fvm)
|
||||
deps: [build-android-bundle-local]
|
||||
dotenv: [".env"]
|
||||
cmds:
|
||||
|
||||
+2
-9
@@ -814,14 +814,7 @@ func (m *Ci) DeployApk(
|
||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||
built := m.firebaseBase().
|
||||
// `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)`}).
|
||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||
WithWorkdir("/src/android").
|
||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||
// preserved in the Dagger layer snapshot but whose process no longer exists.
|
||||
@@ -903,7 +896,7 @@ func withGoCache(c *dagger.Container) *dagger.Container {
|
||||
WithEnvVariable("GOMODCACHE", "/home/ci/go/pkg/mod")
|
||||
}
|
||||
|
||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal and closed-testing (alpha) tracks.
|
||||
// UploadToPlayStore uploads a pre-built AAB to the Play Store internal track.
|
||||
func (m *Ci) UploadToPlayStore(
|
||||
ctx context.Context,
|
||||
aab *dagger.File,
|
||||
|
||||
Generated
+82
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
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's Nix wrapper is a broken self-exec loop, so we
|
||||
# fetch the CLI binary directly. Keep this version in lockstep with
|
||||
# ci/dagger.json (engineVersion) and .forgejo/Dockerfile (DAGGER_VERSION) —
|
||||
# scripts/check_dagger_versions.sh enforces this.
|
||||
daggerCli = pkgs.stdenv.mkDerivation {
|
||||
pname = "dagger";
|
||||
version = "0.20.8";
|
||||
src = pkgs.fetchurl {
|
||||
url = "https://dl.dagger.io/dagger/releases/0.20.8/dagger_v0.20.8_linux_amd64.tar.gz";
|
||||
sha256 = "1ns6wq2z1skd2fq9lbrcali0s8kn24p3haamnjjgchg6zlv6b960";
|
||||
};
|
||||
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
|
||||
daggerCli
|
||||
|
||||
# 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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||
|
||||
class AddressEmailsScreen extends ConsumerStatefulWidget {
|
||||
const AddressEmailsScreen({
|
||||
@@ -26,27 +26,12 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
||||
List<Email>? _emails;
|
||||
bool _loading = true;
|
||||
|
||||
late final EmailThreadListController _selection;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_selection
|
||||
..removeListener(_onSelectionChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelectionChange() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final emails = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
@@ -61,35 +46,43 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selecting = _selection.isSelecting;
|
||||
return Scaffold(
|
||||
appBar: selecting
|
||||
? buildSelectionAppBar(_selection)
|
||||
: AppBar(title: Text(widget.address)),
|
||||
bottomNavigationBar: selecting
|
||||
? buildSelectionBottomBar(
|
||||
context,
|
||||
ref,
|
||||
_selection,
|
||||
onAfterAction: _onAfterBatchAction,
|
||||
)
|
||||
: null,
|
||||
appBar: AppBar(title: Text(widget.address)),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: EmailThreadList(
|
||||
controller: _selection,
|
||||
items: _emails!.map(EmailThread.fromEmail).toList(),
|
||||
enableSwipe: false,
|
||||
showLocationLabel: true,
|
||||
),
|
||||
: _emails!.isEmpty
|
||||
? const Center(child: Text('No emails'))
|
||||
: ListView.builder(
|
||||
itemCount: _emails!.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
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,8 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/ui/widgets/email_thread_list.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.dart';
|
||||
|
||||
class CombinedInboxScreen extends ConsumerStatefulWidget {
|
||||
const CombinedInboxScreen({super.key});
|
||||
@@ -20,24 +22,29 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
late final EmailThreadListController _selection;
|
||||
// Thread-level selection (key = threadId).
|
||||
final Set<String> _selectedThreadIds = {};
|
||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||
List<EmailThread> _currentThreads = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||
bool get _selecting => _selectedThreadIds.isNotEmpty;
|
||||
|
||||
void _toggleThreadSelection(EmailThread thread) {
|
||||
setState(() {
|
||||
if (_selectedThreadIds.contains(thread.threadId)) {
|
||||
_selectedThreadIds.remove(thread.threadId);
|
||||
} else {
|
||||
_selectedThreadIds.add(thread.threadId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_selection
|
||||
..removeListener(_onSelectionChange)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
void _clearSelection() => setState(() => _selectedThreadIds.clear());
|
||||
|
||||
void _onSelectionChange() {
|
||||
if (mounted) setState(() {});
|
||||
void _selectAll() {
|
||||
setState(
|
||||
() => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -65,18 +72,13 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
for (final a in accounts) a.id: a.displayName,
|
||||
};
|
||||
final showAccount = accounts.length > 1;
|
||||
final selecting = _selection.isSelecting;
|
||||
|
||||
return Scaffold(
|
||||
appBar: selecting
|
||||
? buildSelectionAppBar(_selection)
|
||||
: _buildAppBar(accounts),
|
||||
drawer: selecting ? null : _buildDrawer(context, accounts),
|
||||
bottomNavigationBar: selecting
|
||||
? buildSelectionBottomBar(context, ref, _selection)
|
||||
: null,
|
||||
appBar: _buildAppBar(accounts),
|
||||
drawer: _selecting ? null : _buildDrawer(context, accounts),
|
||||
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
|
||||
body: _buildBody(accountNames, showAccount),
|
||||
floatingActionButton: selecting
|
||||
floatingActionButton: _selecting
|
||||
? null
|
||||
: FloatingActionButton(
|
||||
onPressed: () => context.push('/compose'),
|
||||
@@ -88,6 +90,23 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
}
|
||||
|
||||
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(
|
||||
title: const Text('Combined Inbox'),
|
||||
actions: [
|
||||
@@ -109,6 +128,26 @@ 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) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
@@ -187,14 +226,197 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
child: EmailThreadList(
|
||||
controller: _selection,
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeAllInboxThreads(limit: _limit),
|
||||
enablePagination: true,
|
||||
showAccountLabel: showAccount,
|
||||
accountNames: accountNames,
|
||||
onLoadMore: () => setState(() => _limit += _pageSize),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
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, 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,16 +1,7 @@
|
||||
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/undo_action.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_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 }
|
||||
|
||||
@@ -87,288 +78,3 @@ Future<Mailbox?> resolveMailboxByRole(
|
||||
|
||||
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,10 +239,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
ScaffoldMessenger.of(ctx).showSnackBar(
|
||||
SnackBar(
|
||||
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(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
|
||||
@@ -3,14 +3,19 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
import 'package:sharedinbox/core/models/user_preferences.dart';
|
||||
import 'package:sharedinbox/core/repositories/email_repository.dart';
|
||||
import 'package:sharedinbox/di.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
||||
import 'package:sharedinbox/ui/screens/email_action_helpers.dart';
|
||||
import 'package:sharedinbox/ui/widgets/email_thread_tile.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 {
|
||||
const EmailListScreen({
|
||||
@@ -35,7 +40,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Error banner — tracks the last error message that the user dismissed.
|
||||
String? _dismissedError;
|
||||
|
||||
late final EmailThreadListController _selection;
|
||||
// Thread-level selection (key = threadId).
|
||||
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.
|
||||
static const _pageSize = 50;
|
||||
@@ -49,11 +59,12 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Used to skip redundant re-runs when the user presses Enter on an
|
||||
// already-settled search (issue #473).
|
||||
String? _lastSettledQuery;
|
||||
bool get _selecting =>
|
||||
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selection = EmailThreadListController()..addListener(_onSelectionChange);
|
||||
_searchController.addListener(() {
|
||||
if (_searchController.text.isEmpty) {
|
||||
setState(() {
|
||||
@@ -67,15 +78,52 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_selection
|
||||
..removeListener(_onSelectionChange)
|
||||
..dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSelectionChange() {
|
||||
if (mounted) setState(() {});
|
||||
void _toggleThreadSelection(EmailThread thread) {
|
||||
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 {
|
||||
@@ -122,23 +170,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
final prefs =
|
||||
ref.watch(userPreferencesProvider).value ?? const UserPreferences();
|
||||
final menuAtBottom = prefs.menuPosition == MenuPosition.bottom;
|
||||
final selecting = _selection.isSelecting;
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom),
|
||||
drawer: selecting
|
||||
drawer: _selecting
|
||||
? null
|
||||
: FolderDrawer(
|
||||
accountId: widget.accountId,
|
||||
currentMailboxPath: widget.mailboxPath,
|
||||
),
|
||||
bottomNavigationBar: selecting
|
||||
? buildSelectionBottomBar(
|
||||
context,
|
||||
ref,
|
||||
_selection,
|
||||
onAfterAction: _onAfterBatchAction,
|
||||
)
|
||||
bottomNavigationBar: _selecting
|
||||
? _selectionBottomBar()
|
||||
: (menuAtBottom ? _folderNavBottomBar() : null),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -158,52 +200,67 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
AsyncValue<Account?> accountAsync, {
|
||||
required bool menuAtBottom,
|
||||
}) {
|
||||
if (_selection.isSelecting) {
|
||||
return buildSelectionAppBar(_selection);
|
||||
}
|
||||
final selectionCount =
|
||||
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
|
||||
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: !menuAtBottom,
|
||||
title: Text(widget.mailboxPath),
|
||||
actions: [
|
||||
accountAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (account) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Center(
|
||||
child: Text(
|
||||
account?.displayName ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
leading: _selecting
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _clearSelection,
|
||||
)
|
||||
: null,
|
||||
title: _selecting
|
||||
? Text('$selectionCount selected')
|
||||
: Text(widget.mailboxPath),
|
||||
actions: _selecting
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: 'Select all',
|
||||
onPressed: _selectAll,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildSyncButton(emailRepo),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => context.push(
|
||||
'/compose',
|
||||
extra: {'accountId': widget.accountId},
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
]
|
||||
: [
|
||||
accountAsync.when(
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
data: (account) => Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Center(
|
||||
child: Text(
|
||||
account?.displayName ?? '',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildSyncButton(emailRepo),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => context.push(
|
||||
'/compose',
|
||||
extra: {'accountId': widget.accountId},
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
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(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
@@ -212,8 +269,9 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
controller: _searchController,
|
||||
hintText: 'Search…',
|
||||
leading: const Icon(Icons.search),
|
||||
enabled: !_selecting,
|
||||
trailing: [
|
||||
if (_searchController.text.isNotEmpty)
|
||||
if (_searchController.text.isNotEmpty && !_selecting)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () => _searchController.clear(),
|
||||
@@ -292,6 +350,41 @@ 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() {
|
||||
if (_searchLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -302,13 +395,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
if (_searchResults!.isEmpty) {
|
||||
return const Center(child: Text('No results'));
|
||||
}
|
||||
final threads = _searchResults!.map(EmailThread.fromEmail).toList();
|
||||
return EmailThreadList(
|
||||
controller: _selection,
|
||||
items: threads,
|
||||
enableSwipe: false,
|
||||
onTap: (t) => unawaited(_openSearchResultAndRefresh(t.latestEmailId)),
|
||||
);
|
||||
return _buildEmailList(_searchResults!);
|
||||
}
|
||||
|
||||
Widget _buildSyncErrorBanner() {
|
||||
@@ -353,28 +440,82 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
// Also wait for this specific mailbox to sync for immediate feedback.
|
||||
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
||||
},
|
||||
child: EmailThreadList(
|
||||
controller: _selection,
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
stream: emailRepo.observeThreads(
|
||||
widget.accountId,
|
||||
widget.mailboxPath,
|
||||
limit: _limit,
|
||||
),
|
||||
enablePagination: true,
|
||||
onLoadMore: () => setState(() => _limit += _pageSize),
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
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> _openSearchResultAndRefresh(String emailId) async {
|
||||
await context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(emailId)}',
|
||||
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,
|
||||
);
|
||||
await _refreshSearchAndPopIfEmpty();
|
||||
|
||||
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();
|
||||
@@ -385,32 +526,296 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
if (remaining.isEmpty) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
return;
|
||||
} else {
|
||||
_searchController.clear();
|
||||
}
|
||||
_searchController.clear();
|
||||
return;
|
||||
} else {
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
|
||||
void _onAfterBatchAction(List<String> actedThreadIds) {
|
||||
if (!_searching || !mounted) return;
|
||||
Future<void> _openSearchResultAndRefresh(String emailId) async {
|
||||
await context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes'
|
||||
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
||||
'/emails/${Uri.encodeComponent(emailId)}',
|
||||
);
|
||||
await _refreshSearchAndPopIfEmpty();
|
||||
}
|
||||
|
||||
// Filter acted-on emails out of the local results immediately. Calling
|
||||
// searchEmails would still return them because the delete is only
|
||||
// enqueued — not yet applied to the local DB.
|
||||
final actedSet = actedThreadIds.toSet();
|
||||
final remaining = (_searchResults ?? [])
|
||||
.where((e) => !actedSet.contains(e.threadId ?? e.id))
|
||||
Future<void> _batchDelete() async {
|
||||
final ids = _selectedEmailIds;
|
||||
final wasSearching = _searching;
|
||||
_clearSelection();
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
// 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();
|
||||
if (remaining.isEmpty) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
return;
|
||||
|
||||
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);
|
||||
}
|
||||
_searchController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 || !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(
|
||||
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;
|
||||
}
|
||||
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.
|
||||
Widget _buildEmailList(List<Email> emails) {
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final e = emails[i];
|
||||
final t = EmailThread.fromEmail(e);
|
||||
final isSelected = _selectedSearchIds.contains(e.id);
|
||||
return ThreadTile(
|
||||
thread: t,
|
||||
selected: isSelected,
|
||||
leading: SizedBox(
|
||||
width: 40,
|
||||
child: _selecting
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => _toggleSearchSelection(e.id),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onTap: _selecting
|
||||
? () => _toggleSearchSelection(e.id)
|
||||
: () => unawaited(_openSearchResultAndRefresh(e.id)),
|
||||
onLongPress: () => _toggleSearchSelection(e.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,10 +214,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
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(
|
||||
'Images will be loaded automatically for this sender.',
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sharedinbox/core/models/email.dart';
|
||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||
@@ -94,9 +93,7 @@ class UndoLogDetailScreen extends ConsumerWidget {
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
...action.originalEmails.map(
|
||||
(email) => _EmailTile(email: email, accountId: action.accountId),
|
||||
),
|
||||
...action.originalEmails.map((email) => _EmailTile(email: email)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -123,14 +120,13 @@ class _SectionHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailTile extends ConsumerWidget {
|
||||
const _EmailTile({required this.email, required this.accountId});
|
||||
class _EmailTile extends StatelessWidget {
|
||||
const _EmailTile({required this.email});
|
||||
|
||||
final Email email;
|
||||
final String accountId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final sender = email.from.isNotEmpty
|
||||
? (email.from.first.name ?? email.from.first.email)
|
||||
: '(Unknown Sender)';
|
||||
@@ -138,43 +134,6 @@ class _EmailTile extends ConsumerWidget {
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: Text(email.subject ?? '(No Subject)'),
|
||||
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)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,432 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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/ui/screens/user_preferences_screen.dart',
|
||||
'lib/core/services/update_service.dart',
|
||||
'lib/ui/widgets/email_thread_tile.dart',
|
||||
'lib/ui/screens/trusted_image_senders_screen.dart',
|
||||
'lib/data/repositories/note_repository_impl.dart',
|
||||
'lib/ui/widgets/filter_builder.dart',
|
||||
'lib/ui/widgets/thread_tile.dart',
|
||||
'lib/ui/widgets/email_thread_list.dart',
|
||||
};
|
||||
|
||||
void main() {
|
||||
|
||||
@@ -13,6 +13,11 @@ ROOT=$(git rev-parse --show-toplevel)
|
||||
dagger_json=$(grep -oE '"engineVersion"[[:space:]]*:[[:space:]]*"[^"]+"' "$ROOT/ci/dagger.json" \
|
||||
| sed -E 's/.*"v?([^"]+)"$/\1/')
|
||||
|
||||
# flake.nix — the dagger021 derivation's CLI download URL.
|
||||
flake_nix=$(grep -oE 'dagger_v[0-9]+\.[0-9]+\.[0-9]+_linux' "$ROOT/flake.nix" \
|
||||
| head -n1 \
|
||||
| sed -E 's/dagger_v([0-9.]+)_linux/\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 \
|
||||
@@ -24,10 +29,11 @@ dagger_md=$(grep -oE 'dagger/nix/v[0-9]+\.[0-9]+\.[0-9]+' "$ROOT/DAGGER.md" \
|
||||
| sed -E 's@.*/v@@')
|
||||
|
||||
printf 'ci/dagger.json engineVersion = v%s\n' "$dagger_json"
|
||||
printf 'flake.nix dagger021 = %s\n' "$flake_nix"
|
||||
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
|
||||
for v in "$flake_nix" "$dockerfile" "$dagger_md"; do
|
||||
if [ -z "$v" ]; then
|
||||
echo "ERROR: failed to parse a Dagger version reference." >&2
|
||||
exit 1
|
||||
@@ -35,7 +41,7 @@ for v in "$dockerfile" "$dagger_md"; do
|
||||
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
|
||||
echo " Align ci/dagger.json, flake.nix, .forgejo/Dockerfile and DAGGER.md to the same version." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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.
|
||||
"""
|
||||
"""Upload an Android App Bundle to the Google Play Store internal track."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -17,7 +11,7 @@ from google.oauth2 import service_account
|
||||
|
||||
PACKAGE_NAME = "de.sharedinbox.mua"
|
||||
AAB_PATH = "build/app/outputs/bundle/release/app-release.aab"
|
||||
TRACKS = ("internal", "alpha")
|
||||
TRACK = "internal"
|
||||
_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
|
||||
_UPLOAD_BASE = "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
|
||||
_MAX_UPLOAD_ATTEMPTS = 3
|
||||
@@ -100,20 +94,19 @@ def main():
|
||||
version_code = bundle["versionCode"]
|
||||
print(f"Uploaded AAB, version code: {version_code}")
|
||||
|
||||
for track in TRACKS:
|
||||
track_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{track}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
track_resp.raise_for_status()
|
||||
track_resp = session.put(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}/tracks/{TRACK}",
|
||||
json={"releases": [{"versionCodes": [version_code], "status": "completed"}]},
|
||||
timeout=30,
|
||||
)
|
||||
track_resp.raise_for_status()
|
||||
|
||||
commit_resp = session.post(
|
||||
f"{_BASE}/{PACKAGE_NAME}/edits/{edit_id}:commit",
|
||||
timeout=30,
|
||||
)
|
||||
commit_resp.raise_for_status()
|
||||
print(f"Deployed version {version_code} to tracks: {', '.join(TRACKS)}")
|
||||
print(f"Deployed version {version_code} to {TRACK} track")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -95,30 +95,6 @@ class TestMainHappyPath(unittest.TestCase):
|
||||
track_call = session.put.call_args_list[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):
|
||||
def _run_main(self, upload_side_effects, sleep_mock=None):
|
||||
|
||||
@@ -582,54 +582,6 @@ void main() {
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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: 62 KiB After Width: | Height: | Size: 61 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
@@ -249,59 +249,5 @@ void main() {
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
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