Files
oott/frontend/lib/devices/device_event_history.dart
T
rzuastiandClaude Opus 4.8 a51b9c06dc Display unknown values as a dash and centralise repeated literals
Backend: notifications now show a plain "-" for an absent name, vendor, or
device type (was empty string / "(unknown)" / "Unknown"), via a single
UNKNOWN_PLACEHOLDER constant.

Frontend:
- Empty/unknown values render as an em dash everywhere, centralised in a new
  Placeholders.emptyValue constant (replaces inline '—' and '(unknown)').
- Route paths moved to a new Routes class, used by the router and every
  navigation call site.
- Device event type modelled as a DeviceEventType enum mirroring the backend
  (NewDevice/DeviceSeen) instead of bare string comparisons.
- Hardcoded EdgeInsets/SizedBox spacing replaced with existing Insets tokens.

Tests and formatting updated; all backend and frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:07:05 -04:00

312 lines
9.4 KiB
Dart

import 'package:dio/dio.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../model/device.dart';
import '../model/device_event.dart';
import '../model/device_event_type.dart';
import '../utils/oott_api.dart';
import '../widgets/filter_selector.dart';
import '../theme/dimens.dart';
import '../utils/placeholders.dart';
enum _TimeRange {
today('Today'),
lastWeek('Last week'),
lastThreeMonths('Last 3 months'),
lastSixMonths('Last 6 months'),
lastYear('Last year');
const _TimeRange(this.label);
final String label;
DateTime get cutoff {
final now = DateTime.now();
return switch (this) {
_TimeRange.today => DateTime(now.year, now.month, now.day),
_TimeRange.lastWeek => now.subtract(const Duration(days: 7)),
_TimeRange.lastThreeMonths => now.subtract(const Duration(days: 90)),
_TimeRange.lastSixMonths => now.subtract(const Duration(days: 180)),
_TimeRange.lastYear => now.subtract(const Duration(days: 365)),
};
}
double get xIntervalMs {
const hour = 3600000.0;
const day = 86400000.0;
return switch (this) {
_TimeRange.today => 4 * hour,
_TimeRange.lastWeek => day,
_TimeRange.lastThreeMonths => 14 * day,
_TimeRange.lastSixMonths => 30 * day,
_TimeRange.lastYear => 60 * day,
};
}
String formatLabel(DateTime dt) {
final isCurrentYear = dt.year == DateTime.now().year;
return switch (this) {
_TimeRange.today => DateFormat('HH:mm').format(dt),
_TimeRange.lastWeek =>
isCurrentYear
? DateFormat('EEE').format(dt)
: DateFormat('EEE yyyy').format(dt),
_TimeRange.lastThreeMonths || _TimeRange.lastSixMonths =>
isCurrentYear
? DateFormat('MMM d').format(dt)
: DateFormat('MMM d, yyyy').format(dt),
_TimeRange.lastYear =>
isCurrentYear
? DateFormat('MMM').format(dt)
: DateFormat('MMM yyyy').format(dt),
};
}
}
class DeviceEventHistory extends StatefulWidget {
final Device device;
const DeviceEventHistory({super.key, required this.device});
@override
State<DeviceEventHistory> createState() => _DeviceEventHistoryState();
}
class _DeviceEventHistoryState extends State<DeviceEventHistory> {
List<DeviceEvent>? _events;
bool _isLoading = true;
String? _error;
_TimeRange _selectedRange = _TimeRange.lastWeek;
@override
void initState() {
super.initState();
_loadEvents();
}
Future<void> _loadEvents() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final events = await BackendAPI.instance.getDeviceEvents(
widget.device.macAddress,
createdFrom: _selectedRange.cutoff,
);
if (!mounted) return;
setState(() {
_events = events;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
_error = dioErrorToUserMessage(e);
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Padding(
padding: EdgeInsets.all(Insets.xxxl),
child: Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Insets.lg),
child: Center(child: Text('Failed to load event history: $_error')),
);
}
final events = _events ?? [];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilterSelector<_TimeRange>(
values: _TimeRange.values,
selected: _selectedRange,
labelOf: (r) => r.label,
onSelected: (range) {
setState(() => _selectedRange = range);
_loadEvents();
},
),
const SizedBox(height: Insets.lg),
if (events.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: Insets.xxl),
child: Center(child: Text('No events in this time range')),
)
else
SizedBox(
height: 160,
child: _EventChart(
events: events,
device: widget.device,
range: _selectedRange,
),
),
],
);
}
}
class _EventChart extends StatelessWidget {
final List<DeviceEvent> events;
final Device device;
final _TimeRange range;
const _EventChart({
required this.events,
required this.device,
required this.range,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final now = DateTime.now();
final cutoff = range.cutoff;
final intervalMs = range.xIntervalMs;
final cutoffMs = cutoff.millisecondsSinceEpoch.toDouble();
final nowMs = now.millisecondsSinceEpoch.toDouble();
// Snap axis bounds to interval boundaries so every tick falls within the chart.
// This may add a small margin on each side, which is intentional.
final minX = (cutoffMs / intervalMs).floor() * intervalMs;
final maxX = (nowMs / intervalMs).ceil() * intervalMs;
final spots = events.map((e) {
final isNew = e.eventType == DeviceEventType.newDevice;
return ScatterSpot(
e.createdOn.millisecondsSinceEpoch.toDouble(),
1.0,
dotPainter: FlDotCirclePainter(
radius: isNew ? 9.0 : 6.0,
color: theme.colorScheme.tertiary,
),
);
}).toList();
return ScatterChart(
ScatterChartData(
scatterSpots: spots,
minX: minX,
maxX: maxX,
minY: 0.0,
maxY: 2.0,
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawHorizontalLine: false,
verticalInterval: range.xIntervalMs,
),
titlesData: FlTitlesData(
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: range.xIntervalMs,
reservedSize: 28,
getTitlesWidget: (value, meta) {
final dt = DateTime.fromMillisecondsSinceEpoch(value.toInt());
return SideTitleWidget(
meta: meta,
fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
child: Text(
range.formatLabel(dt),
style: theme.textTheme.labelSmall,
),
);
},
),
),
),
scatterTouchData: ScatterTouchData(
touchTooltipData: ScatterTouchTooltipData(
maxContentWidth: 260,
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipColor: (_) => theme.colorScheme.surfaceContainerHighest,
getTooltipItems: (ScatterSpot touchedSpot) {
final idx = events.indexWhere(
(e) =>
e.createdOn.millisecondsSinceEpoch.toDouble() ==
touchedSpot.x,
);
if (idx < 0) return null;
final event = events[idx];
final dt = event.createdOn.toLocal();
final dateStr = DateFormat('MMM d, yyyy HH:mm').format(dt);
final baseLabel = event.eventType == DeviceEventType.newDevice
? 'First seen'
: 'Device seen';
final typeLabel = '$baseLabel (${event.scannerLabel})';
final diffs = <TextSpan>[];
if (event.ipv4Address != device.ipv4Address) {
diffs.add(
TextSpan(
text: '\nIP: ${event.ipv4Address}${device.ipv4Address}',
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
if (event.vendor != device.vendor) {
final eventVendor = event.vendor.isEmpty
? Placeholders.emptyValue
: event.vendor;
final currentVendor = device.vendor.isEmpty
? Placeholders.emptyValue
: device.vendor;
diffs.add(
TextSpan(
text: '\nVendor: $eventVendor$currentVendor',
style: TextStyle(
color: theme.colorScheme.error,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
);
}
return ScatterTooltipItem(
'$dateStr\n$typeLabel',
textStyle: TextStyle(
color: theme.colorScheme.onSurface,
fontSize: 12,
),
children: diffs.isEmpty ? null : diffs,
);
},
),
),
),
);
}
}