First commit
This commit is contained in:
10
lib/layout/app_config_safe.dart
Normal file
10
lib/layout/app_config_safe.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
// Re-export of AppConfig defaults under a name that doesn't pull in
|
||||
// pkg-relative cycles when ChartConfig is constructed in tests.
|
||||
export '../config/app_config.dart' show AppConfig;
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class AppConfigSafe {
|
||||
AppConfigSafe._();
|
||||
static const double defaultYMin = AppConfig.defaultYMin;
|
||||
static const double defaultYMax = AppConfig.defaultYMax;
|
||||
}
|
||||
60
lib/layout/chart_config.dart
Normal file
60
lib/layout/chart_config.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'app_config_safe.dart';
|
||||
|
||||
enum YMode { auto, fixed, userZoomed }
|
||||
|
||||
YMode yModeFromString(String s) {
|
||||
switch (s) {
|
||||
case 'fixed': return YMode.fixed;
|
||||
case 'userZoomed': return YMode.userZoomed;
|
||||
case 'auto':
|
||||
default: return YMode.auto;
|
||||
}
|
||||
}
|
||||
|
||||
String yModeToString(YMode m) {
|
||||
switch (m) {
|
||||
case YMode.auto: return 'auto';
|
||||
case YMode.fixed: return 'fixed';
|
||||
case YMode.userZoomed: return 'userZoomed';
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel display configuration.
|
||||
///
|
||||
/// `channel` is the proto channel index (1..16). `enabled` toggles UI
|
||||
/// visibility — disabled channels still record to the buffer and to CSV.
|
||||
class ChartConfig {
|
||||
ChartConfig({
|
||||
required this.channel,
|
||||
required this.name,
|
||||
this.enabled = true,
|
||||
this.yMode = YMode.auto,
|
||||
this.yMin = AppConfigSafe.defaultYMin,
|
||||
this.yMax = AppConfigSafe.defaultYMax,
|
||||
});
|
||||
|
||||
final int channel;
|
||||
String name;
|
||||
bool enabled;
|
||||
YMode yMode;
|
||||
double yMin;
|
||||
double yMax;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'channel': channel,
|
||||
'name': name,
|
||||
'enabled': enabled,
|
||||
'yMode': yModeToString(yMode),
|
||||
'yMin': yMin,
|
||||
'yMax': yMax,
|
||||
};
|
||||
|
||||
static ChartConfig fromJson(Map<String, dynamic> j) => ChartConfig(
|
||||
channel: j['channel'] as int,
|
||||
name: j['name'] as String,
|
||||
enabled: j['enabled'] as bool? ?? true,
|
||||
yMode: yModeFromString(j['yMode'] as String? ?? 'auto'),
|
||||
yMin: (j['yMin'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMin,
|
||||
yMax: (j['yMax'] as num?)?.toDouble() ?? AppConfigSafe.defaultYMax,
|
||||
);
|
||||
}
|
||||
77
lib/layout/grid_config.dart
Normal file
77
lib/layout/grid_config.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
/// Grid shape and per-cell channel assignments.
|
||||
///
|
||||
/// Cell numbering is 1..N in row-major order. A cell may be unassigned
|
||||
/// (channel = null), in which case it renders as empty.
|
||||
class GridConfig {
|
||||
GridConfig({
|
||||
required this.rows,
|
||||
required this.cols,
|
||||
List<int?>? cellChannels,
|
||||
}) : cellChannels =
|
||||
cellChannels ?? List<int?>.filled(rows * cols, null);
|
||||
|
||||
int rows;
|
||||
int cols;
|
||||
/// Length = rows * cols. Element is the proto channel index (1..16) or null.
|
||||
List<int?> cellChannels;
|
||||
|
||||
int get cellCount => rows * cols;
|
||||
|
||||
/// 1-based cell number → channel, or null if unassigned.
|
||||
int? channelForCell(int cellNumber) {
|
||||
if (cellNumber < 1 || cellNumber > cellCount) return null;
|
||||
return cellChannels[cellNumber - 1];
|
||||
}
|
||||
|
||||
/// Set the channel assigned to a 1-based cell. If [channel] is already
|
||||
/// assigned to a different cell, that cell is cleared (auto-swap).
|
||||
void assign(int cellNumber, int? channel) {
|
||||
if (cellNumber < 1 || cellNumber > cellCount) return;
|
||||
if (channel != null) {
|
||||
for (var i = 0; i < cellChannels.length; i++) {
|
||||
if (cellChannels[i] == channel) cellChannels[i] = null;
|
||||
}
|
||||
}
|
||||
cellChannels[cellNumber - 1] = channel;
|
||||
}
|
||||
|
||||
/// Resize the grid. Channel assignments to cells beyond the new range
|
||||
/// are dropped.
|
||||
void resize(int newRows, int newCols) {
|
||||
final newCells = List<int?>.filled(newRows * newCols, null);
|
||||
final keep = newCells.length < cellChannels.length
|
||||
? newCells.length
|
||||
: cellChannels.length;
|
||||
for (var i = 0; i < keep; i++) {
|
||||
newCells[i] = cellChannels[i];
|
||||
}
|
||||
rows = newRows;
|
||||
cols = newCols;
|
||||
cellChannels = newCells;
|
||||
}
|
||||
|
||||
/// Auto-assign: fill cells with the lowest-numbered enabled channels.
|
||||
void autoAssign(List<int> enabledChannels) {
|
||||
cellChannels = List<int?>.filled(cellCount, null);
|
||||
final n = enabledChannels.length < cellCount
|
||||
? enabledChannels.length
|
||||
: cellCount;
|
||||
for (var i = 0; i < n; i++) {
|
||||
cellChannels[i] = enabledChannels[i];
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'rows': rows,
|
||||
'cols': cols,
|
||||
'cellChannels': cellChannels,
|
||||
};
|
||||
|
||||
static GridConfig fromJson(Map<String, dynamic> j) => GridConfig(
|
||||
rows: j['rows'] as int,
|
||||
cols: j['cols'] as int,
|
||||
cellChannels: (j['cellChannels'] as List)
|
||||
.map((e) => e as int?)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
202
lib/layout/layout_controller.dart
Normal file
202
lib/layout/layout_controller.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
import 'chart_config.dart';
|
||||
import 'grid_config.dart';
|
||||
|
||||
/// Owns the dashboard's display configuration.
|
||||
///
|
||||
/// - Grid shape and channel-to-cell assignment (GridConfig).
|
||||
/// - Per-channel display config (ChartConfig × 16).
|
||||
/// - Status indicator display names (List<String> × 8).
|
||||
///
|
||||
/// Persistence: shared_preferences under the `layout.` prefix.
|
||||
class LayoutController extends ChangeNotifier {
|
||||
LayoutController._({
|
||||
required this.grid,
|
||||
required this.charts,
|
||||
required this.statusNames,
|
||||
});
|
||||
|
||||
GridConfig grid;
|
||||
/// Indexed 0..15 corresponding to channels 1..16.
|
||||
List<ChartConfig> charts;
|
||||
/// Indexed 0..7 corresponding to status1..status8.
|
||||
List<String> statusNames;
|
||||
|
||||
static Future<LayoutController> load() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final gridJson = p.getString('layout.grid');
|
||||
final chartsJson = p.getString('layout.charts');
|
||||
final statusJson = p.getString('layout.statusNames');
|
||||
|
||||
final grid = gridJson != null
|
||||
? GridConfig.fromJson(jsonDecode(gridJson) as Map<String, dynamic>)
|
||||
: GridConfig(
|
||||
rows: AppConfig.defaultGridRows,
|
||||
cols: AppConfig.defaultGridCols,
|
||||
);
|
||||
|
||||
final charts = chartsJson != null
|
||||
? (jsonDecode(chartsJson) as List)
|
||||
.map((e) => ChartConfig.fromJson(e as Map<String, dynamic>))
|
||||
.toList()
|
||||
: List<ChartConfig>.generate(
|
||||
AppConfig.channelCount,
|
||||
(i) => ChartConfig(
|
||||
channel: i + 1,
|
||||
name: AppConfig.defaultChannelNames[i],
|
||||
enabled: i < AppConfig.defaultGridRows * AppConfig.defaultGridCols,
|
||||
),
|
||||
);
|
||||
|
||||
final statusNames = statusJson != null
|
||||
? (jsonDecode(statusJson) as List).cast<String>()
|
||||
: List<String>.from(AppConfig.defaultStatusNames);
|
||||
|
||||
final ctrl = LayoutController._(
|
||||
grid: grid,
|
||||
charts: charts,
|
||||
statusNames: statusNames,
|
||||
);
|
||||
|
||||
// First-run convenience: auto-assign enabled channels to grid cells if
|
||||
// the saved grid is empty.
|
||||
if (gridJson == null) {
|
||||
ctrl.grid.autoAssign(
|
||||
ctrl.charts.where((c) => c.enabled).map((c) => c.channel).toList(),
|
||||
);
|
||||
}
|
||||
return ctrl;
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setString('layout.grid', jsonEncode(grid.toJson()));
|
||||
await p.setString(
|
||||
'layout.charts',
|
||||
jsonEncode(charts.map((c) => c.toJson()).toList()),
|
||||
);
|
||||
await p.setString('layout.statusNames', jsonEncode(statusNames));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Channel 1..16 → ChartConfig.
|
||||
ChartConfig configFor(int channel) => charts[channel - 1];
|
||||
|
||||
/// Replace state from another LayoutController (used by the Layout dialog
|
||||
/// when applying edits from a working copy).
|
||||
void copyFrom(LayoutController other) {
|
||||
grid = GridConfig(
|
||||
rows: other.grid.rows,
|
||||
cols: other.grid.cols,
|
||||
cellChannels: List<int?>.from(other.grid.cellChannels),
|
||||
);
|
||||
charts = other.charts
|
||||
.map((c) => ChartConfig(
|
||||
channel: c.channel,
|
||||
name: c.name,
|
||||
enabled: c.enabled,
|
||||
yMode: c.yMode,
|
||||
yMin: c.yMin,
|
||||
yMax: c.yMax,
|
||||
))
|
||||
.toList();
|
||||
statusNames = List<String>.from(other.statusNames);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
LayoutController clone() {
|
||||
return LayoutController._(
|
||||
grid: GridConfig(
|
||||
rows: grid.rows,
|
||||
cols: grid.cols,
|
||||
cellChannels: List<int?>.from(grid.cellChannels),
|
||||
),
|
||||
charts: charts
|
||||
.map((c) => ChartConfig(
|
||||
channel: c.channel,
|
||||
name: c.name,
|
||||
enabled: c.enabled,
|
||||
yMode: c.yMode,
|
||||
yMin: c.yMin,
|
||||
yMax: c.yMax,
|
||||
))
|
||||
.toList(),
|
||||
statusNames: List<String>.from(statusNames),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mutators that notify on change.
|
||||
|
||||
void setChannelEnabled(int channel, bool enabled) {
|
||||
final c = configFor(channel);
|
||||
if (c.enabled == enabled) return;
|
||||
c.enabled = enabled;
|
||||
if (!enabled) {
|
||||
// Also clear from any grid cell.
|
||||
for (var i = 0; i < grid.cellChannels.length; i++) {
|
||||
if (grid.cellChannels[i] == channel) grid.cellChannels[i] = null;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setChannelName(int channel, String name) {
|
||||
final c = configFor(channel);
|
||||
if (c.name == name) return;
|
||||
c.name = name;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setYMode(int channel, YMode mode) {
|
||||
final c = configFor(channel);
|
||||
if (c.yMode == mode) return;
|
||||
c.yMode = mode;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setYRange(int channel, double yMin, double yMax) {
|
||||
final c = configFor(channel);
|
||||
if (c.yMin == yMin && c.yMax == yMax) return;
|
||||
c.yMin = yMin;
|
||||
c.yMax = yMax;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Mouse-wheel Y zoom on a chart. Switches mode to userZoomed and updates
|
||||
/// the range, keeping the pivot screen-Y fixed.
|
||||
void zoomY({
|
||||
required int channel,
|
||||
required double pivotValue,
|
||||
required double factor,
|
||||
}) {
|
||||
final c = configFor(channel);
|
||||
final span = c.yMax - c.yMin;
|
||||
final newSpan = span * factor;
|
||||
final fraction = (pivotValue - c.yMin) / span;
|
||||
c.yMin = pivotValue - newSpan * fraction;
|
||||
c.yMax = c.yMin + newSpan;
|
||||
c.yMode = YMode.userZoomed;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setStatusName(int statusIdx, String name) {
|
||||
if (statusNames[statusIdx] == name) return;
|
||||
statusNames[statusIdx] = name;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setGridSize(int rows, int cols) {
|
||||
grid.resize(rows, cols);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void assignCellToChannel(int cellNumber, int? channel) {
|
||||
grid.assign(cellNumber, channel);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user