402 lines
13 KiB
Dart
402 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../config/app_config.dart';
|
||
import '../../layout/chart_config.dart';
|
||
import '../../layout/layout_controller.dart';
|
||
import '../app_scope.dart';
|
||
|
||
class LayoutDialog extends StatefulWidget {
|
||
const LayoutDialog({super.key});
|
||
|
||
@override
|
||
State<LayoutDialog> createState() => _LayoutDialogState();
|
||
}
|
||
|
||
class _LayoutDialogState extends State<LayoutDialog> {
|
||
late LayoutController _draft;
|
||
static const _gridOptions = [
|
||
(2, 2), (2, 3), (3, 3), (3, 4), (4, 4),
|
||
];
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final live = context.findAncestorWidgetOfExactType<AppScope>()!.layout;
|
||
_draft = live.clone();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: const Text('Layout configuration'),
|
||
content: SizedBox(
|
||
width: 620,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_gridSection(),
|
||
const SizedBox(height: 16),
|
||
_channelsSection(),
|
||
const SizedBox(height: 16),
|
||
_statusNamesSection(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
_draft.grid.autoAssign(
|
||
_draft.charts
|
||
.where((c) => c.enabled)
|
||
.map((c) => c.channel)
|
||
.toList(),
|
||
);
|
||
});
|
||
},
|
||
child: const Text('Auto-assign'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => _apply(context),
|
||
child: const Text('Apply'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _apply(BuildContext context) async {
|
||
final scope = AppScope.of(context);
|
||
scope.layout.copyFrom(_draft);
|
||
await scope.layout.save();
|
||
if (context.mounted) Navigator.of(context).pop();
|
||
}
|
||
|
||
Widget _gridSection() {
|
||
final enabled =
|
||
_draft.charts.where((c) => c.enabled).length;
|
||
final empty = _draft.grid.cellCount -
|
||
_draft.grid.cellChannels.where((c) => c != null).length;
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(
|
||
width: 130,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Grid size',
|
||
style: TextStyle(fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 4),
|
||
DropdownButton<(int, int)>(
|
||
value: _gridOptions.firstWhere(
|
||
(g) => g.$1 == _draft.grid.rows && g.$2 == _draft.grid.cols,
|
||
orElse: () => _gridOptions.first,
|
||
),
|
||
items: _gridOptions
|
||
.map((g) => DropdownMenuItem(
|
||
value: g,
|
||
child: Text('${g.$1} × ${g.$2}'),
|
||
))
|
||
.toList(),
|
||
onChanged: (g) {
|
||
if (g == null) return;
|
||
setState(() => _draft.grid.resize(g.$1, g.$2));
|
||
},
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'${_draft.grid.cellCount} cells · '
|
||
'$enabled enabled · $empty empty',
|
||
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(child: _gridPreview()),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _gridPreview() {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(
|
||
color: scheme.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: _draft.grid.cols,
|
||
mainAxisSpacing: 6,
|
||
crossAxisSpacing: 6,
|
||
childAspectRatio: 16 / 9,
|
||
),
|
||
itemCount: _draft.grid.cellCount,
|
||
itemBuilder: (_, i) {
|
||
final cellNumber = i + 1;
|
||
final ch = _draft.grid.channelForCell(cellNumber);
|
||
if (ch == null) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
border: Border.all(
|
||
color: scheme.outlineVariant,
|
||
style: BorderStyle.solid,
|
||
),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'$cellNumber',
|
||
style: TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 10,
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
Text(
|
||
'empty',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: scheme.onSurfaceVariant,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
final cfg = _draft.configFor(ch);
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: scheme.surface,
|
||
border: Border.all(color: scheme.outlineVariant),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'$cellNumber',
|
||
style: TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 10,
|
||
color: scheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
Text(
|
||
'CH$ch · ${cfg.name}',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w500,
|
||
color: scheme.onSurface,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _channelsSection() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.only(bottom: 6),
|
||
child: Text('Channels',
|
||
style: TextStyle(fontWeight: FontWeight.w500)),
|
||
),
|
||
...List.generate(AppConfig.channelCount, (i) => _channelRow(i + 1)),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _channelRow(int channel) {
|
||
final cfg = _draft.configFor(channel);
|
||
final assignedCell = () {
|
||
for (var i = 0; i < _draft.grid.cellChannels.length; i++) {
|
||
if (_draft.grid.cellChannels[i] == channel) return i + 1;
|
||
}
|
||
return 0; // 0 = unassigned
|
||
}();
|
||
return Opacity(
|
||
opacity: cfg.enabled ? 1.0 : 0.55,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: Row(
|
||
children: [
|
||
Checkbox(
|
||
value: cfg.enabled,
|
||
onChanged: (v) => setState(() {
|
||
cfg.enabled = v ?? false;
|
||
if (!cfg.enabled) {
|
||
for (var i = 0; i < _draft.grid.cellChannels.length; i++) {
|
||
if (_draft.grid.cellChannels[i] == channel) {
|
||
_draft.grid.cellChannels[i] = null;
|
||
}
|
||
}
|
||
}
|
||
}),
|
||
),
|
||
SizedBox(
|
||
width: 40,
|
||
child: Text('CH$channel',
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 11,
|
||
color: Colors.grey)),
|
||
),
|
||
Expanded(
|
||
child: TextFormField(
|
||
initialValue: cfg.name,
|
||
enabled: cfg.enabled,
|
||
decoration: const InputDecoration(
|
||
isDense: true, contentPadding: EdgeInsets.all(6)),
|
||
onChanged: (v) => cfg.name = v,
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
SizedBox(
|
||
width: 64,
|
||
child: DropdownButton<int>(
|
||
value: assignedCell,
|
||
isExpanded: true,
|
||
onChanged: cfg.enabled
|
||
? (v) => setState(() {
|
||
_draft.grid.assign(v ?? 0, v == 0 ? null : channel);
|
||
})
|
||
: null,
|
||
items: [
|
||
const DropdownMenuItem(value: 0, child: Text('—')),
|
||
...List.generate(
|
||
_draft.grid.cellCount,
|
||
(i) => DropdownMenuItem(
|
||
value: i + 1,
|
||
child: Text('${i + 1}'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
SizedBox(
|
||
width: 78,
|
||
child: DropdownButton<YMode>(
|
||
value:
|
||
cfg.yMode == YMode.userZoomed ? YMode.fixed : cfg.yMode,
|
||
isExpanded: true,
|
||
onChanged: cfg.enabled
|
||
? (m) => setState(() => cfg.yMode = m ?? YMode.auto)
|
||
: null,
|
||
items: const [
|
||
DropdownMenuItem(value: YMode.auto, child: Text('auto')),
|
||
DropdownMenuItem(value: YMode.fixed, child: Text('fixed')),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
SizedBox(
|
||
width: 70,
|
||
child: TextFormField(
|
||
initialValue: cfg.yMin.toString(),
|
||
enabled: cfg.enabled && cfg.yMode != YMode.auto,
|
||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||
textAlign: TextAlign.right,
|
||
decoration: const InputDecoration(
|
||
isDense: true, contentPadding: EdgeInsets.all(6)),
|
||
onChanged: (v) =>
|
||
cfg.yMin = double.tryParse(v) ?? cfg.yMin,
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
SizedBox(
|
||
width: 70,
|
||
child: TextFormField(
|
||
initialValue: cfg.yMax.toString(),
|
||
enabled: cfg.enabled && cfg.yMode != YMode.auto,
|
||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
||
textAlign: TextAlign.right,
|
||
decoration: const InputDecoration(
|
||
isDense: true, contentPadding: EdgeInsets.all(6)),
|
||
onChanged: (v) =>
|
||
cfg.yMax = double.tryParse(v) ?? cfg.yMax,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statusNamesSection() {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.only(bottom: 4),
|
||
child: Text('Status indicator names',
|
||
style: TextStyle(fontWeight: FontWeight.w500)),
|
||
),
|
||
const Padding(
|
||
padding: EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
'Names shown on the status bar pills for the 8 status fields.',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||
),
|
||
),
|
||
GridView.builder(
|
||
shrinkWrap: true,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||
crossAxisCount: 2,
|
||
mainAxisSpacing: 4,
|
||
crossAxisSpacing: 10,
|
||
childAspectRatio: 6,
|
||
),
|
||
itemCount: 8,
|
||
itemBuilder: (_, i) => Row(children: [
|
||
SizedBox(
|
||
width: 56,
|
||
child: Text('status${i + 1}',
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace',
|
||
fontSize: 11,
|
||
color: Colors.grey)),
|
||
),
|
||
Expanded(
|
||
child: TextFormField(
|
||
initialValue: _draft.statusNames[i],
|
||
decoration: const InputDecoration(
|
||
isDense: true, contentPadding: EdgeInsets.all(6)),
|
||
onChanged: (v) => _draft.statusNames[i] = v,
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
} |