First commit

This commit is contained in:
2026-04-21 14:40:09 -03:00
commit 9efd27afa5
48 changed files with 4511 additions and 0 deletions

View 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;
}

View 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,
);
}

View 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(),
);
}

View 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();
}
}