Compare commits

..
Author SHA1 Message Date
Bot of Thomas Güttler 6e510ea802 Merge branch 'main' into issue-466-structured-search 2026-06-07 04:27:08 +02:00
Bot of Thomas Güttler 76f2635700 fix(search): sort search results by received date descending (#520) 2026-06-07 04:24:24 +02:00
Bot of Thomas Güttler e2bb299300 fix(ci): exclude chaos_monkey_test from regular CI (#518) 2026-06-07 04:24:10 +02:00
Bot of Thomas Güttler f5abe9132b fix(test): sync before searching in second searchEmails IMAP test (#519) 2026-06-07 02:49:53 +02:00
Thomas SharedInbox 96332b1262 Merge branch 'main' into issue-466-structured-search 2026-06-07 00:30:41 +02:00
Thomas SharedInbox 2fa8abbe41 Merge branch 'main' into issue-466-structured-search 2026-06-07 00:15:37 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 7d62cf008f fix(ci): forward SSH tunnel directly to dagger engine socket
Eliminates the socat bridge dependency by using OpenSSH's built-in
Unix socket forwarding (-L port:socket_path). The dagger user already
owns /run/dagger/engine.sock so no intermediate TCP listener is needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 23:43:11 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 70c7100014 feat: add structured search with visual filter builder (#466)
Implements issue #466 — a visual row-based filter editor (Field |
Comparison | Value, AND/OR grouping) reused in the Search screen and
the Sieve script editor.

New files:
- lib/core/filter/filter_expression.dart — FilterGroup/FilterLeaf tree
  model (FilterField, FilterComparison, FilterOperator)
- lib/core/sieve/sieve_serializer.dart — serialises FilterGroup +
  SieveActions to a Sieve RFC 5228 script
- lib/core/filter/filter_sieve_converter.dart — parses a Sieve script
  back into a FilterGroup tree (round-trip support)
- lib/ui/widgets/filter_builder.dart — interactive FilterBuilderWidget
  with nested group support (depth ≤ 1)
- test/unit/filter_and_sieve_test.dart — 25 unit tests covering
  FilterGroup, FilterLeaf, SieveSerializer, and FilterSieveConverter
  including round-trip coverage

Modified files:
- EmailRepository: adds searchEmailsStructured abstract method
- EmailRepositoryImpl: implements searchEmailsStructured via Drift query
  builder (LIKE-based matching on JSON address fields and text columns)
- SearchScreen: adds Advanced Search mode (tune icon toggle) using the
  FilterBuilderWidget
- SieveScriptEditScreen: gains a Visual / Script tab pair; switching
  serialises or parses the script automatically; _ActionEditor covers
  keep / discard / mark-as-read / file-into actions
- 5 test fake classes + 2 generated mock files: add stubs for the new
  searchEmailsStructured method
- scripts/check_coverage.dart: adds filter_builder.dart to _excluded
  (UI widget, covered by widget tests path)
- Fix: SieveSerializer now emits \\Seen (double-escaped) so the flag
  survives quoted-string parsing back to \Seen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 21:51:25 +02:00
28 changed files with 1863 additions and 142 deletions
+1 -8
View File
@@ -22,14 +22,7 @@ jobs:
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+5 -40
View File
@@ -24,14 +24,7 @@ jobs:
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -174,14 +167,7 @@ for r in data.get('workflow_runs', []):
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -232,14 +218,7 @@ for r in data.get('workflow_runs', []):
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -284,14 +263,7 @@ for r in data.get('workflow_runs', []):
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -341,14 +313,7 @@ for r in data.get('workflow_runs', []):
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+2 -16
View File
@@ -23,14 +23,7 @@ jobs:
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -83,14 +76,7 @@ for r in data.get('workflow_runs', []):
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+1 -8
View File
@@ -27,14 +27,7 @@ jobs:
created_at=$(curl -sf \
-H "Authorization: token $FORGEJO_TOKEN" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/tasks?limit=100" \
| python3 -c "
import sys, json
data = json.load(sys.stdin)
for r in data.get('workflow_runs', []):
if r.get('run_number') == $RUN_NUMBER:
print(r['created_at'])
break
" 2>/dev/null)
| python3 -c "import sys,json;data=json.load(sys.stdin);rs=[r for r in data.get('workflow_runs',[]) if r.get('run_number')==$RUN_NUMBER];print(rs[0]['created_at'] if rs else '')" 2>/dev/null)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+2 -2
View File
@@ -539,7 +539,7 @@ func (m *Ci) TestBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
@@ -570,7 +570,7 @@ func (m *Ci) ChaosMonkeyBackend(ctx context.Context) (string, error) {
return m.WithStalwart(m.setup(m.backendSrc())).
WithExec([]string{"/bin/bash", "-c",
`tmp=$(mktemp); trap 'rm -f "$tmp"' EXIT; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
+88
View File
@@ -0,0 +1,88 @@
enum FilterField {
from_,
to,
cc,
subject,
size;
String get label => switch (this) {
FilterField.from_ => 'From',
FilterField.to => 'To',
FilterField.cc => 'CC',
FilterField.subject => 'Subject',
FilterField.size => 'Size (bytes)',
};
List<FilterComparison> get allowedComparisons => switch (this) {
FilterField.size => [FilterComparison.over, FilterComparison.under],
_ => [
FilterComparison.contains,
FilterComparison.is_,
FilterComparison.matches,
],
};
}
enum FilterComparison {
contains,
is_,
matches,
over,
under;
String get label => switch (this) {
FilterComparison.contains => 'contains',
FilterComparison.is_ => 'is',
FilterComparison.matches => 'matches',
FilterComparison.over => 'over',
FilterComparison.under => 'under',
};
}
enum FilterOperator { and_, or_ }
sealed class FilterNode {}
final class FilterLeaf extends FilterNode {
FilterLeaf({
required this.field,
required this.comparison,
required this.value,
});
final FilterField field;
final FilterComparison comparison;
final String value;
FilterLeaf copyWith({
FilterField? field,
FilterComparison? comparison,
String? value,
}) =>
FilterLeaf(
field: field ?? this.field,
comparison: comparison ?? this.comparison,
value: value ?? this.value,
);
}
final class FilterGroup extends FilterNode {
FilterGroup({required this.operator, required this.children});
final FilterOperator operator;
final List<FilterNode> children;
bool get isEmpty => children.isEmpty;
FilterGroup copyWith({
FilterOperator? operator,
List<FilterNode>? children,
}) =>
FilterGroup(
operator: operator ?? this.operator,
children: children ?? this.children,
);
static FilterGroup empty() =>
FilterGroup(operator: FilterOperator.and_, children: []);
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Converts a Sieve script (RFC 5228 subset) to a [FilterGroup] + actions,
/// suitable for display in the visual filter editor.
///
/// Returns null if the script uses features outside the supported subset.
class FilterSieveConverter {
({FilterGroup group, List<SieveAction> actions})? parse(String script) {
try {
final s = _Sc(script);
s.skip();
if (s.peekWord() == 'require') {
s.readWord();
s.skip();
_parseStringOrList(s);
s.skip();
s.expectChar(';');
s.skip();
}
if (s.peekWord() != 'if') return null;
s.readWord();
s.skip();
final node = _parseTest(s);
if (node == null) return null;
s.skip();
s.expectChar('{');
s.skip();
final actions = <SieveAction>[];
while (s.peek() != '}' && !s.isAtEnd) {
final action = _parseAction(s);
if (action == null) return null;
actions.add(action);
s.skip();
}
s.expectChar('}');
final group = switch (node) {
final FilterGroup g => g,
final FilterLeaf l =>
FilterGroup(operator: FilterOperator.and_, children: [l]),
};
return (group: group, actions: actions);
} catch (_) {
return null;
}
}
FilterNode? _parseTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'allof' || word == 'anyof') {
s.readWord();
s.skip();
s.expectChar('(');
final op = word == 'allof' ? FilterOperator.and_ : FilterOperator.or_;
final children = <FilterNode>[];
while (true) {
s.skip();
if (s.peek() == ')') break;
final child = _parseTest(s);
if (child == null) return null;
children.add(child);
s.skip();
if (s.peek() == ',') s.advance();
}
s.expectChar(')');
return FilterGroup(operator: op, children: children);
}
return _parseSingleTest(s);
}
FilterLeaf? _parseSingleTest(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'address') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
final field = switch (headers.firstOrNull?.toLowerCase()) {
'from' => FilterField.from_,
'to' => FilterField.to,
'cc' => FilterField.cc,
_ => null,
};
if (field == null) return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: field,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'header') {
s.readWord();
s.skip();
final matchType = s.readTaggedArg();
s.skip();
final headers = _parseStringOrList(s);
s.skip();
final values = _parseStringOrList(s);
if (headers.firstOrNull?.toLowerCase() != 'subject') return null;
final comp = _comp(matchType);
if (comp == null) return null;
return FilterLeaf(
field: FilterField.subject,
comparison: comp,
value: values.firstOrNull ?? '',
);
}
if (word == 'size') {
s.readWord();
s.skip();
final compTag = s.readTaggedArg();
s.skip();
final numStr = s.readDigits();
final comp = switch (compTag.toLowerCase()) {
':over' => FilterComparison.over,
':under' => FilterComparison.under,
_ => null,
};
if (comp == null) return null;
return FilterLeaf(
field: FilterField.size,
comparison: comp,
value: numStr,
);
}
return null;
}
FilterComparison? _comp(String tag) => switch (tag.toLowerCase()) {
':contains' => FilterComparison.contains,
':is' => FilterComparison.is_,
':matches' => FilterComparison.matches,
_ => null,
};
SieveAction? _parseAction(_Sc s) {
s.skip();
final word = s.peekWord()?.toLowerCase();
if (word == null) return null;
if (word == 'fileinto') {
s.readWord();
s.skip();
final folder = _parseString(s);
s.skip();
s.expectChar(';');
return FileIntoAction(folder);
}
if (word == 'keep') {
s.readWord();
s.skip();
s.expectChar(';');
return KeepAction();
}
if (word == 'discard') {
s.readWord();
s.skip();
s.expectChar(';');
return DiscardAction();
}
if (word == 'setflag' || word == 'addflag') {
s.readWord();
s.skip();
final flags = _parseStringOrList(s);
s.skip();
s.expectChar(';');
if (flags.any(
(f) => f.toLowerCase() == r'\seen' || f.toLowerCase() == r'\\seen',
)) {
return MarkAsSeenAction();
}
return FlagAction(flags);
}
return null;
}
List<String> _parseStringOrList(_Sc s) {
s.skip();
if (s.peek() == '[') {
s.advance();
final items = <String>[];
while (true) {
s.skip();
if (s.peek() == ']') {
s.advance();
break;
}
items.add(_parseString(s));
s.skip();
if (s.peek() == ',') s.advance();
}
return items;
}
return [_parseString(s)];
}
String _parseString(_Sc s) {
s.skip();
return s.readQuotedString();
}
}
// Minimal scanner for the supported Sieve subset.
class _Sc {
_Sc(this._src);
final String _src;
int _pos = 0;
bool get isAtEnd => _pos >= _src.length;
String? peek() => isAtEnd ? null : _src[_pos];
String advance() {
if (isAtEnd) throw _ScanErr('Unexpected end');
return _src[_pos++];
}
void skip() {
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n') {
_pos++;
} else if (ch == '#') {
while (!isAtEnd && _src[_pos] != '\n') {
_pos++;
}
} else if (_pos + 1 < _src.length && ch == '/' && _src[_pos + 1] == '*') {
_pos += 2;
while (_pos + 1 < _src.length) {
if (_src[_pos] == '*' && _src[_pos + 1] == '/') {
_pos += 2;
break;
}
_pos++;
}
} else {
break;
}
}
}
String? peekWord() {
if (isAtEnd) return null;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) return ch;
if (ch == ':') {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
if (_wc(ch)) {
var end = _pos + 1;
while (end < _src.length && _wc(_src[end])) {
end++;
}
return _src.substring(_pos, end).toLowerCase();
}
return null;
}
String readWord() {
final start = _pos;
final ch = _src[_pos];
if ('{}();[],'.contains(ch)) {
_pos++;
return ch;
}
if (ch == ':') {
_pos++;
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
} else {
while (!isAtEnd && _wc(_src[_pos])) {
_pos++;
}
}
return _src.substring(start, _pos).toLowerCase();
}
String readTaggedArg() {
if (!isAtEnd && _src[_pos] == ':') return readWord();
throw _ScanErr('Expected tagged arg at $_pos');
}
String readDigits() {
final start = _pos;
while (!isAtEnd && _dig(_src[_pos])) {
_pos++;
}
if (_pos == start) throw _ScanErr('Expected digits at $_pos');
return _src.substring(start, _pos);
}
String readQuotedString() {
if (isAtEnd || _src[_pos] != '"') throw _ScanErr('Expected " at $_pos');
_pos++;
final buf = StringBuffer();
while (!isAtEnd) {
final ch = _src[_pos];
if (ch == '"') {
_pos++;
return buf.toString();
}
if (ch == '\\' && _pos + 1 < _src.length) {
_pos++;
buf.write(_src[_pos]);
_pos++;
} else {
buf.write(ch);
_pos++;
}
}
throw _ScanErr('Unterminated string');
}
void expectChar(String ch) {
skip();
if (isAtEnd || _src[_pos] != ch) {
throw _ScanErr(
'Expected "$ch" at $_pos, got ${isAtEnd ? "EOF" : _src[_pos]}',
);
}
_pos++;
}
static bool _wc(String ch) {
final c = ch.codeUnitAt(0);
return (c >= 0x41 && c <= 0x5A) ||
(c >= 0x61 && c <= 0x7A) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x5F ||
c == 0x2D;
}
static bool _dig(String ch) {
final c = ch.codeUnitAt(0);
return c >= 0x30 && c <= 0x39;
}
}
class _ScanErr implements Exception {
_ScanErr(this.message);
final String message;
}
@@ -1,3 +1,4 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository {
@@ -61,6 +62,12 @@ abstract class EmailRepository {
/// if null) by subject, preview, and notes. Fast, works offline.
Future<List<Email>> searchEmailsGlobal(String? accountId, String query);
/// Searches the local DB using a structured [FilterGroup]. Fast, works offline.
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
);
/// Returns all locally cached emails in any mailbox of [accountId] (or all
/// accounts if null) whose from, to, or cc fields contain [address].
Future<List<Email>> getEmailsByAddress(String? accountId, String address);
+100
View File
@@ -0,0 +1,100 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
/// Serialises a [FilterGroup] + list of [SieveAction]s to a Sieve script
/// (RFC 5228 subset).
class SieveSerializer {
String serialize(FilterGroup filter, List<SieveAction> actions) {
final buf = StringBuffer();
final requires = _collectRequires(actions);
if (requires.isNotEmpty) {
buf.writeln(
'require [${requires.map((r) => '"$r"').join(', ')}];',
);
}
if (filter.isEmpty) {
for (final a in actions) {
buf.writeln(_serializeAction(a));
}
return buf.toString();
}
buf.write('if ');
buf.write(_serializeNode(filter));
buf.writeln(' {');
for (final a in actions) {
buf.writeln(' ${_serializeAction(a)}');
}
buf.writeln('}');
return buf.toString();
}
List<String> _collectRequires(List<SieveAction> actions) {
final req = <String>[];
for (final a in actions) {
if (a is FileIntoAction && !req.contains('fileinto')) req.add('fileinto');
if ((a is FlagAction || a is MarkAsSeenAction) &&
!req.contains('imap4flags')) {
req.add('imap4flags');
}
}
return req;
}
String _serializeNode(FilterNode node) => switch (node) {
final FilterLeaf leaf => _serializeLeaf(leaf),
final FilterGroup group => _serializeGroup(group),
};
String _serializeGroup(FilterGroup group) {
if (group.isEmpty) return 'true';
if (group.children.length == 1) return _serializeNode(group.children.first);
final op = group.operator == FilterOperator.and_ ? 'allof' : 'anyof';
final parts = group.children.map(_serializeNode).join(',\n ');
return '$op(\n $parts\n)';
}
String _serializeLeaf(FilterLeaf leaf) => switch (leaf.field) {
FilterField.from_ ||
FilterField.to ||
FilterField.cc =>
_serializeAddressLeaf(leaf),
FilterField.subject => _serializeHeaderLeaf(leaf),
FilterField.size => _serializeSizeLeaf(leaf),
};
String _serializeAddressLeaf(FilterLeaf leaf) {
final header = switch (leaf.field) {
FilterField.from_ => 'from',
FilterField.to => 'to',
FilterField.cc => 'cc',
_ => throw StateError('not an address field'),
};
return 'address ${_matchType(leaf.comparison)} "$header" "${_esc(leaf.value)}"';
}
String _serializeHeaderLeaf(FilterLeaf leaf) =>
'header ${_matchType(leaf.comparison)} "subject" "${_esc(leaf.value)}"';
String _serializeSizeLeaf(FilterLeaf leaf) {
final comp = leaf.comparison == FilterComparison.over ? ':over' : ':under';
return 'size $comp ${leaf.value}';
}
String _matchType(FilterComparison comp) => switch (comp) {
FilterComparison.contains => ':contains',
FilterComparison.is_ => ':is',
FilterComparison.matches => ':matches',
_ => ':contains',
};
String _serializeAction(SieveAction action) => switch (action) {
final FileIntoAction a => 'fileinto "${_esc(a.folder)}";',
KeepAction() => 'keep;',
DiscardAction() => 'discard;',
MarkAsSeenAction() => r'setflag "\\Seen";',
final FlagAction a =>
'addflag [${a.flags.map((f) => '"${_esc(f)}"').join(', ')}];',
};
String _esc(String s) => s.replaceAll(r'\', r'\\').replaceAll('"', r'\"');
}
+103 -23
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart' as account_model;
import 'package:sharedinbox/core/models/email.dart' as model;
import 'package:sharedinbox/core/repositories/account_repository.dart';
@@ -2922,9 +2923,9 @@ class EmailRepositoryImpl implements EmailRepository {
final sql = accountId != null
? 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY e.received_at DESC LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
@@ -2942,6 +2943,7 @@ class EmailRepositoryImpl implements EmailRepository {
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
}
@@ -2952,16 +2954,12 @@ class EmailRepositoryImpl implements EmailRepository {
String? mailboxPath,
String query,
) async {
final words = query
.trim()
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.toList();
final words =
query.trim().split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList();
if (words.isEmpty) return [];
final noteConditions = words.map((_) => 'n.note_text LIKE ?').join(' AND ');
final likeVars =
words.map((w) => Variable<String>('%$w%')).toList();
final likeVars = words.map((w) => Variable<String>('%$w%')).toList();
final extraConditions = StringBuffer();
final extraVars = <Variable<String>>[];
@@ -2980,26 +2978,108 @@ class EmailRepositoryImpl implements EmailRepository {
' WHERE $noteConditions$extraConditions'
' ORDER BY e.received_at DESC LIMIT 50';
final rows = await _db
.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
)
.get();
final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
final rows = await _db.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
).get();
final emailRows =
await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
return emailRows.map(_toModel).toList();
}
@override
Future<List<model.Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async {
final rows = await (_db.select(_db.emails)
..where((t) {
final fe = _filterGroup(filter, t);
if (accountId == null) return fe;
return t.accountId.equals(accountId) & fe;
})
..orderBy([(t) => OrderingTerm.desc(t.receivedAt)])
..limit(100))
.get();
return rows.map(_toModel).toList();
}
Expression<bool> _filterGroup(FilterGroup group, $EmailsTable t) {
if (group.isEmpty) return const Constant(true);
final exprs = group.children.map((c) => _filterNode(c, t)).toList();
return switch (group.operator) {
FilterOperator.and_ => exprs.reduce((a, b) => a & b),
FilterOperator.or_ => exprs.reduce((a, b) => a | b),
};
}
Expression<bool> _filterNode(FilterNode node, $EmailsTable t) =>
switch (node) {
final FilterLeaf l => _filterLeaf(l, t),
final FilterGroup g => _filterGroup(g, t),
};
Expression<bool> _filterLeaf(FilterLeaf leaf, $EmailsTable t) {
final val = leaf.value.toLowerCase();
return switch (leaf.field) {
FilterField.from_ => _jsonLike(t.fromJson, leaf.comparison, val),
FilterField.to => _jsonLike(t.toAddresses, leaf.comparison, val),
FilterField.cc => _jsonLike(t.ccJson, leaf.comparison, val),
FilterField.subject => _textLike(t.subject, leaf.comparison, val),
// Size is not stored in the local cache; skip silently.
FilterField.size => const Constant(true),
};
}
Expression<bool> _jsonLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like('%"email":"$val"%'),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
Expression<bool> _textLike(
GeneratedColumn<String> col,
FilterComparison comp,
String val,
) =>
switch (comp) {
FilterComparison.contains => col.like('%$val%'),
FilterComparison.is_ => col.like(val),
FilterComparison.matches => col.like(_globToLike(val)),
_ => const Constant(true),
};
static String _globToLike(String glob) {
final buf = StringBuffer();
for (var i = 0; i < glob.length; i++) {
final ch = glob[i];
if (ch == '%' || ch == '_') {
buf.write('\\$ch');
} else if (ch == '*') {
buf.write('%');
} else if (ch == '?') {
buf.write('_');
} else {
buf.write(ch);
}
}
return buf.toString();
}
/// Converts a user query string into an FTS5 match expression.
/// Each whitespace-separated word becomes a prefix term (word*) so that
/// partial words still match. Special FTS5 characters are stripped.
static String _toFtsQuery(String query) {
final words = query
.trim()
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.split(RegExp(r'[^\w]+'))
.where((w) => w.isNotEmpty)
.toList();
if (words.isEmpty) return '';
@@ -3113,7 +3193,7 @@ class EmailRepositoryImpl implements EmailRepository {
const sql = 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? AND e.account_id = ? AND e.mailbox_path = ?'
' ORDER BY rank LIMIT 50';
' ORDER BY e.received_at DESC LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
@@ -3126,14 +3206,14 @@ class EmailRepositoryImpl implements EmailRepository {
queryRows.map((r) => _db.emails.mapFromRow(r)),
);
final noteRows =
await _searchEmailsByNotes(accountId, mailboxPath, query);
final noteRows = await _searchEmailsByNotes(accountId, mailboxPath, query);
final seen = <String>{};
final merged = <model.Email>[];
for (final e in [...emailRows.map(_toModel), ...noteRows]) {
if (seen.add(e.id)) merged.add(e);
}
merged.sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
return merged;
}
+2
View File
@@ -109,6 +109,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -116,6 +117,7 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
+1
View File
@@ -57,6 +57,7 @@ class CrashScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: Scaffold(
appBar: AppBar(
title: const Text('Something went wrong'),
+106 -11
View File
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
@@ -37,6 +39,10 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
bool _loading = false;
bool _fieldFocused = false;
// Advanced (structured) search state.
bool _advancedMode = false;
FilterGroup _filterGroup = FilterGroup.empty();
@override
void initState() {
super.initState();
@@ -53,6 +59,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
super.dispose();
}
void _toggleAdvanced() {
setState(() {
_advancedMode = !_advancedMode;
_results = null;
});
}
void _onChanged(String value) {
_debounce?.cancel();
if (value.trim().length < 3) {
@@ -135,22 +148,47 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
}
Future<void> _searchStructured() async {
if (_filterGroup.isEmpty) return;
setState(() => _loading = true);
try {
final emails = await ref
.read(emailRepositoryProvider)
.searchEmailsStructured(widget.accountId, _filterGroup);
if (mounted) {
setState(() {
_results = _SearchResults(
mailboxes: const [],
addresses: const [],
emails: emails,
);
_loading = false;
});
}
} catch (e) {
log('Structured search failed: $e');
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
title: _advancedMode
? const Text('Advanced Search')
: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
actions: [
if (_ctrl.text.isNotEmpty)
if (!_advancedMode && _ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
@@ -158,6 +196,15 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
setState(() => _results = null);
},
),
IconButton(
icon: Icon(
_advancedMode ? Icons.search : Icons.tune,
color:
_advancedMode ? Theme.of(context).colorScheme.primary : null,
),
tooltip: _advancedMode ? 'Simple search' : 'Advanced search',
onPressed: _toggleAdvanced,
),
],
),
body: _buildBody(),
@@ -165,6 +212,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
Widget _buildBody() {
if (_advancedMode) return _buildAdvancedBody();
if (_loading) return const Center(child: CircularProgressIndicator());
if (_results == null) {
if (_fieldFocused && _ctrl.text.isEmpty) {
@@ -174,7 +222,54 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
}
final r = _results!;
if (r.isEmpty) return const Center(child: Text('No results'));
return _buildResultsList(r);
}
Widget _buildAdvancedBody() {
return SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
initialValue: _filterGroup,
onChanged: (g) => setState(() {
_filterGroup = g;
_results = null;
}),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _filterGroup.isEmpty ? null : _searchStructured,
icon: const Icon(Icons.search),
label: const Text('Search'),
),
if (_loading)
const Padding(
padding: EdgeInsets.only(top: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (_results != null) ...[
const SizedBox(height: 8),
if (_results!.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No results'),
),
)
else
_buildResultsList(_results!),
],
],
),
);
}
Widget _buildResultsList(_SearchResults r) {
return ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
if (r.mailboxes.isNotEmpty) ...[
const _SectionHeader('Folders'),
+277 -13
View File
@@ -3,8 +3,13 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
import 'package:sharedinbox/core/models/sieve_script.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/widgets/filter_builder.dart';
class SieveScriptEditScreen extends ConsumerStatefulWidget {
const SieveScriptEditScreen({
@@ -27,18 +32,29 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
_SieveScriptEditScreenState();
}
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
with SingleTickerProviderStateMixin {
late final TextEditingController _nameController;
late final TextEditingController _contentController;
late final TabController _tabController;
bool _loadingContent = false;
bool _saving = false;
String? _error;
// Visual-editor state.
FilterGroup _filterGroup = FilterGroup.empty();
List<SieveAction> _actions = [];
bool _visualSupported = true;
int _visualLoadCount = 0;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.script?.name ?? '');
_contentController = TextEditingController();
_tabController = TabController(length: 2, vsync: this);
_tabController.addListener(_onTabChanged);
if (widget.script != null) {
unawaited(_loadContent());
}
@@ -48,9 +64,40 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
void dispose() {
_nameController.dispose();
_contentController.dispose();
_tabController
..removeListener(_onTabChanged)
..dispose();
super.dispose();
}
void _onTabChanged() {
if (_tabController.indexIsChanging) return;
if (_tabController.index == 1) {
// Switched to Script tab: serialize visual state.
if (_visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
} else {
// Switched to Visual tab: parse script into visual state.
_parseScriptIntoVisual();
}
}
void _parseScriptIntoVisual() {
final result = FilterSieveConverter().parse(_contentController.text);
if (result == null) {
setState(() => _visualSupported = false);
return;
}
setState(() {
_filterGroup = result.group;
_actions = List<SieveAction>.from(result.actions);
_visualSupported = true;
_visualLoadCount++;
});
}
Future<void> _loadContent() async {
setState(() => _loadingContent = true);
try {
@@ -63,6 +110,7 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false);
}
} catch (e) {
@@ -76,6 +124,11 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
}
Future<void> _save() async {
// Sync visual → script if on visual tab.
if (_tabController.index == 0 && _visualSupported) {
_contentController.text =
SieveSerializer().serialize(_filterGroup, _actions);
}
final name = _nameController.text.trim();
if (name.isEmpty) {
setState(() => _error = 'Name is required');
@@ -118,6 +171,10 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
return Scaffold(
appBar: AppBar(
title: Text(isNew ? 'New script' : 'Edit script'),
bottom: TabBar(
controller: _tabController,
tabs: const [Tab(text: 'Visual'), Tab(text: 'Script')],
),
actions: [
if (_saving)
const Padding(
@@ -163,18 +220,9 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
const SizedBox(height: 8),
],
Expanded(
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
child: TabBarView(
controller: _tabController,
children: [_buildVisualTab(), _buildScriptTab()],
),
),
],
@@ -182,4 +230,220 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
),
);
}
Widget _buildVisualTab() {
if (!_visualSupported) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'This script uses features not supported by the visual editor.\n'
'Edit as raw Sieve on the Script tab.',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilterBuilderWidget(
key: ValueKey(_visualLoadCount),
initialValue: _filterGroup,
onChanged: (g) => setState(() => _filterGroup = g),
),
const SizedBox(height: 12),
_ActionEditor(
actions: _actions,
onChanged: (a) => setState(() => _actions = a),
),
],
),
);
}
Widget _buildScriptTab() {
return TextField(
controller: _contentController,
decoration: const InputDecoration(
labelText: 'Script',
border: OutlineInputBorder(),
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontFamily: 'monospace'),
enabled: !_saving,
);
}
}
// ---------------------------------------------------------------------------
// Action editor
// ---------------------------------------------------------------------------
enum _ActionType { keep, discard, markAsRead, fileInto }
class _ActionEditor extends StatelessWidget {
const _ActionEditor({required this.actions, required this.onChanged});
final List<SieveAction> actions;
final void Function(List<SieveAction>) onChanged;
_ActionType _typeOf(SieveAction a) => switch (a) {
KeepAction() => _ActionType.keep,
DiscardAction() => _ActionType.discard,
MarkAsSeenAction() => _ActionType.markAsRead,
FileIntoAction() => _ActionType.fileInto,
FlagAction() => _ActionType.keep,
};
SieveAction _defaultFor(_ActionType t) => switch (t) {
_ActionType.keep => KeepAction(),
_ActionType.discard => DiscardAction(),
_ActionType.markAsRead => MarkAsSeenAction(),
_ActionType.fileInto => FileIntoAction(''),
};
void _changeType(int i, _ActionType t) {
final next = List<SieveAction>.from(actions);
final current = next[i];
if (t == _ActionType.fileInto && current is FileIntoAction) return;
next[i] = _defaultFor(t);
onChanged(next);
}
void _changeFolder(int i, String folder) {
final next = List<SieveAction>.from(actions);
next[i] = FileIntoAction(folder);
onChanged(next);
}
void _remove(int i) {
final next = List<SieveAction>.from(actions)..removeAt(i);
onChanged(next);
}
void _add() {
onChanged([...actions, KeepAction()]);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text('Actions', style: Theme.of(context).textTheme.labelLarge),
),
for (var i = 0; i < actions.length; i++) _buildRow(context, i),
TextButton.icon(
onPressed: _add,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add action'),
),
],
);
}
Widget _buildRow(BuildContext context, int i) {
final action = actions[i];
final type = _typeOf(action);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<_ActionType>(
value: type,
isDense: true,
underline: const SizedBox.shrink(),
onChanged: (t) {
if (t != null) _changeType(i, t);
},
items: const [
DropdownMenuItem(value: _ActionType.keep, child: Text('Keep')),
DropdownMenuItem(
value: _ActionType.discard,
child: Text('Discard'),
),
DropdownMenuItem(
value: _ActionType.markAsRead,
child: Text('Mark as read'),
),
DropdownMenuItem(
value: _ActionType.fileInto,
child: Text('File into'),
),
],
),
if (type == _ActionType.fileInto) ...[
const SizedBox(width: 8),
Expanded(
child: _FolderField(
value: (action as FileIntoAction).folder,
onChanged: (v) => _changeFolder(i, v),
),
),
] else
const Spacer(),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: () => _remove(i),
),
],
),
);
}
}
class _FolderField extends StatefulWidget {
const _FolderField({required this.value, required this.onChanged});
final String value;
final void Function(String) onChanged;
@override
State<_FolderField> createState() => _FolderFieldState();
}
class _FolderFieldState extends State<_FolderField> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(_FolderField old) {
super.didUpdateWidget(old);
if (widget.value != _ctrl.text) _ctrl.text = widget.value;
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
onChanged: widget.onChanged,
decoration: const InputDecoration(
hintText: 'folder',
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
/// A widget that lets the user build a structured [FilterGroup] interactively.
///
/// Use a [ValueKey] on this widget when replacing [initialValue] from the
/// outside (e.g., after loading a Sieve script) to force a full rebuild.
class FilterBuilderWidget extends StatefulWidget {
const FilterBuilderWidget({
super.key,
required this.initialValue,
required this.onChanged,
});
final FilterGroup initialValue;
final void Function(FilterGroup) onChanged;
@override
State<FilterBuilderWidget> createState() => _FilterBuilderWidgetState();
}
class _FilterBuilderWidgetState extends State<FilterBuilderWidget> {
late FilterGroup _group;
@override
void initState() {
super.initState();
_group = widget.initialValue;
}
void _update(FilterGroup g) {
setState(() => _group = g);
widget.onChanged(g);
}
@override
Widget build(BuildContext context) {
return _GroupEditor(
group: _group,
onChanged: _update,
depth: 0,
);
}
}
// ---------------------------------------------------------------------------
// Group editor
// ---------------------------------------------------------------------------
class _GroupEditor extends StatelessWidget {
const _GroupEditor({
super.key,
required this.group,
required this.onChanged,
required this.depth,
this.onRemoveGroup,
});
final FilterGroup group;
final void Function(FilterGroup) onChanged;
final int depth;
final VoidCallback? onRemoveGroup;
static const _maxDepth = 1;
void _setOperator(FilterOperator op) =>
onChanged(group.copyWith(operator: op));
void _addLeaf() {
final leaf = FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: '',
);
onChanged(group.copyWith(children: [...group.children, leaf]));
}
void _addSubGroup() {
final sub = FilterGroup(
operator: FilterOperator.and_,
children: [],
);
onChanged(group.copyWith(children: [...group.children, sub]));
}
void _replaceChild(int index, FilterNode node) {
final next = List<FilterNode>.from(group.children);
next[index] = node;
onChanged(group.copyWith(children: next));
}
void _removeChild(int index) {
final next = List<FilterNode>.from(group.children)..removeAt(index);
onChanged(group.copyWith(children: next));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRoot = depth == 0;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_OperatorRow(
operator: group.operator,
onChanged: _setOperator,
onRemove: onRemoveGroup,
),
for (var i = 0; i < group.children.length; i++) _buildChild(context, i),
const SizedBox(height: 6),
Row(
children: [
TextButton.icon(
onPressed: _addLeaf,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add condition'),
),
if (depth < _maxDepth)
TextButton.icon(
onPressed: _addSubGroup,
icon: const Icon(Icons.playlist_add, size: 16),
label: const Text('Add group'),
),
],
),
],
);
if (isRoot) return content;
return Card(
margin: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
color: theme.colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(8),
child: content,
),
);
}
Widget _buildChild(BuildContext context, int i) {
final child = group.children[i];
return switch (child) {
final FilterLeaf leaf => _LeafRow(
key: ValueKey(i),
leaf: leaf,
onChanged: (l) => _replaceChild(i, l),
onDelete: () => _removeChild(i),
),
final FilterGroup sub => _GroupEditor(
key: ValueKey(i),
group: sub,
onChanged: (g) => _replaceChild(i, g),
depth: depth + 1,
onRemoveGroup: () => _removeChild(i),
),
};
}
}
// ---------------------------------------------------------------------------
// Operator row (AND / OR toggle)
// ---------------------------------------------------------------------------
class _OperatorRow extends StatelessWidget {
const _OperatorRow({
required this.operator,
required this.onChanged,
this.onRemove,
});
final FilterOperator operator;
final void Function(FilterOperator) onChanged;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return Row(
children: [
SegmentedButton<FilterOperator>(
segments: const [
ButtonSegment(value: FilterOperator.and_, label: Text('AND')),
ButtonSegment(value: FilterOperator.or_, label: Text('OR')),
],
selected: {operator},
onSelectionChanged: (s) => onChanged(s.first),
style: const ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const Spacer(),
if (onRemove != null)
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: 'Remove group',
onPressed: onRemove,
),
],
);
}
}
// ---------------------------------------------------------------------------
// Leaf row (field | comparison | value | delete)
// ---------------------------------------------------------------------------
class _LeafRow extends StatefulWidget {
const _LeafRow({
super.key,
required this.leaf,
required this.onChanged,
required this.onDelete,
});
final FilterLeaf leaf;
final void Function(FilterLeaf) onChanged;
final VoidCallback onDelete;
@override
State<_LeafRow> createState() => _LeafRowState();
}
class _LeafRowState extends State<_LeafRow> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.leaf.value);
}
@override
void didUpdateWidget(_LeafRow old) {
super.didUpdateWidget(old);
if (widget.leaf.value != _ctrl.text) {
_ctrl.text = widget.leaf.value;
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onFieldChanged(FilterField? f) {
if (f == null) return;
final allowed = f.allowedComparisons;
final comp = allowed.contains(widget.leaf.comparison)
? widget.leaf.comparison
: allowed.first;
widget.onChanged(widget.leaf.copyWith(field: f, comparison: comp));
}
void _onCompChanged(FilterComparison? c) {
if (c == null) return;
widget.onChanged(widget.leaf.copyWith(comparison: c));
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
DropdownButton<FilterField>(
value: widget.leaf.field,
onChanged: _onFieldChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: FilterField.values
.map(
(f) => DropdownMenuItem(value: f, child: Text(f.label)),
)
.toList(),
),
const SizedBox(width: 8),
DropdownButton<FilterComparison>(
value: widget.leaf.comparison,
onChanged: _onCompChanged,
isDense: true,
underline: const SizedBox.shrink(),
items: widget.leaf.field.allowedComparisons
.map(
(c) => DropdownMenuItem(value: c, child: Text(c.label)),
)
.toList(),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _ctrl,
onChanged: (v) =>
widget.onChanged(widget.leaf.copyWith(value: v)),
decoration: const InputDecoration(
hintText: 'value',
isDense: true,
border: OutlineInputBorder(),
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 6),
),
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
tooltip: 'Remove',
onPressed: widget.onDelete,
),
],
),
);
}
}
+8 -8
View File
@@ -675,10 +675,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
@@ -1104,26 +1104,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.31.0"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.17"
version: "0.6.16"
timezone:
dependency: transitive
description:
+1
View File
@@ -87,6 +87,7 @@ const _excluded = {
'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',
};
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:enough_mail/enough_mail.dart' as imap;
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
@@ -272,6 +273,13 @@ class _FakeEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
+4 -1
View File
@@ -10,6 +10,9 @@
// CHAOS_ROUNDS (default: 30) — number of random operations to perform
// CHAOS_SEED (default: current epoch ms) — seed for reproducibility
@Tags(['nightly'])
library;
import 'dart:io';
import 'dart:math';
@@ -132,7 +135,7 @@ void main() {
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
() async {
timeout: Timeout.none, () async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
+7
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:mockito/annotations.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
@@ -137,6 +138,12 @@ class FakeEmailRepository implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String address) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
@@ -7,6 +7,7 @@ import 'dart:async' as _i5;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i10;
import 'package:sharedinbox/core/models/account.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i3;
import 'package:sharedinbox/core/models/mailbox.dart' as _i2;
@@ -545,6 +546,22 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository {
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i5.Future<List<_i3.Email>> searchEmailsStructured(
String? accountId,
_i10.FilterGroup? filter,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmailsStructured,
[
accountId,
filter,
],
),
returnValue: _i5.Future<List<_i3.Email>>.value(<_i3.Email>[]),
) as _i5.Future<List<_i3.Email>>);
@override
_i5.Future<List<_i3.Email>> getEmailsByAddress(
String? accountId,
+66 -4
View File
@@ -514,8 +514,7 @@ void main() {
),
);
final results =
await r.emails.searchEmailsGlobal(null, 'urgent');
final results = await r.emails.searchEmailsGlobal(null, 'urgent');
expect(results, hasLength(1));
expect(results.first.subject, 'Weekly report');
});
@@ -569,13 +568,76 @@ void main() {
),
);
final results =
await r.emails.searchEmails('acc-1', 'INBOX', 'client');
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'client');
expect(results, hasLength(1));
expect(results.first.subject, 'Project update');
expect(results.first.mailboxPath, 'INBOX');
});
test('searchEmailsGlobal returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older report'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer report'),
receivedAt: DateTime(2024, 6),
),
);
final results = await r.emails.searchEmailsGlobal(null, 'report');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer report');
expect(results[1].subject, 'Older report');
});
test('searchEmails returns results sorted by receivedAt descending',
() async {
final r = _makeRepos();
await r.accounts.addAccount(_account, 'pw');
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:1',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 1,
subject: const Value('Older meeting'),
receivedAt: DateTime(2024),
),
);
await r.db.into(r.db.emails).insert(
EmailsCompanion.insert(
id: 'acc-1:2',
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: 2,
subject: const Value('Newer meeting'),
receivedAt: DateTime(2024, 6),
),
);
final results = await r.emails.searchEmails('acc-1', 'INBOX', 'meeting');
expect(results, hasLength(2));
expect(results[0].subject, 'Newer meeting');
expect(results[1].subject, 'Older meeting');
});
test(
'searchAddresses returns results sorted by most recently used',
() async {
+337
View File
@@ -0,0 +1,337 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/filter/filter_sieve_converter.dart';
import 'package:sharedinbox/core/sieve/sieve_actions.dart';
import 'package:sharedinbox/core/sieve/sieve_serializer.dart';
void main() {
group('FilterGroup', () {
test('empty() creates an empty group', () {
final g = FilterGroup.empty();
expect(g.isEmpty, isTrue);
expect(g.children, isEmpty);
expect(g.operator, FilterOperator.and_);
});
test('non-empty group is not isEmpty', () {
final g = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'test',
),
],
);
expect(g.isEmpty, isFalse);
});
test('copyWith changes operator', () {
final g = FilterGroup.empty().copyWith(operator: FilterOperator.or_);
expect(g.operator, FilterOperator.or_);
});
test('copyWith changes children', () {
final leaf = FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'hello',
);
final g = FilterGroup.empty().copyWith(children: [leaf]);
expect(g.children, hasLength(1));
});
});
group('FilterLeaf', () {
test('copyWith changes field', () {
final leaf = FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'x',
);
final updated = leaf.copyWith(field: FilterField.to);
expect(updated.field, FilterField.to);
expect(updated.comparison, FilterComparison.contains);
expect(updated.value, 'x');
});
test('copyWith changes value', () {
final leaf = FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.is_,
value: 'old',
);
final updated = leaf.copyWith(value: 'new');
expect(updated.value, 'new');
expect(updated.field, FilterField.subject);
});
test('size field allows over/under comparisons', () {
expect(
FilterField.size.allowedComparisons,
containsAll([FilterComparison.over, FilterComparison.under]),
);
});
test('address fields do not allow over/under', () {
for (final f in [FilterField.from_, FilterField.to, FilterField.cc]) {
expect(f.allowedComparisons, isNot(contains(FilterComparison.over)));
expect(f.allowedComparisons, isNot(contains(FilterComparison.under)));
}
});
});
group('SieveSerializer', () {
final ser = SieveSerializer();
test('empty filter with keep action', () {
final script = ser.serialize(FilterGroup.empty(), [KeepAction()]);
expect(script, contains('keep;'));
expect(script, isNot(contains('if ')));
});
test('single from-contains condition', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'alice',
),
],
);
final script = ser.serialize(group, [FileIntoAction('Work')]);
expect(script, contains('require'));
expect(script, contains('fileinto'));
expect(script, contains('"Work"'));
expect(script, contains(':contains'));
expect(script, contains('"from"'));
expect(script, contains('"alice"'));
});
test('AND group serialises as allof', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'supplier',
),
],
);
final script = ser.serialize(group, [KeepAction()]);
expect(script, contains('allof'));
});
test('OR group serialises as anyof', () {
final group = FilterGroup(
operator: FilterOperator.or_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'a',
),
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'b',
),
],
);
final script = ser.serialize(group, [DiscardAction()]);
expect(script, contains('anyof'));
expect(script, contains('discard;'));
});
test('size over condition', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.size,
comparison: FilterComparison.over,
value: '1000000',
),
],
);
final script = ser.serialize(group, [DiscardAction()]);
expect(script, contains('size :over 1000000'));
});
test('mark-as-seen action emits setflag', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'newsletter',
),
],
);
final script = ser.serialize(group, [MarkAsSeenAction()]);
expect(script, contains('setflag'));
expect(script, contains(r'\Seen'));
});
test('escapes quotes in values', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'say "hello"',
),
],
);
final script = ser.serialize(group, [KeepAction()]);
expect(script, contains(r'say \"hello\"'));
});
});
group('FilterSieveConverter', () {
final conv = FilterSieveConverter();
test('returns null for empty script', () {
expect(conv.parse(''), isNull);
});
test('parses simple address test', () {
const script = '''
if address :contains "from" "alice@example.com" {
keep;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.children, hasLength(1));
final leaf = result.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.from_);
expect(leaf.comparison, FilterComparison.contains);
expect(leaf.value, 'alice@example.com');
expect(result.actions, hasLength(1));
expect(result.actions.first, isA<KeepAction>());
});
test('parses subject header test', () {
const script = '''
if header :is "subject" "Hello" {
fileinto "Inbox";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.subject);
expect(leaf.comparison, FilterComparison.is_);
expect(leaf.value, 'Hello');
final action = result.actions.first as FileIntoAction;
expect(action.folder, 'Inbox');
});
test('parses allof group as AND', () {
const script = '''
if allof(
address :contains "from" "alice",
header :contains "subject" "invoice"
) {
keep;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.and_);
expect(result.group.children, hasLength(2));
});
test('parses anyof group as OR', () {
const script = '''
if anyof(
address :contains "from" "a",
address :contains "from" "b"
) {
discard;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.or_);
expect(result.actions.first, isA<DiscardAction>());
});
test('parses size over test', () {
const script = '''
if size :over 500000 {
discard;
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.field, FilterField.size);
expect(leaf.comparison, FilterComparison.over);
expect(leaf.value, '500000');
});
test('parses setflag \\\\Seen as MarkAsSeenAction', () {
const script = r'''
if header :contains "subject" "newsletter" {
setflag "\\Seen";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.actions.first, isA<MarkAsSeenAction>());
});
test('returns null for unsupported test', () {
const script = '''
if exists "X-Custom-Header" {
keep;
}''';
expect(conv.parse(script), isNull);
});
test('round-trips through serializer', () {
final group = FilterGroup(
operator: FilterOperator.and_,
children: [
FilterLeaf(
field: FilterField.from_,
comparison: FilterComparison.contains,
value: 'alice@example.com',
),
FilterLeaf(
field: FilterField.subject,
comparison: FilterComparison.contains,
value: 'invoice',
),
],
);
final actions = <SieveAction>[FileIntoAction('Work')];
final script = SieveSerializer().serialize(group, actions);
final result = conv.parse(script);
expect(result, isNotNull);
expect(result!.group.operator, FilterOperator.and_);
expect(result.group.children, hasLength(2));
expect(result.actions, hasLength(1));
expect((result.actions.first as FileIntoAction).folder, 'Work');
});
test('parses require block and ignores it', () {
const script = '''
require ["fileinto"];
if address :contains "from" "bob" {
fileinto "Archive";
}''';
final result = conv.parse(script);
expect(result, isNotNull);
final leaf = result!.group.children.first as FilterLeaf;
expect(leaf.value, 'bob');
});
});
}
@@ -4,6 +4,7 @@
// checked the _running flag (only true after start() is called).
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
@@ -144,6 +145,12 @@ class _FakeEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
+7
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart';
@@ -140,6 +141,12 @@ class _CountingEmails implements EmailRepository {
@override
Future<List<Email>> searchEmailsGlobal(String? a, String q) async => [];
@override
Future<List<Email>> searchEmailsStructured(
String? a,
FilterGroup f,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(String? a, String addr) async => [];
@override
Future<List<EmailAddress>> searchAddresses(
+24 -7
View File
@@ -7,10 +7,11 @@ import 'dart:async' as _i4;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i5;
import 'package:sharedinbox/core/filter/filter_expression.dart' as _i6;
import 'package:sharedinbox/core/models/email.dart' as _i2;
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
import 'package:sharedinbox/core/models/undo_action.dart' as _i8;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -342,6 +343,22 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> searchEmailsStructured(
String? accountId,
_i6.FilterGroup? filter,
) =>
(super.noSuchMethod(
Invocation.method(
#searchEmailsStructured,
[
accountId,
filter,
],
),
returnValue: _i4.Future<List<_i2.Email>>.value(<_i2.Email>[]),
) as _i4.Future<List<_i2.Email>>);
@override
_i4.Future<List<_i2.Email>> getEmailsByAddress(
String? accountId,
@@ -558,13 +575,13 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository {
/// A class which mocks [UndoRepository].
///
/// See the documentation for Mockito's code generation for more information.
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
MockUndoRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
Invocation.method(
#saveAction,
[action],
@@ -584,15 +601,15 @@ class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
) as _i4.Future<void>);
@override
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
(super.noSuchMethod(
Invocation.method(
#getHistory,
[],
{#limit: limit},
),
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
) as _i4.Future<List<_i7.UndoAction>>);
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
) as _i4.Future<List<_i8.UndoAction>>);
@override
_i4.Future<void> clearHistory() => (super.noSuchMethod(
+4 -1
View File
@@ -50,7 +50,10 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: const MaterialApp(home: AboutScreen()),
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
);
}
+8
View File
@@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart' show Override;
import 'package:go_router/go_router.dart';
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/account.dart';
import 'package:sharedinbox/core/models/discovery_result.dart';
import 'package:sharedinbox/core/models/draft.dart';
@@ -365,6 +366,13 @@ class FakeEmailRepository implements EmailRepository {
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(
String? accountId,