Dashboard - Selectable main sensor (gauge) + sub sensor, 10 selectable values (landscape 5 left + 5 right, portrait 2x5 on one screen) and 4 selectable flags; long-press to change; persisted. - Flags: the requirement examples (MIL, FAN Out, VTEC Out, Knock, WBO / IAT / CLT Error - errors derived from the stored DTC bits) plus the other S300 status bits (cuts, launch, closed loop, VTP, AC, brake). - Record button moved to the app bar; debug screen moved to Settings. Sensor list: 1 column on phone portrait, 2 on tablet/landscape; name, unit, min/max, segmented bar and value. Graph: auto-scaled Y, window max/min, current value, seconds axis and a touch cursor; RPM/MAP/TPS/ECT by default. Units: km/h|mph, degC|degF, kPa|bar|psi (display only), persisted. Light/dark: palette is a ThemeExtension used by every screen; theme persisted. SafeArea keeps content off the side navigation bar. Datalog playback now drives the dashboard, list, graph and DTC screen, with the playback bar on the main screen and seek updating the displays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
6.7 KiB
Dart
226 lines
6.7 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../datalog/datalog_db.dart';
|
|
import '../datalog/datalog_player.dart';
|
|
import '../datalog/datalog_recorder.dart';
|
|
import '../models/datalog_session.dart';
|
|
import '../bluetooth/bt_poller.dart';
|
|
import 'bt_provider.dart';
|
|
import 'sensor_history_provider.dart';
|
|
import 'settings_provider.dart';
|
|
|
|
// ─── Singletons ───────────────────────────────────────────────────────────────
|
|
|
|
final datalogDbProvider = Provider<DatalogDb>((ref) {
|
|
final db = DatalogDb();
|
|
ref.onDispose(db.close);
|
|
return db;
|
|
});
|
|
|
|
final datalogRecorderProvider = Provider<DatalogRecorder>((ref) {
|
|
final recorder = DatalogRecorder(ref.watch(datalogDbProvider));
|
|
ref.onDispose(recorder.dispose);
|
|
return recorder;
|
|
});
|
|
|
|
final datalogPlayerProvider = Provider<DatalogPlayer>((ref) {
|
|
final player = DatalogPlayer(ref.watch(datalogDbProvider));
|
|
ref.onDispose(player.dispose);
|
|
return player;
|
|
});
|
|
|
|
// ─── Recording state ──────────────────────────────────────────────────────────
|
|
|
|
enum RecordingStatus { idle, recording }
|
|
|
|
class RecordingState {
|
|
final RecordingStatus status;
|
|
final int frameCount;
|
|
final int? sessionId;
|
|
|
|
const RecordingState({
|
|
this.status = RecordingStatus.idle,
|
|
this.frameCount = 0,
|
|
this.sessionId,
|
|
});
|
|
|
|
bool get isRecording => status == RecordingStatus.recording;
|
|
|
|
RecordingState copyWith({
|
|
RecordingStatus? status,
|
|
int? frameCount,
|
|
int? sessionId,
|
|
}) =>
|
|
RecordingState(
|
|
status: status ?? this.status,
|
|
frameCount: frameCount ?? this.frameCount,
|
|
sessionId: sessionId ?? this.sessionId,
|
|
);
|
|
}
|
|
|
|
class RecordingNotifier extends StateNotifier<RecordingState> {
|
|
final Ref _ref;
|
|
|
|
RecordingNotifier(this._ref) : super(const RecordingState());
|
|
|
|
Future<void> startRecording() async {
|
|
if (state.isRecording) return;
|
|
|
|
final recorder = _ref.read(datalogRecorderProvider);
|
|
final btNotifier = _ref.read(btProvider.notifier);
|
|
final ecuType = _ref.read(settingsProvider).ecuType.name;
|
|
|
|
await recorder.start(btNotifier.frameStream, ecuType);
|
|
|
|
state = RecordingState(
|
|
status: RecordingStatus.recording,
|
|
sessionId: recorder.sessionId,
|
|
);
|
|
}
|
|
|
|
Future<DatalogSession?> stopRecording() async {
|
|
if (!state.isRecording) return null;
|
|
|
|
final recorder = _ref.read(datalogRecorderProvider);
|
|
final session = await recorder.stop();
|
|
|
|
state = const RecordingState();
|
|
// Refresh session list
|
|
_ref.invalidate(sessionListProvider);
|
|
return session;
|
|
}
|
|
|
|
/// Called periodically from UI to update live frame count display.
|
|
void syncFrameCount() {
|
|
if (!state.isRecording) return;
|
|
final count = _ref.read(datalogRecorderProvider).frameCount;
|
|
if (count != state.frameCount) {
|
|
state = state.copyWith(frameCount: count);
|
|
}
|
|
}
|
|
}
|
|
|
|
final recordingProvider =
|
|
StateNotifierProvider<RecordingNotifier, RecordingState>(
|
|
(ref) => RecordingNotifier(ref),
|
|
);
|
|
|
|
// ─── Session list ─────────────────────────────────────────────────────────────
|
|
|
|
final sessionListProvider = FutureProvider<List<DatalogSession>>((ref) {
|
|
return ref.watch(datalogDbProvider).getSessions();
|
|
});
|
|
|
|
// ─── Playback state ───────────────────────────────────────────────────────────
|
|
|
|
class PlaybackState {
|
|
final bool isActive;
|
|
final int? sessionId;
|
|
/// ECU type the session was recorded with; frames are parsed with it.
|
|
final EcuType ecuType;
|
|
final int position;
|
|
final int total;
|
|
final double speed;
|
|
final PlaybackStatus status;
|
|
|
|
const PlaybackState({
|
|
this.isActive = false,
|
|
this.sessionId,
|
|
this.ecuType = EcuType.s300,
|
|
this.position = 0,
|
|
this.total = 0,
|
|
this.speed = 1.0,
|
|
this.status = PlaybackStatus.idle,
|
|
});
|
|
|
|
double get progress => total == 0 ? 0 : position / total;
|
|
|
|
PlaybackState copyWith({
|
|
bool? isActive,
|
|
int? sessionId,
|
|
int? position,
|
|
int? total,
|
|
double? speed,
|
|
PlaybackStatus? status,
|
|
}) =>
|
|
PlaybackState(
|
|
isActive: isActive ?? this.isActive,
|
|
sessionId: sessionId ?? this.sessionId,
|
|
ecuType: ecuType,
|
|
position: position ?? this.position,
|
|
total: total ?? this.total,
|
|
speed: speed ?? this.speed,
|
|
status: status ?? this.status,
|
|
);
|
|
}
|
|
|
|
class PlaybackNotifier extends StateNotifier<PlaybackState> {
|
|
final Ref _ref;
|
|
|
|
PlaybackNotifier(this._ref) : super(const PlaybackState());
|
|
|
|
/// Loads a recorded session; while active, the dashboard, sensor list,
|
|
/// graph and DTC screen show the recording instead of live data.
|
|
Future<void> loadSession(int sessionId, String ecuType) async {
|
|
final player = _ref.read(datalogPlayerProvider);
|
|
await player.load(sessionId);
|
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
|
|
|
player.onProgress = (pos, total) {
|
|
state = state.copyWith(
|
|
position: pos,
|
|
total: total,
|
|
status: player.status,
|
|
);
|
|
};
|
|
|
|
state = PlaybackState(
|
|
isActive: true,
|
|
sessionId: sessionId,
|
|
ecuType: ecuType == EcuType.kpro.name ? EcuType.kpro : EcuType.s300,
|
|
total: player.totalFrames,
|
|
speed: player.speed,
|
|
status: PlaybackStatus.idle,
|
|
);
|
|
}
|
|
|
|
void play() {
|
|
_ref.read(datalogPlayerProvider).play();
|
|
state = state.copyWith(status: PlaybackStatus.playing);
|
|
}
|
|
|
|
void pause() {
|
|
_ref.read(datalogPlayerProvider).pause();
|
|
state = state.copyWith(status: PlaybackStatus.paused);
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
await _ref.read(datalogPlayerProvider).stop();
|
|
state = const PlaybackState();
|
|
_ref.read(sensorHistoryProvider.notifier).clear();
|
|
}
|
|
|
|
void seek(int index) {
|
|
_ref.read(datalogPlayerProvider).seek(index);
|
|
state = state.copyWith(position: index);
|
|
}
|
|
|
|
void setSpeed(double speed) {
|
|
_ref.read(datalogPlayerProvider).speed = speed;
|
|
state = state.copyWith(speed: speed);
|
|
}
|
|
}
|
|
|
|
final playbackProvider =
|
|
StateNotifierProvider<PlaybackNotifier, PlaybackState>(
|
|
(ref) => PlaybackNotifier(ref),
|
|
);
|
|
|
|
/// ECU type the displays should decode with: the recorded session's while a
|
|
/// datalog is playing back, otherwise the live setting.
|
|
final effectiveEcuTypeProvider = Provider<EcuType>((ref) {
|
|
final playback = ref.watch(playbackProvider);
|
|
if (playback.isActive) return playback.ecuType;
|
|
return ref.watch(settingsProvider.select((s) => s.ecuType));
|
|
});
|