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] 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)
|
||||
- [ ] Scan process monitor and summary page
|
||||
- [ ] Is the scan process running
|
||||
- [ ] Last run (when, how long did it take, how many devices did it found)
|
||||
- [ ] When is the next run
|
||||
- [x] Scan process monitor and summary page
|
||||
- [x] Is the scan process running
|
||||
- [x] Last run (when, how long did it take, how many devices did it found)
|
||||
- [x] When is the next run
|
||||
- [ ] Rework notifications page into a homepage for the app
|
||||
- [ ] Notifications list
|
||||
- [ ] 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)
|
||||
- [ ] About page
|
||||
- [x] About page
|
||||
|
||||
## 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 rusqlite::{params, params_from_iter};
|
||||
|
||||
use crate::{
|
||||
db::{self, error::DbError},
|
||||
model::devices::Device,
|
||||
model::devices::{Device, DeviceSummary},
|
||||
};
|
||||
|
||||
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> {
|
||||
let conn = db::get_db_connection();
|
||||
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(
|
||||
device: Device,
|
||||
mac_address: String,
|
||||
|
||||
@@ -4,6 +4,15 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
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)]
|
||||
pub struct Device {
|
||||
pub mac_address: String,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::error::Error;
|
||||
|
||||
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::settings::get_settings;
|
||||
use crate::web_server::arp_scanner::ArpScannerStatusResponse;
|
||||
@@ -38,6 +38,7 @@ pub mod utils;
|
||||
paths(
|
||||
test_api,
|
||||
devices::list,
|
||||
devices::summary,
|
||||
devices::read,
|
||||
devices::register,
|
||||
devices::unregister,
|
||||
@@ -51,6 +52,7 @@ pub mod utils;
|
||||
),
|
||||
components(schemas(
|
||||
Device,
|
||||
DeviceSummary,
|
||||
Notification,
|
||||
NotificationType,
|
||||
RegisterDevicePayload,
|
||||
@@ -103,6 +105,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
.route("/api/test", get(test_api))
|
||||
.route("/api/devices", get(devices::list))
|
||||
.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}", get(devices::read))
|
||||
.route("/api/devices/{mac_address}/events", get(device_events::list))
|
||||
|
||||
@@ -8,7 +8,7 @@ use log::{debug, error};
|
||||
use serde::Deserialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{db, model::devices::Device};
|
||||
use crate::{db, model::devices::{Device, DeviceSummary}};
|
||||
|
||||
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
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
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 'devices/device_detail.dart';
|
||||
import 'devices/device_list.dart';
|
||||
import 'notifications/notification_list.dart';
|
||||
import 'home/home_screen.dart';
|
||||
import 'status/status_screen.dart';
|
||||
import 'utils/pref_utils.dart';
|
||||
|
||||
@@ -22,7 +22,7 @@ final GoRouter router = GoRouter(
|
||||
GoRoute(
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
builder: (context, state) => NotificationList(),
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
redirect: (context, state) => _redirectToSettings(),
|
||||
),
|
||||
GoRoute(
|
||||
@@ -103,9 +103,9 @@ class MainShell extends StatelessWidget {
|
||||
extended: constraints.maxWidth >= 600,
|
||||
destinations: [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.notifications_outlined),
|
||||
selectedIcon: Icon(Icons.notifications),
|
||||
label: Text('Notifications'),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: Text('Home'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
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:frontend/model/arp_scanner_status.dart';
|
||||
import 'package:frontend/utils/oott_api.dart';
|
||||
import 'package:frontend/widgets/arp_scanner_card.dart';
|
||||
|
||||
class StatusScreen extends StatefulWidget {
|
||||
const StatusScreen({super.key});
|
||||
@@ -29,7 +30,9 @@ class _StatusScreenState extends State<StatusScreen> {
|
||||
);
|
||||
_tickTimer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) { if (mounted) setState(() {}); },
|
||||
(_) {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,100 +69,13 @@ class _StatusScreenState extends State<StatusScreen> {
|
||||
children: [
|
||||
Text('Status', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const SizedBox(height: 20),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
_ArpScannerCard(
|
||||
status: _status,
|
||||
statusReceivedAt: _statusReceivedAt,
|
||||
error: _error,
|
||||
),
|
||||
ArpScannerCard(
|
||||
status: _status,
|
||||
statusReceivedAt: _statusReceivedAt,
|
||||
error: _error,
|
||||
isLoading: _isLoading,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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/device.dart';
|
||||
import '../model/device_event.dart';
|
||||
import '../model/device_summary.dart';
|
||||
import '../model/device_type.dart';
|
||||
import '../model/notification.dart';
|
||||
|
||||
@@ -157,6 +158,13 @@ class BackendAPI {
|
||||
.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 {
|
||||
debugPrint('About to call 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