Redesign Home screen with device summary and scanner status

Adds a responsive Home screen combining the notification list with a
device summary panel and ARP scanner status, visible side-by-side on
wide screens and stacked on narrow ones.

Backend:
- New GET /api/devices/summary endpoint returning registered device
  counts and "seen in last 24h / 7 days" breakdowns by registration
  status
- Migration 07 adds indexes on (is_registered) and (last_seen,
  is_registered) so summary queries use index range scans instead of
  full table scans

Frontend:
- HomeScreen replaces NotificationList, embedding notifications,
  device summary card, and ArpScannerCard in a LayoutBuilder-driven
  two-column (≥700 px) or single-column layout
- ArpScannerCard extracted to a shared public widget reused by both
  HomeScreen and StatusScreen
- Nav rail entry renamed to "Home" with home icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-28 19:10:08 -04:00
co-authored by Claude Sonnet 4.6
parent dc8f47e66d
commit 251d8a479e
13 changed files with 802 additions and 367 deletions
+5 -5
View File
@@ -26,15 +26,15 @@
- [x] Style app title like favicon (font Barlow Condensed in a pill format with "primary" background) - [x] Style app title like favicon (font Barlow Condensed in a pill format with "primary" background)
- [x] Update the device type management to consider the following list: phone, laptop, tablet, server, tv, printer, network_appliance (router, switch, firewall, etc.), home_security (camera, doorbell, etc.), home_appliance (fridge, dish washer, washer, dryer, etc.), watch, pc, gaming_console, unknown (use when a vendor cannot be clearly identified with any of the device types or a device is of a vendor not in the vendors json file) - [x] Update the device type management to consider the following list: phone, laptop, tablet, server, tv, printer, network_appliance (router, switch, firewall, etc.), home_security (camera, doorbell, etc.), home_appliance (fridge, dish washer, washer, dryer, etc.), watch, pc, gaming_console, unknown (use when a vendor cannot be clearly identified with any of the device types or a device is of a vendor not in the vendors json file)
- [x] Change the unkown device icon (maybe just a question mark) - [x] Change the unkown device icon (maybe just a question mark)
- [ ] Scan process monitor and summary page - [x] Scan process monitor and summary page
- [ ] Is the scan process running - [x] Is the scan process running
- [ ] Last run (when, how long did it take, how many devices did it found) - [x] Last run (when, how long did it take, how many devices did it found)
- [ ] When is the next run - [x] When is the next run
- [ ] Rework notifications page into a homepage for the app - [ ] Rework notifications page into a homepage for the app
- [ ] Notifications list - [ ] Notifications list
- [ ] Summary of devices recorded (how many in total, how many seen in the last day, how many not seen for a week) - [ ] Summary of devices recorded (how many in total, how many seen in the last day, how many not seen for a week)
- [ ] Scanning process summary (is it running, when will it run again) - [ ] Scanning process summary (is it running, when will it run again)
- [ ] About page - [x] About page
## Improve engine ## Improve engine
@@ -0,0 +1,2 @@
CREATE INDEX idx_devices_is_registered ON devices (is_registered);
CREATE INDEX idx_devices_last_seen_is_registered ON devices (last_seen, is_registered);
+96 -2
View File
@@ -1,10 +1,10 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Duration, Utc};
use log::{debug, error}; use log::{debug, error};
use rusqlite::{params, params_from_iter}; use rusqlite::{params, params_from_iter};
use crate::{ use crate::{
db::{self, error::DbError}, db::{self, error::DbError},
model::devices::Device, model::devices::{Device, DeviceSummary},
}; };
pub fn list_devices( pub fn list_devices(
@@ -122,6 +122,47 @@ pub fn insert(device: Device) -> Result<(), DbError> {
} }
} }
pub fn get_summary() -> Result<DeviceSummary, DbError> {
debug!("Getting device summary");
let conn = db::get_db_connection();
let one_day_ago = (Utc::now() - Duration::days(1)).to_rfc3339();
let one_week_ago = (Utc::now() - Duration::weeks(1)).to_rfc3339();
let total_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE is_registered = 1",
[],
|row| row.get(0),
)?;
let seen_last_day_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1",
params![one_day_ago],
|row| row.get(0),
)?;
let seen_last_day_unregistered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0",
params![one_day_ago],
|row| row.get(0),
)?;
let seen_last_week_registered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 1",
params![one_week_ago],
|row| row.get(0),
)?;
let seen_last_week_unregistered: i64 = conn.query_row(
"SELECT COUNT(*) FROM devices WHERE last_seen >= ?1 AND is_registered = 0",
params![one_week_ago],
|row| row.get(0),
)?;
Ok(DeviceSummary {
total_registered,
seen_last_day_registered,
seen_last_day_unregistered,
seen_last_week_registered,
seen_last_week_unregistered,
})
}
pub fn update(device: Device) -> Result<(), DbError> { pub fn update(device: Device) -> Result<(), DbError> {
let conn = db::get_db_connection(); let conn = db::get_db_connection();
match conn.execute( match conn.execute(
@@ -432,6 +473,59 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_get_summary() {
tests_common::setup().await;
// Insert a registered device seen now
insert(Device {
mac_address: "su:mm:ar:y1:01:01".to_string(),
ipv4_address: "192.168.50.1".to_string(),
vendor: "Test".to_string(),
last_seen: Utc::now(),
is_registered: true,
owner: "Test".to_string(),
device_type: "Server".to_string(),
})
.unwrap();
// Insert an unregistered device seen now
insert(Device {
mac_address: "su:mm:ar:y1:02:02".to_string(),
ipv4_address: "192.168.50.2".to_string(),
vendor: "Test".to_string(),
last_seen: Utc::now(),
is_registered: false,
owner: "".to_string(),
device_type: "".to_string(),
})
.unwrap();
let summary = get_summary().unwrap();
// bb and cc from seed data are registered, plus our new one
assert!(
summary.total_registered >= 3,
"Should have at least 3 registered devices"
);
assert!(
summary.seen_last_day_registered >= 1,
"Should have at least 1 registered device seen in last day"
);
assert!(
summary.seen_last_day_unregistered >= 1,
"Should have at least 1 unregistered device seen in last day"
);
assert!(
summary.seen_last_week_registered >= 1,
"Should have at least 1 registered device seen in last week"
);
assert!(
summary.seen_last_week_unregistered >= 1,
"Should have at least 1 unregistered device seen in last week"
);
}
fn validate_device( fn validate_device(
device: Device, device: Device,
mac_address: String, mac_address: String,
+9
View File
@@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use utoipa::ToSchema; use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct DeviceSummary {
pub total_registered: i64,
pub seen_last_day_registered: i64,
pub seen_last_day_unregistered: i64,
pub seen_last_week_registered: i64,
pub seen_last_week_unregistered: i64,
}
#[derive(Clone, Serialize, Deserialize, ToSchema)] #[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct Device { pub struct Device {
pub mac_address: String, pub mac_address: String,
+4 -1
View File
@@ -1,7 +1,7 @@
use std::error::Error; use std::error::Error;
use crate::model::device_events::{DeviceEvent, DeviceEventType}; use crate::model::device_events::{DeviceEvent, DeviceEventType};
use crate::model::devices::Device; use crate::model::devices::{Device, DeviceSummary};
use crate::model::notifications::{Notification, NotificationType}; use crate::model::notifications::{Notification, NotificationType};
use crate::settings::get_settings; use crate::settings::get_settings;
use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::arp_scanner::ArpScannerStatusResponse;
@@ -38,6 +38,7 @@ pub mod utils;
paths( paths(
test_api, test_api,
devices::list, devices::list,
devices::summary,
devices::read, devices::read,
devices::register, devices::register,
devices::unregister, devices::unregister,
@@ -51,6 +52,7 @@ pub mod utils;
), ),
components(schemas( components(schemas(
Device, Device,
DeviceSummary,
Notification, Notification,
NotificationType, NotificationType,
RegisterDevicePayload, RegisterDevicePayload,
@@ -103,6 +105,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
.route("/api/test", get(test_api)) .route("/api/test", get(test_api))
.route("/api/devices", get(devices::list)) .route("/api/devices", get(devices::list))
.route("/api/devices", put(devices::register)) .route("/api/devices", put(devices::register))
.route("/api/devices/summary", get(devices::summary))
.route("/api/devices/{mac_address}", delete(devices::unregister)) .route("/api/devices/{mac_address}", delete(devices::unregister))
.route("/api/devices/{mac_address}", get(devices::read)) .route("/api/devices/{mac_address}", get(devices::read))
.route("/api/devices/{mac_address}/events", get(device_events::list)) .route("/api/devices/{mac_address}/events", get(device_events::list))
+21 -1
View File
@@ -8,7 +8,7 @@ use log::{debug, error};
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{db, model::devices::Device}; use crate::{db, model::devices::{Device, DeviceSummary}};
use crate::web_server::utils; use crate::web_server::utils;
@@ -177,6 +177,26 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
} }
} }
#[utoipa::path(
get,
path = "/api/devices/summary",
tag = "devices",
responses(
(status = 200, description = "Device summary counts", body = DeviceSummary),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn summary() -> Result<Json<DeviceSummary>, StatusCode> {
match db::devices::get_summary() {
Ok(value) => Ok(Json(value)),
Err(err) => {
error!("Error getting device summary: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
// Payload structs // Payload structs
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
pub struct RegisterDevicePayload { pub struct RegisterDevicePayload {
+514
View File
@@ -0,0 +1,514 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../model/arp_scanner_status.dart';
import '../model/device_summary.dart';
import '../model/notification.dart' as oott_model;
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../widgets/arp_scanner_card.dart';
const _twoColumnBreakpoint = 700.0;
enum _NotificationFilter {
newOnly('New'),
oldOnly('Old'),
all('All');
const _NotificationFilter(this.label);
final String label;
bool? get isNew => switch (this) {
_NotificationFilter.newOnly => true,
_NotificationFilter.oldOnly => false,
_NotificationFilter.all => null,
};
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
// Notification state
_NotificationFilter _filter = _NotificationFilter.newOnly;
Timer? _notificationTimer;
late final _pagingController =
PagingController<int, oott_model.Notification>(
getNextPageKey: (state) => state.lastPageIsEmpty
? null
: (state.items == null ? 0 : state.items?.length),
fetchPage: (pageKey) =>
BackendAPI.instance.listNotifications(_filter.isNew, pageKey),
);
// Device summary state
DeviceSummary? _deviceSummary;
bool _isLoadingDeviceSummary = true;
String? _deviceSummaryError;
Timer? _deviceSummaryTimer;
// Scanner status state
ArpScannerStatus? _scannerStatus;
DateTime? _scannerStatusReceivedAt;
bool _isLoadingScanner = true;
String? _scannerError;
Timer? _scannerRefreshTimer;
Timer? _scannerTickTimer;
@override
void initState() {
super.initState();
_notificationTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _pagingController.refresh(),
);
_loadDeviceSummary();
_deviceSummaryTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _loadDeviceSummary(),
);
_loadScannerStatus();
_scannerRefreshTimer = Timer.periodic(
const Duration(seconds: 5),
(_) => _loadScannerStatus(),
);
_scannerTickTimer = Timer.periodic(
const Duration(seconds: 1),
(_) {
if (mounted) setState(() {});
},
);
}
@override
void dispose() {
_notificationTimer?.cancel();
_pagingController.dispose();
_deviceSummaryTimer?.cancel();
_scannerRefreshTimer?.cancel();
_scannerTickTimer?.cancel();
super.dispose();
}
Future<void> _loadDeviceSummary() async {
try {
final summary = await BackendAPI.instance.getDeviceSummary();
if (!mounted) return;
setState(() {
_deviceSummary = summary;
_deviceSummaryError = null;
_isLoadingDeviceSummary = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_deviceSummaryError = e.toString();
_isLoadingDeviceSummary = false;
});
}
}
Future<void> _loadScannerStatus() async {
try {
final status = await BackendAPI.instance.getArpScannerStatus();
if (!mounted) return;
setState(() {
_scannerStatus = status;
_scannerStatusReceivedAt = DateTime.now();
_scannerError = null;
_isLoadingScanner = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_scannerError = e.toString();
_isLoadingScanner = false;
});
}
}
Future<void> _markAllAsRead(BuildContext context) async {
await BackendAPI.instance.markAllNotificationsAsRead();
_pagingController.refresh();
if (context.mounted) {
UISnackbars.showSuccess(context, 'All notifications marked as read');
}
}
Future<bool> _setRead(
BuildContext context,
oott_model.Notification item,
bool read,
) async {
if (item.isNew == !read) {
UISnackbars.showWarning(
context,
'Notification was already marked as ${read ? 'read' : 'unread'}',
);
return false;
}
if (read) {
await BackendAPI.instance.markNotificationAsRead(item.id);
} else {
await BackendAPI.instance.markNotificationAsNew(item.id);
}
if (!context.mounted) return false;
UISnackbars.showSuccess(
context,
'Event marked as ${read ? 'read' : 'unread'}',
);
if (_filter != _NotificationFilter.all) {
_pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id,
);
return true;
}
_pagingController.mapItems(
(n) => n.id == item.id ? n.copyWith(isNew: !read) : n,
);
return false;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint;
return PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) => isTwoColumn
? _buildTwoColumn(context, state, fetchNextPage)
: _buildSingleColumn(context, state, fetchNextPage),
);
},
);
}
Widget _buildTwoColumn(
BuildContext context,
PagingState<int, oott_model.Notification> state,
void Function() fetchNextPage,
) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildNotificationsHeader(context, state),
Expanded(
child: CustomScrollView(
slivers: [_buildNotificationSliver(state, fetchNextPage)],
),
),
],
),
),
const VerticalDivider(width: 32),
SizedBox(
width: 300,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildDeviceSummaryCard(context),
const SizedBox(height: 16),
_buildScannerCard(),
],
),
),
),
],
);
}
Widget _buildSingleColumn(
BuildContext context,
PagingState<int, oott_model.Notification> state,
void Function() fetchNextPage,
) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildNotificationsHeader(context, state)),
_buildNotificationSliver(state, fetchNextPage),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 24),
child: _buildDeviceSummaryCard(context),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 20),
child: _buildScannerCard(),
),
),
],
);
}
Widget _buildNotificationsHeader(
BuildContext context,
PagingState<int, oott_model.Notification> state,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Notifications',
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
if (_filter == _NotificationFilter.newOnly &&
(state.items?.isNotEmpty ?? false))
IconButton(
onPressed: () => _markAllAsRead(context),
icon: const Icon(Icons.done_all),
tooltip: 'Mark all as read',
),
],
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
spacing: 8.0,
children: _NotificationFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_pagingController.refresh();
},
),
)
.toList(),
),
),
const SizedBox(height: 8),
],
);
}
Widget _buildNotificationSliver(
PagingState<int, oott_model.Notification> state,
void Function() fetchNextPage,
) {
final formatter = FriendlyDateFormatter();
return PagedSliverList<int, oott_model.Notification>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) => _NotificationCard(
item: item,
formatter: formatter,
onSetRead: (ctx, read) => _setRead(ctx, item, read),
),
),
);
}
Widget _buildDeviceSummaryCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Devices', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
if (_isLoadingDeviceSummary)
const Center(child: CircularProgressIndicator())
else if (_deviceSummaryError != null)
Text(
'Error loading device summary',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
)
else if (_deviceSummary != null) ...[
_SummaryRow(
label: 'Registered',
value: '${_deviceSummary!.totalRegistered}',
),
const Divider(height: 20),
Text(
'Seen in the last 24 hours',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(height: 6),
_SummaryRow(
label: 'Registered',
value: '${_deviceSummary!.seenLastDayRegistered}',
),
_SummaryRow(
label: 'Unregistered',
value: '${_deviceSummary!.seenLastDayUnregistered}',
),
const Divider(height: 20),
Text(
'Seen in the last 7 days',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(height: 6),
_SummaryRow(
label: 'Registered',
value: '${_deviceSummary!.seenLastWeekRegistered}',
),
_SummaryRow(
label: 'Unregistered',
value: '${_deviceSummary!.seenLastWeekUnregistered}',
),
],
],
),
),
);
}
Widget _buildScannerCard() {
return ArpScannerCard(
status: _scannerStatus,
statusReceivedAt: _scannerStatusReceivedAt,
error: _scannerError,
isLoading: _isLoadingScanner,
);
}
}
class _SummaryRow extends StatelessWidget {
final String label;
final String value;
const _SummaryRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: Theme.of(context).textTheme.bodyMedium),
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}
class _NotificationCard extends StatelessWidget {
final oott_model.Notification item;
final FriendlyDateFormatter formatter;
final Future<bool> Function(BuildContext, bool read) onSetRead;
const _NotificationCard({
required this.item,
required this.formatter,
required this.onSetRead,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: item.isNew ? theme.colorScheme.secondaryContainer : null,
child: Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) => direction == DismissDirection.startToEnd
? onSetRead(context, false)
: onSetRead(context, true),
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
child: const Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.done),
),
child: ListTile(
leading: Icon(
item.notificationType.icon,
color: item.isNew ? theme.colorScheme.primary : null,
),
title: Text(
'${formatter.format(item.createdOn)} - ${item.title}',
style: item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.body, maxLines: 5),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'view_device') {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
} else if (value == 'mark_read') {
await onSetRead(context, true);
} else if (value == 'mark_new') {
await onSetRead(context, false);
}
},
itemBuilder: (context) => [
if (item.macAddress != null)
const PopupMenuItem(
value: 'view_device',
child: Text('View device'),
),
if (item.isNew)
const PopupMenuItem(
value: 'mark_read',
child: Text('Mark as read'),
),
if (!item.isNew)
const PopupMenuItem(
value: 'mark_new',
child: Text('Mark as unread'),
),
],
),
onTap: item.macAddress != null
? () async {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
}
: null,
isThreeLine: true,
),
),
);
}
}
+22
View File
@@ -0,0 +1,22 @@
class DeviceSummary {
final int totalRegistered;
final int seenLastDayRegistered;
final int seenLastDayUnregistered;
final int seenLastWeekRegistered;
final int seenLastWeekUnregistered;
const DeviceSummary({
required this.totalRegistered,
required this.seenLastDayRegistered,
required this.seenLastDayUnregistered,
required this.seenLastWeekRegistered,
required this.seenLastWeekUnregistered,
});
DeviceSummary.fromJson(Map<String, dynamic> json)
: totalRegistered = json['total_registered'] as int,
seenLastDayRegistered = json['seen_last_day_registered'] as int,
seenLastDayUnregistered = json['seen_last_day_unregistered'] as int,
seenLastWeekRegistered = json['seen_last_week_registered'] as int,
seenLastWeekUnregistered = json['seen_last_week_unregistered'] as int;
}
+5 -5
View File
@@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'devices/device_detail.dart'; import 'devices/device_detail.dart';
import 'devices/device_list.dart'; import 'devices/device_list.dart';
import 'notifications/notification_list.dart'; import 'home/home_screen.dart';
import 'status/status_screen.dart'; import 'status/status_screen.dart';
import 'utils/pref_utils.dart'; import 'utils/pref_utils.dart';
@@ -22,7 +22,7 @@ final GoRouter router = GoRouter(
GoRoute( GoRoute(
path: '/notifications', path: '/notifications',
name: 'notifications', name: 'notifications',
builder: (context, state) => NotificationList(), builder: (context, state) => const HomeScreen(),
redirect: (context, state) => _redirectToSettings(), redirect: (context, state) => _redirectToSettings(),
), ),
GoRoute( GoRoute(
@@ -103,9 +103,9 @@ class MainShell extends StatelessWidget {
extended: constraints.maxWidth >= 600, extended: constraints.maxWidth >= 600,
destinations: [ destinations: [
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.notifications_outlined), icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.notifications), selectedIcon: Icon(Icons.home),
label: Text('Notifications'), label: Text('Home'),
), ),
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.devices_other_outlined), icon: Icon(Icons.devices_other_outlined),
@@ -1,259 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../model/notification.dart' as oott_model;
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
enum _NotificationFilter {
newOnly('New'),
oldOnly('Old'),
all('All');
const _NotificationFilter(this.label);
final String label;
bool? get isNew => switch (this) {
_NotificationFilter.newOnly => true,
_NotificationFilter.oldOnly => false,
_NotificationFilter.all => null,
};
}
class NotificationList extends StatefulWidget {
const NotificationList({super.key});
@override
State<NotificationList> createState() => _NotificationListState();
}
class _NotificationListState extends State<NotificationList> {
_NotificationFilter _filter = _NotificationFilter.newOnly;
Timer? _refreshTimer;
@override
void initState() {
super.initState();
_refreshTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _pagingController.refresh(),
);
}
late final _pagingController = PagingController<int, oott_model.Notification>(
getNextPageKey: (state) => state.lastPageIsEmpty
? null
: (state.items == null ? 0 : state.items?.length),
fetchPage: (pageKey) =>
BackendAPI.instance.listNotifications(_filter.isNew, pageKey),
);
Future<void> _markAllAsRead(BuildContext context) async {
await BackendAPI.instance.markAllNotificationsAsRead();
_pagingController.refresh();
if (context.mounted) {
UISnackbars.showSuccess(context, 'All notifications marked as read');
}
}
Future<bool> _setRead(
BuildContext context,
oott_model.Notification item,
bool read,
) async {
if (item.isNew == !read) {
UISnackbars.showWarning(
context,
'Notification was already marked as ${read ? 'read' : 'unread'}',
);
return false;
}
if (read) {
await BackendAPI.instance.markNotificationAsRead(item.id);
} else {
await BackendAPI.instance.markNotificationAsNew(item.id);
}
if (!context.mounted) return false;
UISnackbars.showSuccess(
context,
'Event marked as ${read ? 'read' : 'unread'}',
);
if (_filter != _NotificationFilter.all) {
_pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id,
);
return true;
}
_pagingController.mapItems(
(n) => n.id == item.id ? n.copyWith(isNew: !read) : n,
);
return false;
}
@override
Widget build(BuildContext context) {
final formatter = FriendlyDateFormatter();
return PagingListener(
controller: _pagingController,
builder: (context, state, fetchNextPage) => Scaffold(
appBar: AppBar(
title: const Text('Notifications'),
actions: [
if (_filter == _NotificationFilter.newOnly &&
(state.items?.isNotEmpty ?? false))
IconButton(
onPressed: () => _markAllAsRead(context),
icon: const Icon(Icons.done_all),
tooltip: 'Mark all as read',
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
spacing: 8.0,
children: _NotificationFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_pagingController.refresh();
},
),
)
.toList(),
),
),
),
),
),
body: CustomScrollView(
slivers: [
PagedSliverList<int, oott_model.Notification>(
state: state,
fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) => _NotificationCard(
item: item,
formatter: formatter,
onSetRead: (ctx, read) => _setRead(ctx, item, read),
),
),
),
],
),
),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
_pagingController.dispose();
super.dispose();
}
}
class _NotificationCard extends StatelessWidget {
final oott_model.Notification item;
final FriendlyDateFormatter formatter;
final Future<bool> Function(BuildContext, bool read) onSetRead;
const _NotificationCard({
required this.item,
required this.formatter,
required this.onSetRead,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: item.isNew ? theme.colorScheme.secondaryContainer : null,
child: Dismissible(
key: UniqueKey(),
confirmDismiss: (direction) => direction == DismissDirection.startToEnd
? onSetRead(context, false)
: onSetRead(context, true),
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
child: const Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: const Icon(Icons.done),
),
child: ListTile(
leading: Icon(
item.notificationType.icon,
color: item.isNew ? theme.colorScheme.primary : null,
),
title: Text(
'${formatter.format(item.createdOn)} - ${item.title}',
style: item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(item.body, maxLines: 5),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'view_device') {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
} else if (value == 'mark_read') {
await onSetRead(context, true);
} else if (value == 'mark_new') {
await onSetRead(context, false);
}
},
itemBuilder: (context) => [
if (item.macAddress != null)
const PopupMenuItem(
value: 'view_device',
child: Text('View device'),
),
if (item.isNew)
const PopupMenuItem(
value: 'mark_read',
child: Text('Mark as read'),
),
if (!item.isNew)
const PopupMenuItem(
value: 'mark_new',
child: Text('Mark as unread'),
),
],
),
onTap: item.macAddress != null
? () async {
if (item.isNew) await onSetRead(context, true);
if (context.mounted) {
context.push('/devices/${item.macAddress}');
}
}
: null,
isThreeLine: true,
),
),
);
}
}
+10 -94
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:frontend/model/arp_scanner_status.dart'; import 'package:frontend/model/arp_scanner_status.dart';
import 'package:frontend/utils/oott_api.dart'; import 'package:frontend/utils/oott_api.dart';
import 'package:frontend/widgets/arp_scanner_card.dart';
class StatusScreen extends StatefulWidget { class StatusScreen extends StatefulWidget {
const StatusScreen({super.key}); const StatusScreen({super.key});
@@ -29,7 +30,9 @@ class _StatusScreenState extends State<StatusScreen> {
); );
_tickTimer = Timer.periodic( _tickTimer = Timer.periodic(
const Duration(seconds: 1), const Duration(seconds: 1),
(_) { if (mounted) setState(() {}); }, (_) {
if (mounted) setState(() {});
},
); );
} }
@@ -66,100 +69,13 @@ class _StatusScreenState extends State<StatusScreen> {
children: [ children: [
Text('Status', style: Theme.of(context).textTheme.headlineMedium), Text('Status', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 20), const SizedBox(height: 20),
if (_isLoading) ArpScannerCard(
const Center(child: CircularProgressIndicator()) status: _status,
else statusReceivedAt: _statusReceivedAt,
_ArpScannerCard( error: _error,
status: _status, isLoading: _isLoading,
statusReceivedAt: _statusReceivedAt, ),
error: _error,
),
], ],
); );
} }
} }
class _ArpScannerCard extends StatelessWidget {
final ArpScannerStatus? status;
final DateTime? statusReceivedAt;
final String? error;
const _ArpScannerCard({this.status, this.statusReceivedAt, this.error});
@override
Widget build(BuildContext context) {
final (color, label, sublabel) = _resolveState(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.circle, color: color, size: 14),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ARP Scanner',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodyMedium),
if (sublabel != null) ...[
const SizedBox(height: 2),
Text(
sublabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
],
),
),
],
),
),
);
}
(Color, String, String?) _resolveState(BuildContext context) {
if (error != null || status == null) {
return (
Colors.red,
'Error',
'Unable to reach the server or a server-side error occurred. Check the logs for details.',
);
}
final elapsed = statusReceivedAt != null
? DateTime.now().difference(statusReceivedAt!).inSeconds.toDouble()
: 0.0;
if (status!.isRunning) {
final sub = status!.runningForSeconds != null
? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}'
: null;
return (Colors.green, 'Running', sub);
}
if (status!.nextRunInSeconds != null) {
final remaining = (status!.nextRunInSeconds! - elapsed).clamp(0.0, double.infinity);
return (
Colors.amber,
'Waiting for next run',
'Next run in ${_formatSeconds(remaining)}',
);
}
return (Colors.grey, 'Not yet started', null);
}
}
String _formatSeconds(double seconds) {
final total = seconds.round().clamp(0, double.maxFinite.toInt());
if (total < 60) return '${total}s';
final m = total ~/ 60;
final s = total % 60;
return '${m}m ${s}s';
}
+8
View File
@@ -7,6 +7,7 @@ import 'package:frontend/utils/pref_utils.dart';
import '../model/arp_scanner_status.dart'; import '../model/arp_scanner_status.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_event.dart'; import '../model/device_event.dart';
import '../model/device_summary.dart';
import '../model/device_type.dart'; import '../model/device_type.dart';
import '../model/notification.dart'; import '../model/notification.dart';
@@ -157,6 +158,13 @@ class BackendAPI {
.toList(); .toList();
} }
Future<DeviceSummary> getDeviceSummary() async {
debugPrint('About to call GET /devices/summary');
final response = await _dio.get('/devices/summary');
debugPrint('Received: ${response.data}');
return DeviceSummary.fromJson(response.data as Map<String, dynamic>);
}
Future<ArpScannerStatus> getArpScannerStatus() async { Future<ArpScannerStatus> getArpScannerStatus() async {
debugPrint('About to call GET /arp_scanner/status'); debugPrint('About to call GET /arp_scanner/status');
final response = await _dio.get('/arp_scanner/status'); final response = await _dio.get('/arp_scanner/status');
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:frontend/model/arp_scanner_status.dart';
class ArpScannerCard extends StatelessWidget {
final ArpScannerStatus? status;
final DateTime? statusReceivedAt;
final String? error;
final bool isLoading;
const ArpScannerCard({
super.key,
this.status,
this.statusReceivedAt,
this.error,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
),
);
}
final (color, label, sublabel) = _resolveState(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.circle, color: color, size: 14),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'ARP Scanner',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 2),
Text(label, style: Theme.of(context).textTheme.bodyMedium),
if (sublabel != null) ...[
const SizedBox(height: 2),
Text(
sublabel,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
],
],
),
),
],
),
),
);
}
(Color, String, String?) _resolveState(BuildContext context) {
if (error != null || status == null) {
return (
Colors.red,
'Error',
'Unable to reach the server or a server-side error occurred. Check the logs for details.',
);
}
final elapsed = statusReceivedAt != null
? DateTime.now().difference(statusReceivedAt!).inSeconds.toDouble()
: 0.0;
if (status!.isRunning) {
final sub = status!.runningForSeconds != null
? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}'
: null;
return (Colors.green, 'Running', sub);
}
if (status!.nextRunInSeconds != null) {
final remaining = (status!.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (
Colors.amber,
'Waiting for next run',
'Next run in ${_formatSeconds(remaining)}',
);
}
return (Colors.grey, 'Not yet started', null);
}
}
String _formatSeconds(double seconds) {
final total = seconds.round().clamp(0, double.maxFinite.toInt());
if (total < 60) return '${total}s';
final m = total ~/ 60;
final s = total % 60;
return '${m}m ${s}s';
}