mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Add device identification guide dialog for unregistered devices
Adds a "How to identify this device" button next to Register Device that opens a dialog with personalized clues (vendor/type) and steps to track down the physical device on the network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3d7c4a1323
commit
f3d845ce37
@@ -9,6 +9,7 @@ import '../utils/oott_api.dart';
|
||||
import '../widgets/status_badge.dart';
|
||||
import 'device_actions.dart';
|
||||
import 'device_event_history.dart';
|
||||
import 'device_identification_guide.dart';
|
||||
import '../theme/dimens.dart';
|
||||
import '../utils/placeholders.dart';
|
||||
|
||||
@@ -241,10 +242,21 @@ class _DeviceActions extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
return FilledButton.icon(
|
||||
onPressed: () => showRegisterDeviceDialog(context, device, onAction),
|
||||
icon: const Icon(Icons.how_to_reg),
|
||||
label: const Text('Register Device'),
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => showRegisterDeviceDialog(context, device, onAction),
|
||||
icon: const Icon(Icons.how_to_reg),
|
||||
label: const Text('Register Device'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showDeviceIdentificationDialog(context, device),
|
||||
icon: const Icon(Icons.help_outline),
|
||||
label: const Text('How to identify this device'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../model/device.dart';
|
||||
import '../model/device_type.dart';
|
||||
import '../theme/dimens.dart';
|
||||
|
||||
/// Opens a dialog that helps the user work out which physical device on their
|
||||
/// network an unregistered entry corresponds to.
|
||||
Future<void> showDeviceIdentificationDialog(
|
||||
BuildContext context,
|
||||
Device device,
|
||||
) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _IdentificationDialog(device: device),
|
||||
);
|
||||
}
|
||||
|
||||
class _IdentificationDialog extends StatelessWidget {
|
||||
final Device device;
|
||||
|
||||
const _IdentificationDialog({required this.device});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final steps = _buildSteps();
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Identify this device'),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_intro(), style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: Insets.lg),
|
||||
for (var i = 0; i < steps.length; i++) ...[
|
||||
_GuideStep(step: steps[i]),
|
||||
if (i < steps.length - 1) const SizedBox(height: Insets.lg),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A short, personalised sentence framing whatever clues OOTT already has.
|
||||
String _intro() {
|
||||
final hasVendor = device.vendor.isNotEmpty;
|
||||
final hasType = device.deviceType != DeviceType.unknown;
|
||||
|
||||
if (hasVendor && hasType) {
|
||||
return 'OOTT thinks this is a ${device.deviceType.label} with networking '
|
||||
'hardware from ${device.vendor}. Use that as a starting clue, then '
|
||||
'narrow it down with the steps below.';
|
||||
}
|
||||
if (hasVendor) {
|
||||
return 'OOTT detected networking hardware from ${device.vendor}. Use '
|
||||
'that as a starting clue, then narrow it down with the steps below.';
|
||||
}
|
||||
if (hasType) {
|
||||
return 'OOTT thinks this is a ${device.deviceType.label}. Use that as a '
|
||||
'starting clue, then narrow it down with the steps below.';
|
||||
}
|
||||
return "OOTT couldn't infer the vendor or type for this device, but the "
|
||||
'steps below can still help you track it down.';
|
||||
}
|
||||
|
||||
List<_Step> _buildSteps() {
|
||||
return [
|
||||
if (device.vendor.isNotEmpty)
|
||||
_Step(
|
||||
icon: Icons.memory,
|
||||
title: 'Check the manufacturer',
|
||||
body:
|
||||
'The vendor (${device.vendor}) comes from the MAC address and '
|
||||
'identifies who made the network chip — often a different company '
|
||||
'than the brand on the product. Searching for their products can '
|
||||
'hint at what this is.',
|
||||
),
|
||||
const _Step(
|
||||
icon: Icons.power_settings_new,
|
||||
title: 'Try the unplug test',
|
||||
body:
|
||||
'Power off or unplug a device you suspect, wait a minute, then '
|
||||
'reopen this page. If its "Last Seen" time stops updating, you have '
|
||||
'found it.',
|
||||
),
|
||||
_Step(
|
||||
icon: Icons.router,
|
||||
title: 'Check your router',
|
||||
body:
|
||||
'Sign in to your router (often http://192.168.1.1) and open its '
|
||||
'list of connected devices. Match this MAC address '
|
||||
'(${device.macAddress}) or IP address (${device.ipv4Address}) to '
|
||||
'see the name your router has for it.',
|
||||
),
|
||||
_Step(
|
||||
icon: Icons.travel_explore,
|
||||
title: 'Scan its open ports (advanced)',
|
||||
body:
|
||||
'From a computer on the same network, run a port scan such as '
|
||||
'"nmap ${device.ipv4Address}". The services it exposes can reveal '
|
||||
'its role — for example a web page often means a camera, NAS or '
|
||||
'printer.',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// One identification method: an icon, a heading and an explanation.
|
||||
class _Step {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
const _Step({required this.icon, required this.title, required this.body});
|
||||
}
|
||||
|
||||
class _GuideStep extends StatelessWidget {
|
||||
final _Step step;
|
||||
|
||||
const _GuideStep({required this.step});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(step.icon, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: Insets.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(step.title, style: theme.textTheme.titleSmall),
|
||||
const SizedBox(height: Insets.xs),
|
||||
// Selectable so users can copy the MAC/IP straight out of a step.
|
||||
SelectableText(step.body, style: theme.textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frontend/devices/device_identification_guide.dart';
|
||||
import 'package:frontend/model/device.dart';
|
||||
|
||||
import '../helpers/fixtures.dart';
|
||||
import '../helpers/pump_app.dart';
|
||||
|
||||
void main() {
|
||||
Device device({String vendor = 'Acme Corp', String deviceType = 'laptop'}) =>
|
||||
Device.fromJson(deviceJson(vendor: vendor, deviceType: deviceType));
|
||||
|
||||
// Pumps a button that opens the dialog, then taps it so the dialog is shown.
|
||||
Future<void> openDialog(WidgetTester tester, Device d) async {
|
||||
await pumpScreen(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => showDeviceIdentificationDialog(context, d),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('shows every guidance step', (tester) async {
|
||||
await openDialog(tester, device());
|
||||
|
||||
expect(find.text('Identify this device'), findsOneWidget);
|
||||
expect(find.text('Check the manufacturer'), findsOneWidget);
|
||||
expect(find.text('Try the unplug test'), findsOneWidget);
|
||||
expect(find.text('Check your router'), findsOneWidget);
|
||||
expect(find.text('Scan its open ports (advanced)'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('weaves the vendor and type into the intro', (tester) async {
|
||||
await openDialog(
|
||||
tester,
|
||||
device(vendor: 'Acme Corp', deviceType: 'printer'),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining('Printer'),
|
||||
findsWidgets,
|
||||
reason: 'the inferred device type should appear in the intro',
|
||||
);
|
||||
expect(find.textContaining('Acme Corp'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('closes when Close is tapped', (tester) async {
|
||||
await openDialog(tester, device());
|
||||
|
||||
await tester.tap(find.text('Close'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Identify this device'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user