Compare commits

..
Author SHA1 Message Date
Thomas GuettlerandClaude Sonnet 4.6 6853ad130f fix(test): sync before searching in second searchEmails IMAP test
The searchEmails implementation now queries local SQLite FTS5 (not IMAP),
so syncEmails must be called first to populate the index. The first test
was already fixed; this adds the same syncEmails call to the second test
and adds a clarifying comment to the implementation.

Closes #506

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:49:05 +00:00
28 changed files with 142 additions and 1863 deletions
+8 -1
View File
@@ -22,7 +22,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+40 -5
View File
@@ -24,7 +24,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -167,7 +174,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -218,7 +232,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -263,7 +284,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -313,7 +341,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+16 -2
View File
@@ -23,7 +23,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
@@ -76,7 +83,14 @@ 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);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)
| 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)
if [ -n "$created_at" ]; then
queued_epoch=$(date -d "$created_at" +%s)
wait_seconds=$((runner_start - queued_epoch))
+8 -1
View File
@@ -27,7 +27,14 @@ 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);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)
| 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)
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 --exclude-tags=nightly test/backend >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test --concurrency=1 --reporter expanded --no-pub 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 --tags=nightly >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`flutter test test/backend/chaos_monkey_test.dart --reporter expanded --concurrency=1 --no-pub >"$tmp" 2>&1 || { cat "$tmp"; exit 1; }; ` +
`grep -E '^All [0-9]+ tests passed' "$tmp" || tail -1 "$tmp"`}).
Stdout(ctx)
}
-88
View File
@@ -1,88 +0,0 @@
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
@@ -1,358 +0,0 @@
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,4 +1,3 @@
import 'package:sharedinbox/core/filter/filter_expression.dart';
import 'package:sharedinbox/core/models/email.dart';
abstract class EmailRepository {
@@ -62,12 +61,6 @@ 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
@@ -1,100 +0,0 @@
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'\"');
}
+23 -103
View File
@@ -9,7 +9,6 @@ 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';
@@ -2923,9 +2922,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 e.received_at DESC LIMIT 50'
' WHERE email_fts MATCH ? AND e.account_id = ? ORDER BY rank LIMIT 50'
: 'SELECT e.* FROM email_fts f JOIN emails e ON e.rowid = f.rowid'
' WHERE email_fts MATCH ? ORDER BY e.received_at DESC LIMIT 50';
' WHERE email_fts MATCH ? ORDER BY rank LIMIT 50';
final variables = accountId != null
? [Variable<String>(ftsQuery), Variable<String>(accountId)]
: [Variable<String>(ftsQuery)];
@@ -2943,7 +2942,6 @@ 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;
}
@@ -2954,12 +2952,16 @@ 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>>[];
@@ -2978,99 +2980,15 @@ 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)));
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))
final rows = await _db
.customSelect(
sql,
variables: [...likeVars, ...extraVars],
readsFrom: {_db.emails, _db.emailNotes},
)
.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();
final emailRows = await Future.wait(rows.map((r) => _db.emails.mapFromRow(r)));
return emailRows.map(_toModel).toList();
}
/// Converts a user query string into an FTS5 match expression.
@@ -3079,7 +2997,9 @@ class EmailRepositoryImpl implements EmailRepository {
static String _toFtsQuery(String query) {
final words = query
.trim()
.split(RegExp(r'[^\w]+'))
.split(RegExp(r'\s+'))
.where((w) => w.isNotEmpty)
.map((w) => w.replaceAll(RegExp(r'[^\w]'), ''))
.where((w) => w.isNotEmpty)
.toList();
if (words.isEmpty) return '';
@@ -3193,7 +3113,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 e.received_at DESC LIMIT 50';
' ORDER BY rank LIMIT 50';
final variables = [
Variable<String>(ftsQuery),
Variable<String>(accountId),
@@ -3206,14 +3126,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,7 +109,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
@@ -117,7 +116,6 @@ class _SharedInboxAppState extends ConsumerState<SharedInboxApp> {
brightness: Brightness.dark,
),
useMaterial3: true,
splashFactory: NoSplash.splashFactory,
),
routerConfig: router,
);
-1
View File
@@ -57,7 +57,6 @@ 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'),
+11 -106
View File
@@ -4,12 +4,10 @@ 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>>((
@@ -39,10 +37,6 @@ 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();
@@ -59,13 +53,6 @@ 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) {
@@ -148,47 +135,22 @@ 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: _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,
),
title: TextField(
controller: _ctrl,
focusNode: _focusNode,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search folders, addresses, emails…',
border: InputBorder.none,
),
onChanged: _onChanged,
),
actions: [
if (!_advancedMode && _ctrl.text.isNotEmpty)
if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
@@ -196,15 +158,6 @@ 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(),
@@ -212,7 +165,6 @@ 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) {
@@ -222,54 +174,7 @@ 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'),
+13 -277
View File
@@ -3,13 +3,8 @@ 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({
@@ -32,29 +27,18 @@ class SieveScriptEditScreen extends ConsumerStatefulWidget {
_SieveScriptEditScreenState();
}
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
with SingleTickerProviderStateMixin {
class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen> {
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());
}
@@ -64,40 +48,9 @@ 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 {
@@ -110,7 +63,6 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false);
}
} catch (e) {
@@ -124,11 +76,6 @@ 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');
@@ -171,10 +118,6 @@ 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(
@@ -220,9 +163,18 @@ class _SieveScriptEditScreenState extends ConsumerState<SieveScriptEditScreen>
const SizedBox(height: 8),
],
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildVisualTab(), _buildScriptTab()],
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,
),
),
],
@@ -230,220 +182,4 @@ 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
@@ -1,312 +0,0 @@
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: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
mime:
dependency: "direct main"
description:
@@ -1104,26 +1104,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
version: "1.31.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
version: "0.7.11"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
version: "0.6.17"
timezone:
dependency: transitive
description:
-1
View File
@@ -87,7 +87,6 @@ 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,7 +3,6 @@ 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';
@@ -273,13 +272,6 @@ 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 => [];
+1 -4
View File
@@ -10,9 +10,6 @@
// 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';
@@ -135,7 +132,7 @@ void main() {
tearDown(() => db.close());
test('chaos monkey — random operations do not crash the repository',
timeout: Timeout.none, () async {
() async {
final seedStr = _env('CHAOS_SEED');
final seed = seedStr.isEmpty
? DateTime.now().millisecondsSinceEpoch
-7
View File
@@ -2,7 +2,6 @@ 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';
@@ -138,12 +137,6 @@ 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,7 +7,6 @@ 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;
@@ -546,22 +545,6 @@ 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,
+4 -66
View File
@@ -514,7 +514,8 @@ 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');
});
@@ -568,76 +569,13 @@ 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
@@ -1,337 +0,0 @@
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,7 +4,6 @@
// 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';
@@ -145,12 +144,6 @@ 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,7 +1,6 @@
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';
@@ -141,12 +140,6 @@ 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(
+7 -24
View File
@@ -7,11 +7,10 @@ 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 _i8;
import 'package:sharedinbox/core/models/undo_action.dart' as _i7;
import 'package:sharedinbox/core/repositories/email_repository.dart' as _i3;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i7;
import 'package:sharedinbox/core/repositories/undo_repository.dart' as _i6;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
@@ -343,22 +342,6 @@ 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,
@@ -575,13 +558,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 _i7.UndoRepository {
class MockUndoRepository extends _i1.Mock implements _i6.UndoRepository {
MockUndoRepository() {
_i1.throwOnMissingStub(this);
}
@override
_i4.Future<void> saveAction(_i8.UndoAction? action) => (super.noSuchMethod(
_i4.Future<void> saveAction(_i7.UndoAction? action) => (super.noSuchMethod(
Invocation.method(
#saveAction,
[action],
@@ -601,15 +584,15 @@ class MockUndoRepository extends _i1.Mock implements _i7.UndoRepository {
) as _i4.Future<void>);
@override
_i4.Future<List<_i8.UndoAction>> getHistory({int? limit = 10}) =>
_i4.Future<List<_i7.UndoAction>> getHistory({int? limit = 10}) =>
(super.noSuchMethod(
Invocation.method(
#getHistory,
[],
{#limit: limit},
),
returnValue: _i4.Future<List<_i8.UndoAction>>.value(<_i8.UndoAction>[]),
) as _i4.Future<List<_i8.UndoAction>>);
returnValue: _i4.Future<List<_i7.UndoAction>>.value(<_i7.UndoAction>[]),
) as _i4.Future<List<_i7.UndoAction>>);
@override
_i4.Future<void> clearHistory() => (super.noSuchMethod(
+1 -4
View File
@@ -50,10 +50,7 @@ Widget _buildScreen({List<Account> accounts = const []}) {
FakeAccountRepository(accounts),
),
],
child: MaterialApp(
theme: ThemeData(splashFactory: NoSplash.splashFactory),
home: const AboutScreen(),
),
child: const MaterialApp(home: AboutScreen()),
);
}
-8
View File
@@ -10,7 +10,6 @@ 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';
@@ -366,13 +365,6 @@ class FakeEmailRepository implements EmailRepository {
) async =>
_searchResults;
@override
Future<List<Email>> searchEmailsStructured(
String? accountId,
FilterGroup filter,
) async =>
[];
@override
Future<List<Email>> getEmailsByAddress(
String? accountId,