mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
dc8f47e66d
commit
251d8a479e
@@ -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);
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user