look ahead
This commit is contained in:
@@ -26,11 +26,14 @@ class TransactionDetailScreen extends StatefulWidget {
|
||||
|
||||
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
late final TextEditingController _notesController;
|
||||
late final TextEditingController _labelSearchController;
|
||||
late SimilarMatchCriteria _matchCriteria;
|
||||
late Set<String> _draftLabelIds;
|
||||
var _similarCount = 0;
|
||||
var _similarReady = false;
|
||||
var _dirty = false;
|
||||
var _labelsDirty = false;
|
||||
late bool _applyToSimilar;
|
||||
var _applyToSimilar = false;
|
||||
var _busy = false;
|
||||
String? _busyMessage;
|
||||
|
||||
@@ -40,6 +43,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final meta = context.read<TxMetaController>();
|
||||
final notes = meta.metaFor(widget.tx).notes;
|
||||
_notesController = TextEditingController(text: notes);
|
||||
_labelSearchController = TextEditingController();
|
||||
final tx = widget.tx;
|
||||
_matchCriteria = SimilarMatchCriteria(
|
||||
pattern: defaultSimilarPattern(tx.description),
|
||||
@@ -50,19 +54,31 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
: TxFlowFilter.all,
|
||||
);
|
||||
_draftLabelIds = meta.metaFor(tx).labelIds.toSet();
|
||||
// Defer full-list similar scan so the detail route paints immediately.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshSimilarCount());
|
||||
}
|
||||
|
||||
void _refreshSimilarCount() {
|
||||
if (!mounted) return;
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = meta.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
_applyToSimilar = similarCount > 1;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_similarCount = count;
|
||||
_similarReady = true;
|
||||
// Default on when there are peers — otherwise users never notice the toggle.
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notesController.dispose();
|
||||
_labelSearchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -85,13 +101,10 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
_matchCriteria = result;
|
||||
final count = context.read<TxMetaController>().countSimilar(
|
||||
widget.tx,
|
||||
all,
|
||||
criteria: result,
|
||||
);
|
||||
if (count > 1) _applyToSimilar = true;
|
||||
_similarReady = false;
|
||||
_similarCount = 0;
|
||||
});
|
||||
_refreshSimilarCount();
|
||||
}
|
||||
|
||||
Future<void> _runBusy(
|
||||
@@ -282,17 +295,27 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
final tx = widget.tx;
|
||||
final metaController = context.watch<TxMetaController>();
|
||||
final meta = metaController.metaFor(tx);
|
||||
// Prefer read: bank data rarely changes while editing labels.
|
||||
final all = context.read<BankController>().transactions;
|
||||
final similarCount = metaController.countSimilar(
|
||||
tx,
|
||||
all,
|
||||
criteria: _matchCriteria,
|
||||
);
|
||||
final similarCount = _similarCount;
|
||||
final theme = Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final amountColor = tx.amount >= 0 ? AppTheme.accent : AppTheme.expense;
|
||||
final labelEntries = metaController.labelsInTreeOrder();
|
||||
final needle = _labelSearchController.text.trim().toLowerCase();
|
||||
final allLabelEntries = metaController.labelsInTreeOrder();
|
||||
final labelEntries = needle.isEmpty
|
||||
? allLabelEntries
|
||||
: () {
|
||||
final keep = <String>{};
|
||||
for (final entry in allLabelEntries) {
|
||||
if (!entry.label.name.toLowerCase().contains(needle)) continue;
|
||||
keep.add(entry.label.id);
|
||||
keep.addAll(metaController.ancestorIdsOf(entry.label.id));
|
||||
keep.addAll(metaController.descendantIdsOf(entry.label.id));
|
||||
}
|
||||
return [
|
||||
for (final entry in allLabelEntries)
|
||||
if (keep.contains(entry.label.id)) entry,
|
||||
];
|
||||
}();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -305,275 +328,325 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ResponsiveBody(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 32),
|
||||
children: [
|
||||
if (_busy) ...[
|
||||
const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 4),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
body: Column(
|
||||
children: [
|
||||
if (_busy) const LinearProgressIndicator(minHeight: 2),
|
||||
if (_busy && _busyMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
child: Text(
|
||||
_busyMessage!,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
),
|
||||
Expanded(
|
||||
child: ResponsiveBody(
|
||||
child: AbsorbPointer(
|
||||
absorbing: _busy,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 16),
|
||||
children: [
|
||||
for (final entry in labelEntries) ...[
|
||||
if (entry != labelEntries.first)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant.withValues(alpha: 0.45),
|
||||
Text(
|
||||
formatMoney(tx.amount, tx.currency),
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: amountColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(tx.description, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
[
|
||||
formatIsoDate(tx.date),
|
||||
if (tx.pending) 'Pending',
|
||||
].join(' · '),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Match similar', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
icon: const Icon(Icons.tune, size: 18),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
InkWell(
|
||||
onTap:
|
||||
_busy ? null : () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress:
|
||||
_busy ? null : () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) =>
|
||||
_toggleDraftLabel(entry.label.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'“${_matchCriteria.pattern}”',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.copy_all_outlined),
|
||||
title: const Text('Include matched transactions'),
|
||||
subtitle: Text(
|
||||
similarCount <= 1
|
||||
? 'No other matches for this pattern'
|
||||
: '$similarCount matching transactions',
|
||||
),
|
||||
value: _applyToSimilar && similarCount > 1,
|
||||
onChanged: _busy || !_similarReady || similarCount <= 1
|
||||
? null
|
||||
: (value) => setState(() => _applyToSimilar = value),
|
||||
),
|
||||
if (_applyToSimilar && similarCount > 1)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: _busy ? null : _editMatchCriteria,
|
||||
child: Text('Review $similarCount matches'),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.autorenew),
|
||||
title: const Text('Recurring'),
|
||||
subtitle: const Text('Salary, mortgage, subscriptions…'),
|
||||
value: meta.recurring,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TxMetaController>().setRecurring(tx, value);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
secondary: const Icon(Icons.swap_horiz),
|
||||
title: const Text('Internal transfer'),
|
||||
subtitle: const Text(
|
||||
'Between your own accounts — excluded from income/expense',
|
||||
),
|
||||
value: meta.internalTransfer,
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (value) {
|
||||
context
|
||||
.read<TxMetaController>()
|
||||
.setInternalTransfer(tx, value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Text('Labels', style: theme.textTheme.titleSmall),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _createLabel,
|
||||
child: const Text('New'),
|
||||
),
|
||||
if (_draftLabelIds.isNotEmpty)
|
||||
TextButton(
|
||||
onPressed: _busy ? null : _clearDraftLabels,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _labelSearchController,
|
||||
enabled: !_busy,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
hintText: 'Filter labels',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: needle.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
_labelSearchController.clear();
|
||||
setState(() {});
|
||||
},
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
child: labelEntries.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
needle.isEmpty
|
||||
? 'No labels yet'
|
||||
: 'No labels match',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: labelEntries.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
indent: 40,
|
||||
color: colors.outlineVariant
|
||||
.withValues(alpha: 0.45),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = labelEntries[index];
|
||||
return InkWell(
|
||||
onTap: _busy
|
||||
? null
|
||||
: () => _toggleDraftLabel(entry.label.id),
|
||||
onLongPress: _busy
|
||||
? null
|
||||
: () => _labelActions(entry.label),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8 + entry.depth * 16.0,
|
||||
2,
|
||||
4,
|
||||
2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.depth > 0) ...[
|
||||
Icon(
|
||||
Icons.subdirectory_arrow_right,
|
||||
size: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Icon(
|
||||
entry.label.icon,
|
||||
size: 18,
|
||||
color: entry.label.color,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.label.name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: Checkbox(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
value: _draftLabelIds
|
||||
.contains(entry.label.id),
|
||||
onChanged: _busy
|
||||
? null
|
||||
: (_) => _toggleDraftLabel(
|
||||
entry.label.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy || !_labelsDirty
|
||||
? null
|
||||
: () => _applyLabels(toMatched: false),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _busy ||
|
||||
!_labelsDirty ||
|
||||
!_applyToSimilar ||
|
||||
similarCount <= 1
|
||||
? null
|
||||
: () => _applyLabels(toMatched: true),
|
||||
child: Text(
|
||||
similarCount > 1
|
||||
? 'Apply to matched ($similarCount)'
|
||||
: 'Apply to matched',
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Material(
|
||||
elevation: 3,
|
||||
color: colors.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _busy || !_labelsDirty
|
||||
? null
|
||||
: () => _applyLabels(toMatched: false),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _busy ||
|
||||
!_labelsDirty ||
|
||||
!_applyToSimilar ||
|
||||
similarCount <= 1
|
||||
? null
|
||||
: () => _applyLabels(toMatched: true),
|
||||
child: Text(
|
||||
similarCount > 1
|
||||
? 'Apply to matched ($similarCount)'
|
||||
: 'Apply to matched',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('Notes', style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _notesController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
enabled: !_busy,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Add a private note…',
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _dirty && !_busy ? _saveNotes : null,
|
||||
child: Text(
|
||||
_applyToSimilar && similarCount > 1
|
||||
? 'Save notes for $similarCount'
|
||||
: 'Save notes',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Sub-labels also select their parents. Unchecking a parent '
|
||||
'clears its sub-labels. Long-press a row to move or delete. '
|
||||
'Use Apply to save label changes.',
|
||||
style: theme.textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user