Files
sharedinbox/lib/ui/screens/sieve_script_edit_screen.dart

450 lines
13 KiB
Dart

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({
super.key,
required this.accountId,
this.script,
this.isLocal = false,
});
final String accountId;
/// Null when creating a new script.
final SieveScript? script;
/// True for locally-executed scripts; false for server-side (ManageSieve/JMAP).
final bool isLocal;
@override
ConsumerState<SieveScriptEditScreen> createState() =>
_SieveScriptEditScreenState();
}
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());
}
}
@override
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 {
final content = widget.isLocal
? await ref
.read(localSieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId)
: await ref
.read(sieveRepositoryProvider)
.getScriptContent(widget.accountId, widget.script!.blobId);
if (mounted) {
_contentController.text = content;
_parseScriptIntoVisual();
setState(() => _loadingContent = false);
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_loadingContent = false;
});
}
}
}
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');
return;
}
setState(() {
_saving = true;
_error = null;
});
try {
if (widget.isLocal) {
await ref.read(localSieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
} else {
await ref.read(sieveRepositoryProvider).saveScript(
widget.accountId,
id: widget.script?.id,
name: name,
content: _contentController.text,
);
}
if (mounted) Navigator.of(context).pop();
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_saving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final isNew = widget.script == null;
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(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
icon: const Icon(Icons.save),
onPressed: _save,
tooltip: 'Save',
),
],
),
body: _loadingContent
? const Center(child: CircularProgressIndicator())
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
enabled: !_saving,
),
const SizedBox(height: 12),
if (_error != null) ...[
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
const SizedBox(height: 8),
],
Expanded(
child: TabBarView(
controller: _tabController,
children: [_buildVisualTab(), _buildScriptTab()],
),
),
],
),
),
);
}
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),
),
);
}
}