Polish frontend: design tokens, theming, AppBar titles, empty/loading states

Frontend usability and aesthetics pass across web and mobile:

- Add shared layout tokens (theme/dimens.dart: Insets + Breakpoints) and
  replace magic-number spacing and per-file breakpoint consts.
- Route both themes through buildAppTheme (theme/theme_builder.dart): explicit
  useMaterial3 and a shared branded textTheme (Barlow Condensed for
  display/headline/title styles); logo now reads its style from the theme.
- Move screen titles into the shared AppBar (route-derived) and drop the
  redundant in-body headers; upgrade Settings buttons to M3 FilledButton.
- Add reusable EmptyState and skeleton loaders; Devices empty state links to
  scanner status, notifications get filter-aware messages.
- Add a first-run welcome intro and Save-disabled helper text in Settings.
- Make device Filter/Sort adaptive: bottom sheet on phones, dialog on wide.
- Collapse the device-list filter chips and Sort/Filter buttons into one row;
  rename the home Scanners card title and align its style.

Tests: add EmptyState/skeleton widget tests and Settings first-run cases;
update tests for the FilledButton swap and relocated titles. 89 passing,
dart analyze clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 08:39:37 -04:00
co-authored by Claude Opus 4.8
parent 5f6f5ad679
commit 9367c359d7
20 changed files with 661 additions and 274 deletions
+22 -16
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme/dimens.dart';
// The version is read at runtime from the bundled package metadata
// (frontend/pubspec.yaml), so it never needs to be hand-edited here.
const _releaseDate = 'May 28, 2026';
@@ -18,18 +20,16 @@ class About extends StatelessWidget {
final textTheme = Theme.of(context).textTheme;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.all(Insets.xxl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('About OOTT', style: textTheme.headlineSmall),
const SizedBox(height: 16),
Text(
'Easy to setup and use network device discovery and alert system. '
'Notifies you when new or unknown devices join your local area network.',
style: textTheme.bodyLarge,
),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
@@ -45,7 +45,7 @@ class About extends StatelessWidget {
);
},
),
const SizedBox(height: 24),
const SizedBox(height: Insets.xxl),
_SurfaceContainer(
colorScheme: colorScheme,
child: Column(
@@ -60,17 +60,17 @@ class About extends StatelessWidget {
Divider(
height: 1,
color: colorScheme.outlineVariant,
indent: 16,
endIndent: 16,
indent: Insets.lg,
endIndent: Insets.lg,
),
_LicenseRow(colorScheme: colorScheme, textTheme: textTheme),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
_SurfaceContainer(
colorScheme: colorScheme,
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(Insets.lg),
child: _NoticesSection(textTheme: textTheme),
),
],
@@ -132,11 +132,14 @@ class _LinkRow extends StatelessWidget {
return InkWell(
onTap: _open,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
padding: const EdgeInsets.symmetric(
vertical: Insets.md,
horizontal: Insets.lg,
),
child: Row(
children: [
Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(width: 12),
const SizedBox(width: Insets.md),
Text(
label,
style: textTheme.bodyMedium?.copyWith(
@@ -168,12 +171,15 @@ class _LicenseRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
padding: const EdgeInsets.symmetric(
vertical: Insets.md,
horizontal: Insets.lg,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.gavel, size: 18, color: colorScheme.primary),
const SizedBox(width: 12),
const SizedBox(width: Insets.md),
Flexible(
child: Wrap(
children: [
@@ -214,7 +220,7 @@ class _NoticesSection extends StatelessWidget {
'OOTT, Copyright (C) 2024-2026 Ricardo Zuasti',
style: textTheme.bodyMedium,
),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
Text(
'This product includes software developed by third parties and distributed '
'under the Apache License, Version 2.0.',
@@ -222,7 +228,7 @@ class _NoticesSection extends StatelessWidget {
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
const SizedBox(height: Insets.md),
..._thirdPartyComponents.map(
(c) => _ThirdPartyEntry(component: c, textTheme: textTheme),
),
@@ -241,7 +247,7 @@ class _ThirdPartyEntry extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.only(bottom: Insets.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+59 -43
View File
@@ -2,19 +2,22 @@ import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/device.dart';
import '../model/device_type.dart';
import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../widgets/empty_state.dart';
import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'device_list_filter.dart';
import 'device_list_rows.dart';
import 'device_list_sort.dart';
const _pageSize = 10;
const _wideLayoutBreakpoint = 600.0;
class DeviceList extends StatefulWidget {
const DeviceList({super.key});
@@ -147,11 +150,32 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
bool get _hasActiveDetailFilters =>
_ownerController.text.isNotEmpty || _typeFilter != null;
void _showFilterSheet() {
showModalBottomSheet(
/// Presents [child] as a modal bottom sheet on narrow (phone) layouts and as
/// a centered dialog on wider (tablet/desktop) layouts, where a sheet sliding
/// up from the bottom of a large window reads poorly.
Future<void> _showAdaptivePanel(Widget child) {
final isWide = MediaQuery.sizeOf(context).width >= Breakpoints.medium;
if (isWide) {
return showDialog<void>(
context: context,
builder: (_) => Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: SingleChildScrollView(child: child),
),
),
);
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => DeviceFilterSheet(
builder: (_) => child,
);
}
void _showFilterSheet() {
_showAdaptivePanel(
DeviceFilterSheet(
ownerController: _ownerController,
typeFilter: _typeFilter,
hasActiveFilters: _hasActiveDetailFilters,
@@ -171,10 +195,8 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
}
void _showSortSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => DeviceSortSheet(
_showAdaptivePanel(
DeviceSortSheet(
currentColumn: _sortColumn,
ascending: _sortAscending,
onChanged: _onSortSheetChanged,
@@ -184,18 +206,37 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= _wideLayoutBreakpoint;
final isWide = constraints.maxWidth >= Breakpoints.medium;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text('Devices', style: textTheme.headlineSmall),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.xs,
),
child: Wrap(
spacing: Insets.sm,
children: DeviceFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_fetchPage(0);
},
),
)
.toList(),
),
),
),
if (!isWide)
IconButton(
@@ -213,25 +254,6 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
),
],
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Wrap(
spacing: 8.0,
children: DeviceFilter.values
.map(
(f) => ChoiceChip(
label: Text(f.label),
selected: _filter == f,
onSelected: (_) {
setState(() => _filter = f);
_fetchPage(0);
},
),
)
.toList(),
),
),
Expanded(child: _buildBody(context, isWide)),
],
);
@@ -241,7 +263,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
Widget _buildBody(BuildContext context, bool isWide) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
return const ListSkeleton();
}
if (_error != null) {
return Center(
@@ -252,17 +274,11 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
);
}
if (_devices.isEmpty) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 12),
child: Center(
child: Text(
_emptyMessage(),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
return EmptyState(
icon: Icons.devices_other_outlined,
message: _emptyMessage(),
actionLabel: 'Check scanner status',
onAction: () => context.go('/status'),
);
}
+6 -7
View File
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
import '../widgets/device_summary_card.dart';
import '../widgets/scanners_status_card.dart';
import 'notifications_list.dart';
const _twoColumnBreakpoint = 700.0;
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@@ -13,7 +12,7 @@ class HomeScreen extends StatelessWidget {
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint;
final isTwoColumn = constraints.maxWidth >= Breakpoints.twoColumn;
return isTwoColumn ? _buildTwoColumn() : _buildSingleColumn();
},
);
@@ -24,7 +23,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: NotificationsList()),
VerticalDivider(width: 32),
VerticalDivider(width: Insets.xxxl),
SizedBox(
width: 300,
child: SingleChildScrollView(
@@ -32,7 +31,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DeviceSummaryCard(),
SizedBox(height: 16),
SizedBox(height: Insets.lg),
ScannersStatusCard(),
],
),
@@ -47,13 +46,13 @@ class HomeScreen extends StatelessWidget {
trailingSlivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 24),
padding: EdgeInsets.only(top: Insets.xxl),
child: DeviceSummaryCard(),
),
),
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 16, bottom: 20),
padding: EdgeInsets.only(top: Insets.lg, bottom: Insets.xl),
child: ScannersStatusCard(),
),
),
+8 -7
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/notification.dart' as oott_model;
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
class NotificationCard extends StatefulWidget {
@@ -25,7 +26,7 @@ class _NotificationCardState extends State<NotificationCard> {
Widget _buildActions(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
padding: const EdgeInsets.fromLTRB(Insets.sm, 0, Insets.sm, Insets.sm),
child: OverflowBar(
alignment: MainAxisAlignment.end,
children: [
@@ -61,13 +62,13 @@ class _NotificationCardState extends State<NotificationCard> {
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16),
padding: const EdgeInsets.only(left: Insets.lg),
child: const Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.only(right: Insets.lg),
child: const Icon(Icons.done),
),
child: Column(
@@ -76,8 +77,7 @@ class _NotificationCardState extends State<NotificationCard> {
ListTile(
leading: Icon(
widget.item.notificationType.icon,
color:
widget.item.isNew ? theme.colorScheme.primary : null,
color: widget.item.isNew ? theme.colorScheme.primary : null,
),
title: Text(
'${widget.formatter.format(widget.item.createdOn)} - ${widget.item.title}',
@@ -88,8 +88,9 @@ class _NotificationCardState extends State<NotificationCard> {
subtitle: Text(
widget.item.body,
maxLines: _expanded ? null : 2,
overflow:
_expanded ? TextOverflow.visible : TextOverflow.ellipsis,
overflow: _expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
onTap: () => setState(() => _expanded = !_expanded),
),
+18 -17
View File
@@ -5,10 +5,13 @@ import 'package:flutter/material.dart';
import '../model/notification.dart' as oott_model;
import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../widgets/empty_state.dart';
import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'notification_card.dart';
const _pageSize = 5;
@@ -190,6 +193,12 @@ class _NotificationsListState extends State<NotificationsList>
return false;
}
String _emptyMessage() => switch (_filter) {
_NotificationFilter.newOnly => 'No new notifications',
_NotificationFilter.oldOnly => 'No old notifications',
_NotificationFilter.all => 'No notifications yet',
};
@override
Widget build(BuildContext context) {
return Column(
@@ -229,9 +238,12 @@ class _NotificationsListState extends State<NotificationsList>
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.xs,
),
child: Wrap(
spacing: 8.0,
spacing: Insets.sm,
children: _NotificationFilter.values
.map(
(f) => ChoiceChip(
@@ -252,11 +264,7 @@ class _NotificationsListState extends State<NotificationsList>
List<Widget> _buildNotificationSlivers(BuildContext context) {
if (_isLoading) {
return [
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
];
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
}
if (_error != null) {
return [
@@ -273,16 +281,9 @@ class _NotificationsListState extends State<NotificationsList>
if (_items.isEmpty) {
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 12),
child: Center(
child: Text(
'No items found',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
child: EmptyState(
icon: Icons.notifications_off_outlined,
message: _emptyMessage(),
),
),
];
+35 -29
View File
@@ -2,18 +2,14 @@ import 'package:flutter/material.dart';
import 'package:frontend/about/about.dart';
import 'package:frontend/settings/settings.dart';
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 'home/home_screen.dart';
import 'status/status_screen.dart';
import 'theme/dimens.dart';
import 'utils/pref_utils.dart';
import 'widgets/offline_banner.dart';
// M3 window size class breakpoints
const _mediumBreakpoint = 600.0;
const _expandedBreakpoint = 840.0;
typedef _NavDest = ({IconData icon, IconData activeIcon, String label});
const List<_NavDest> _destinations = [
@@ -107,18 +103,15 @@ class MainShell extends StatelessWidget {
final selectedIndex = _calculateSelectedIndex(context);
final width = constraints.maxWidth;
if (width < _mediumBreakpoint) {
if (width < Breakpoints.medium) {
return Scaffold(
appBar: _buildAppBar(context),
appBar: _buildAppBar(context, selectedIndex),
body: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
padding: const EdgeInsets.all(Insets.lg),
child: child,
),
),
@@ -142,7 +135,7 @@ class MainShell extends StatelessWidget {
}
return Scaffold(
appBar: _buildAppBar(context),
appBar: _buildAppBar(context, selectedIndex),
body: Row(
children: [
SafeArea(
@@ -150,7 +143,7 @@ class MainShell extends StatelessWidget {
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
extended: width >= _expandedBreakpoint,
extended: width >= Breakpoints.expanded,
destinations: _destinations
.map(
(d) => NavigationRailDestination(
@@ -173,7 +166,7 @@ class MainShell extends StatelessWidget {
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.all(Insets.lg),
child: child,
),
),
@@ -188,24 +181,37 @@ class MainShell extends StatelessWidget {
);
}
AppBar _buildAppBar(BuildContext context) {
AppBar _buildAppBar(BuildContext context, int selectedIndex) {
final theme = Theme.of(context);
return AppBar(
title: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'OOTT',
style: GoogleFonts.barlowCondensed(
color: Theme.of(context).colorScheme.onPrimary,
fontWeight: FontWeight.bold,
fontSize: 26,
titleSpacing: Insets.lg,
title: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'OOTT',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: Insets.md),
Flexible(
child: Text(
_destinations[selectedIndex].label,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge,
),
),
],
),
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
backgroundColor: theme.colorScheme.surfaceContainerLowest,
);
}
}
+51 -14
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../main.dart';
import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/oott_api.dart';
import '../utils/pref_utils.dart';
import '../utils/ui_snackbars.dart';
@@ -21,6 +22,7 @@ class _SettingsState extends State<Settings> {
bool _apiKeyVisible = false;
bool _testOk = false;
bool _connectionModified = false;
bool _isFirstRun = false;
late String _selectedTheme;
final _formKey = GlobalKey<FormState>();
@@ -28,6 +30,7 @@ class _SettingsState extends State<Settings> {
@override
void initState() {
super.initState();
_isFirstRun = (PrefUtil.getValue('base_url', '') as String).isEmpty;
_baseUrlController.text = PrefUtil.getValue('base_url', '') as String;
_apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue('api_key', '') as String,
@@ -100,6 +103,7 @@ class _SettingsState extends State<Settings> {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final saveDisabled = _connectionModified && !_testOk;
return SingleChildScrollView(
child: Form(
@@ -107,8 +111,33 @@ class _SettingsState extends State<Settings> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Settings', style: textTheme.headlineSmall),
const SizedBox(height: 16),
if (_isFirstRun) ...[
Card(
color: colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: Row(
children: [
Icon(
Icons.waving_hand_outlined,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: Insets.md),
Expanded(
child: Text(
'Welcome to OOTT! Point the app at your servers API '
'below, then Test and Save to get started.',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
),
),
),
],
),
),
),
const SizedBox(height: Insets.lg),
],
TextFormField(
controller: _baseUrlController,
onChanged: _onConnectionChanged,
@@ -121,7 +150,7 @@ class _SettingsState extends State<Settings> {
hintText: 'For example http://192.168.0.1:3000/api',
),
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
TextFormField(
controller: _apiKeyController,
onChanged: _onConnectionChanged,
@@ -141,7 +170,7 @@ class _SettingsState extends State<Settings> {
),
),
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
DropdownButtonFormField<String>(
initialValue: _selectedTheme,
decoration: const InputDecoration(
@@ -162,17 +191,17 @@ class _SettingsState extends State<Settings> {
if (value != null) setState(() => _selectedTheme = value);
},
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton.icon(
FilledButton.icon(
onPressed: () {
_testConnection();
},
label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: ElevatedButton.styleFrom(
style: FilledButton.styleFrom(
backgroundColor: _testOk
? appColors.success
: colorScheme.secondary,
@@ -181,18 +210,26 @@ class _SettingsState extends State<Settings> {
: colorScheme.onSecondary,
),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: (_connectionModified && !_testOk) ? null : _save,
const SizedBox(width: Insets.sm),
FilledButton.icon(
onPressed: saveDisabled ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
),
),
],
),
if (saveDisabled) ...[
const SizedBox(height: Insets.sm),
Align(
alignment: Alignment.centerRight,
child: Text(
'Test the connection before saving your changes.',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
],
),
),
+5 -6
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
import '../widgets/arp_scanner_card.dart';
import '../widgets/dhcp_scanner_card.dart';
import '../widgets/mdns_scanner_card.dart';
@@ -15,16 +16,14 @@ class StatusScreen extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Status', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 20),
const ArpScannerCard(),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
const MdnsScannerCard(),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
const SsdpScannerCard(),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
const DhcpScannerCard(),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
const SnmpScannerCard(),
],
),
+60 -61
View File
@@ -1,93 +1,92 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'theme_builder.dart';
abstract final class CatppuccinMochaColors {
static const Color crust = Color(0xFF11111b);
static const Color mantle = Color(0xFF181825);
static const Color base = Color(0xFF1e1e2e);
static const Color surface0 = Color(0xFF313244);
static const Color surface1 = Color(0xFF45475a);
static const Color surface2 = Color(0xFF585b70);
static const Color overlay0 = Color(0xFF6c7086);
static const Color overlay1 = Color(0xFF7f849c);
static const Color overlay2 = Color(0xFF9399b2);
static const Color subtext0 = Color(0xFFa6adc8);
static const Color subtext1 = Color(0xFFbac2de);
static const Color text = Color(0xFFcdd6f4);
static const Color crust = Color(0xFF11111b);
static const Color mantle = Color(0xFF181825);
static const Color base = Color(0xFF1e1e2e);
static const Color surface0 = Color(0xFF313244);
static const Color surface1 = Color(0xFF45475a);
static const Color surface2 = Color(0xFF585b70);
static const Color overlay0 = Color(0xFF6c7086);
static const Color overlay1 = Color(0xFF7f849c);
static const Color overlay2 = Color(0xFF9399b2);
static const Color subtext0 = Color(0xFFa6adc8);
static const Color subtext1 = Color(0xFFbac2de);
static const Color text = Color(0xFFcdd6f4);
static const Color rosewater = Color(0xFFf5e0dc);
static const Color flamingo = Color(0xFFf2cdcd);
static const Color pink = Color(0xFFf5c2e7);
static const Color mauve = Color(0xFFcba6f7);
static const Color red = Color(0xFFf38ba8);
static const Color maroon = Color(0xFFeba0ac);
static const Color peach = Color(0xFFfab387);
static const Color yellow = Color(0xFFf9e2af);
static const Color green = Color(0xFFa6e3a1);
static const Color teal = Color(0xFF94e2d5);
static const Color sky = Color(0xFF89dceb);
static const Color sapphire = Color(0xFF74c7ec);
static const Color blue = Color(0xFF89b4fa);
static const Color lavender = Color(0xFFb4befe);
static const Color flamingo = Color(0xFFf2cdcd);
static const Color pink = Color(0xFFf5c2e7);
static const Color mauve = Color(0xFFcba6f7);
static const Color red = Color(0xFFf38ba8);
static const Color maroon = Color(0xFFeba0ac);
static const Color peach = Color(0xFFfab387);
static const Color yellow = Color(0xFFf9e2af);
static const Color green = Color(0xFFa6e3a1);
static const Color teal = Color(0xFF94e2d5);
static const Color sky = Color(0xFF89dceb);
static const Color sapphire = Color(0xFF74c7ec);
static const Color blue = Color(0xFF89b4fa);
static const Color lavender = Color(0xFFb4befe);
}
const ColorScheme _catppuccinMochaColorScheme = ColorScheme(
brightness: Brightness.dark,
// Surfaces — AppBar (darkest) → NavRail → body (lightest)
surface: CatppuccinMochaColors.base,
surfaceContainerLowest: CatppuccinMochaColors.crust,
surfaceContainerLow: CatppuccinMochaColors.mantle,
surfaceContainer: CatppuccinMochaColors.surface0,
surfaceContainerHigh: CatppuccinMochaColors.surface1,
surface: CatppuccinMochaColors.base,
surfaceContainerLowest: CatppuccinMochaColors.crust,
surfaceContainerLow: CatppuccinMochaColors.mantle,
surfaceContainer: CatppuccinMochaColors.surface0,
surfaceContainerHigh: CatppuccinMochaColors.surface1,
surfaceContainerHighest: CatppuccinMochaColors.surface2,
onSurface: CatppuccinMochaColors.text,
onSurfaceVariant: CatppuccinMochaColors.subtext1,
outline: CatppuccinMochaColors.overlay1,
outlineVariant: CatppuccinMochaColors.overlay0,
onSurface: CatppuccinMochaColors.text,
onSurfaceVariant: CatppuccinMochaColors.subtext1,
outline: CatppuccinMochaColors.overlay1,
outlineVariant: CatppuccinMochaColors.overlay0,
// Primary — Save button, selected nav indicators
primary: CatppuccinMochaColors.mauve,
onPrimary: CatppuccinMochaColors.crust,
primaryContainer: CatppuccinMochaColors.surface0,
primary: CatppuccinMochaColors.mauve,
onPrimary: CatppuccinMochaColors.crust,
primaryContainer: CatppuccinMochaColors.surface0,
onPrimaryContainer: CatppuccinMochaColors.text,
// Secondary — Test button (not-passing state)
secondary: CatppuccinMochaColors.blue,
onSecondary: CatppuccinMochaColors.crust,
secondaryContainer: CatppuccinMochaColors.surface1,
secondary: CatppuccinMochaColors.blue,
onSecondary: CatppuccinMochaColors.crust,
secondaryContainer: CatppuccinMochaColors.surface1,
onSecondaryContainer: CatppuccinMochaColors.text,
// Tertiary — swipe-to-unread background
tertiary: CatppuccinMochaColors.teal,
onTertiary: CatppuccinMochaColors.crust,
tertiaryContainer: CatppuccinMochaColors.surface0,
tertiary: CatppuccinMochaColors.teal,
onTertiary: CatppuccinMochaColors.crust,
tertiaryContainer: CatppuccinMochaColors.surface0,
onTertiaryContainer: CatppuccinMochaColors.text,
// Error
error: CatppuccinMochaColors.red,
onError: CatppuccinMochaColors.crust,
errorContainer: CatppuccinMochaColors.maroon,
error: CatppuccinMochaColors.red,
onError: CatppuccinMochaColors.crust,
errorContainer: CatppuccinMochaColors.maroon,
onErrorContainer: CatppuccinMochaColors.crust,
// Inverse / scrim
inverseSurface: CatppuccinMochaColors.text,
inverseSurface: CatppuccinMochaColors.text,
onInverseSurface: CatppuccinMochaColors.crust,
inversePrimary: CatppuccinMochaColors.mauve,
scrim: CatppuccinMochaColors.crust,
shadow: CatppuccinMochaColors.crust,
inversePrimary: CatppuccinMochaColors.mauve,
scrim: CatppuccinMochaColors.crust,
shadow: CatppuccinMochaColors.crust,
);
final ThemeData catppuccinMochaDarkTheme = ThemeData(
final ThemeData catppuccinMochaDarkTheme = buildAppTheme(
colorScheme: _catppuccinMochaColorScheme,
extensions: [
const AppColorExtension(
success: CatppuccinMochaColors.green,
onSuccess: CatppuccinMochaColors.crust,
warning: CatppuccinMochaColors.peach,
onWarning: CatppuccinMochaColors.crust,
info: CatppuccinMochaColors.blue,
onInfo: CatppuccinMochaColors.crust,
),
],
appColors: const AppColorExtension(
success: CatppuccinMochaColors.green,
onSuccess: CatppuccinMochaColors.crust,
warning: CatppuccinMochaColors.peach,
onWarning: CatppuccinMochaColors.crust,
info: CatppuccinMochaColors.blue,
onInfo: CatppuccinMochaColors.crust,
),
);
+28
View File
@@ -0,0 +1,28 @@
/// Shared layout tokens for consistent spacing and responsive breakpoints.
///
/// Use these instead of hand-written magic numbers so spacing stays uniform
/// across screens and breakpoints are defined in a single place.
library;
/// Spacing scale (logical pixels). Used for padding, margins and gaps.
abstract final class Insets {
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
static const double xxxl = 32;
}
/// Responsive layout breakpoints, aligned with Material 3 window size classes.
abstract final class Breakpoints {
/// Compact → medium: switch from bottom NavigationBar to NavigationRail.
static const double medium = 600;
/// Medium → expanded: extend the NavigationRail with labels.
static const double expanded = 840;
/// Home screen switches to its two-column layout at this width.
static const double twoColumn = 700;
}
+56 -57
View File
@@ -1,30 +1,31 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'theme_builder.dart';
abstract final class GruvboxColors {
static const Color bgHard = Color(0xFF1d2021);
static const Color bg = Color(0xFF282828);
static const Color bgSoft = Color(0xFF32302f);
static const Color bg1 = Color(0xFF3c3836);
static const Color bg2 = Color(0xFF504945);
static const Color bg3 = Color(0xFF665c54);
static const Color bg4 = Color(0xFF7c6f64);
static const Color fg = Color(0xFFebdbb2);
static const Color fg2 = Color(0xFFd5c4a1);
static const Color gray = Color(0xFF928374);
static const Color red = Color(0xFFcc241d);
static const Color brightRed = Color(0xFFfb4934);
static const Color green = Color(0xFF98971a);
static const Color brightGreen = Color(0xFFb8bb26);
static const Color yellow = Color(0xFFd79921);
static const Color bgHard = Color(0xFF1d2021);
static const Color bg = Color(0xFF282828);
static const Color bgSoft = Color(0xFF32302f);
static const Color bg1 = Color(0xFF3c3836);
static const Color bg2 = Color(0xFF504945);
static const Color bg3 = Color(0xFF665c54);
static const Color bg4 = Color(0xFF7c6f64);
static const Color fg = Color(0xFFebdbb2);
static const Color fg2 = Color(0xFFd5c4a1);
static const Color gray = Color(0xFF928374);
static const Color red = Color(0xFFcc241d);
static const Color brightRed = Color(0xFFfb4934);
static const Color green = Color(0xFF98971a);
static const Color brightGreen = Color(0xFFb8bb26);
static const Color yellow = Color(0xFFd79921);
static const Color brightYellow = Color(0xFFfabd2f);
static const Color blue = Color(0xFF458588);
static const Color brightBlue = Color(0xFF83a598);
static const Color purple = Color(0xFFb16286);
static const Color blue = Color(0xFF458588);
static const Color brightBlue = Color(0xFF83a598);
static const Color purple = Color(0xFFb16286);
static const Color brightPurple = Color(0xFFd3869b);
static const Color aqua = Color(0xFF689d6a);
static const Color brightAqua = Color(0xFF8ec07c);
static const Color orange = Color(0xFFd65d0e);
static const Color aqua = Color(0xFF689d6a);
static const Color brightAqua = Color(0xFF8ec07c);
static const Color orange = Color(0xFFd65d0e);
static const Color brightOrange = Color(0xFFfe8019);
}
@@ -32,59 +33,57 @@ const ColorScheme _gruvboxColorScheme = ColorScheme(
brightness: Brightness.dark,
// Surfaces — AppBar (darkest) → NavRail → body (lightest)
surface: GruvboxColors.bgSoft,
surfaceContainerLowest: GruvboxColors.bgHard,
surfaceContainerLow: GruvboxColors.bg,
surfaceContainer: GruvboxColors.bg1,
surfaceContainerHigh: GruvboxColors.bg1,
surface: GruvboxColors.bgSoft,
surfaceContainerLowest: GruvboxColors.bgHard,
surfaceContainerLow: GruvboxColors.bg,
surfaceContainer: GruvboxColors.bg1,
surfaceContainerHigh: GruvboxColors.bg1,
surfaceContainerHighest: GruvboxColors.bg2,
onSurface: GruvboxColors.fg,
onSurfaceVariant: GruvboxColors.fg2,
outline: GruvboxColors.gray,
outlineVariant: GruvboxColors.bg4,
onSurface: GruvboxColors.fg,
onSurfaceVariant: GruvboxColors.fg2,
outline: GruvboxColors.gray,
outlineVariant: GruvboxColors.bg4,
// Primary — Save button, selected nav indicators
primary: GruvboxColors.brightOrange,
onPrimary: GruvboxColors.bgHard,
primaryContainer: GruvboxColors.bg1,
primary: GruvboxColors.brightOrange,
onPrimary: GruvboxColors.bgHard,
primaryContainer: GruvboxColors.bg1,
onPrimaryContainer: GruvboxColors.fg,
// Secondary — Test button (not-passing state)
secondary: GruvboxColors.blue,
onSecondary: GruvboxColors.fg,
secondaryContainer: GruvboxColors.bg2,
secondary: GruvboxColors.blue,
onSecondary: GruvboxColors.fg,
secondaryContainer: GruvboxColors.bg2,
onSecondaryContainer: GruvboxColors.fg,
// Tertiary — swipe-to-unread background
tertiary: GruvboxColors.aqua,
onTertiary: GruvboxColors.bgHard,
tertiaryContainer: GruvboxColors.bg1,
tertiary: GruvboxColors.aqua,
onTertiary: GruvboxColors.bgHard,
tertiaryContainer: GruvboxColors.bg1,
onTertiaryContainer: GruvboxColors.fg,
// Error
error: GruvboxColors.brightRed,
onError: GruvboxColors.bgHard,
errorContainer: GruvboxColors.red,
error: GruvboxColors.brightRed,
onError: GruvboxColors.bgHard,
errorContainer: GruvboxColors.red,
onErrorContainer: GruvboxColors.fg,
// Inverse / scrim
inverseSurface: GruvboxColors.fg,
inverseSurface: GruvboxColors.fg,
onInverseSurface: GruvboxColors.bgHard,
inversePrimary: GruvboxColors.orange,
scrim: GruvboxColors.bgHard,
shadow: GruvboxColors.bgHard,
inversePrimary: GruvboxColors.orange,
scrim: GruvboxColors.bgHard,
shadow: GruvboxColors.bgHard,
);
final ThemeData gruvboxDarkTheme = ThemeData(
final ThemeData gruvboxDarkTheme = buildAppTheme(
colorScheme: _gruvboxColorScheme,
extensions: [
const AppColorExtension(
success: GruvboxColors.brightGreen,
onSuccess: GruvboxColors.bgHard,
warning: GruvboxColors.brightYellow,
onWarning: GruvboxColors.bgHard,
info: GruvboxColors.brightBlue,
onInfo: GruvboxColors.bgHard,
),
],
appColors: const AppColorExtension(
success: GruvboxColors.brightGreen,
onSuccess: GruvboxColors.bgHard,
warning: GruvboxColors.brightYellow,
onWarning: GruvboxColors.bgHard,
info: GruvboxColors.brightBlue,
onInfo: GruvboxColors.bgHard,
),
);
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
/// Builds a Material 3 [ThemeData] from a [ColorScheme] and the app's custom
/// [AppColorExtension], applying the shared OOTT typography.
///
/// Both bundled themes (Catppuccin Mocha, Gruvbox Dark) go through here so they
/// stay visually consistent — only their colors differ.
ThemeData buildAppTheme({
required ColorScheme colorScheme,
required AppColorExtension appColors,
}) {
final base = ThemeData(colorScheme: colorScheme, useMaterial3: true);
return base.copyWith(
textTheme: _brandTextTheme(base.textTheme),
extensions: [appColors],
);
}
/// Applies the OOTT brand font (Barlow Condensed) to display, headline and
/// large-title styles while leaving body/label styles on the default face for
/// readability. Sizes and colors from [base] are preserved.
TextTheme _brandTextTheme(TextTheme base) {
final branded = GoogleFonts.barlowCondensedTextTheme(base);
return base.copyWith(
displayLarge: branded.displayLarge,
displayMedium: branded.displayMedium,
displaySmall: branded.displaySmall,
headlineLarge: branded.headlineLarge,
headlineMedium: branded.headlineMedium,
headlineSmall: branded.headlineSmall,
titleLarge: branded.titleLarge,
);
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
/// A centered placeholder for "nothing here yet" situations: an icon, a short
/// message and an optional call-to-action button. Keeps empty screens
/// consistent and gives the user a next step instead of a bare line of text.
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.message,
this.actionLabel,
this.onAction,
super.key,
});
final IconData icon;
final String message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasAction = actionLabel != null && onAction != null;
return Center(
child: Padding(
padding: const EdgeInsets.all(Insets.xxl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: Insets.md),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (hasAction) ...[
const SizedBox(height: Insets.lg),
OutlinedButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}
@@ -179,8 +179,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Status',
style: Theme.of(context).textTheme.titleMedium,
'Scanners',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
_scannerRow(
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
/// A single gently pulsing placeholder block, used to build skeleton loaders.
class Skeleton extends StatefulWidget {
const Skeleton({
this.width,
this.height = Insets.md,
this.borderRadius,
super.key,
});
final double? width;
final double height;
final BorderRadius? borderRadius;
@override
State<Skeleton> createState() => _SkeletonState();
}
class _SkeletonState extends State<Skeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final base = Theme.of(context).colorScheme.surfaceContainerHighest;
return FadeTransition(
opacity: Tween<double>(begin: 0.4, end: 1.0).animate(_controller),
child: Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: base,
borderRadius: widget.borderRadius ?? BorderRadius.circular(Insets.xs),
),
),
);
}
}
/// A skeleton placeholder approximating a list of rows (avatar + two text
/// lines), shown while list data is loading to avoid a bare spinner and layout
/// shift.
class ListSkeleton extends StatelessWidget {
const ListSkeleton({this.rows = 6, super.key});
final int rows;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Column(
children: List.generate(
rows,
(_) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.md,
),
child: Row(
children: const [
Skeleton(
width: 36,
height: 36,
borderRadius: BorderRadius.all(Radius.circular(18)),
),
SizedBox(width: Insets.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Skeleton(width: 160),
SizedBox(height: Insets.sm),
Skeleton(width: 100),
],
),
),
],
),
),
),
),
);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ void main() {
// falls back to the hard-coded release date.
await tester.pump();
expect(find.text('About OOTT'), findsOneWidget);
// The screen title now lives in the shared shell AppBar, not the body.
// Assert the fallback prefix only, not the release date literal (it changes
// every release).
expect(find.textContaining('Released'), findsOneWidget);
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/widgets/empty_state.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
setUp(() async {
await setUpBackendForTest();
});
testWidgets('renders the icon and message without an action button', (
tester,
) async {
await pumpScreen(
tester,
const EmptyState(
icon: Icons.devices_other_outlined,
message: 'No devices found',
),
);
expect(find.text('No devices found'), findsOneWidget);
expect(find.byIcon(Icons.devices_other_outlined), findsOneWidget);
expect(find.byType(OutlinedButton), findsNothing);
});
testWidgets('renders an action button and invokes its callback', (
tester,
) async {
var tapped = false;
await pumpScreen(
tester,
EmptyState(
icon: Icons.devices_other_outlined,
message: 'No devices found',
actionLabel: 'Check scanner status',
onAction: () => tapped = true,
),
);
await tester.tap(
find.widgetWithText(OutlinedButton, 'Check scanner status'),
);
expect(tapped, isTrue);
});
}
+51 -11
View File
@@ -2,6 +2,7 @@ import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/settings/settings.dart';
import 'package:frontend/utils/pref_utils.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
@@ -24,24 +25,26 @@ void main() {
expect(find.text('Catppuccin Mocha'), findsOneWidget);
});
testWidgets('validates an empty base URL when testing the connection',
(tester) async {
testWidgets('validates an empty base URL when testing the connection', (
tester,
) async {
await pumpScreen(tester, const Settings());
await tester.enterText(find.byType(TextFormField).first, '');
await tester.tap(find.widgetWithText(ElevatedButton, 'Test'));
await tester.tap(find.widgetWithText(FilledButton, 'Test'));
await tester.pump();
expect(find.text('The URL cannot be empty'), findsOneWidget);
});
testWidgets('disables Save once the connection details are edited',
(tester) async {
testWidgets('disables Save once the connection details are edited', (
tester,
) async {
await pumpScreen(tester, const Settings());
final saveButton = find.widgetWithText(ElevatedButton, 'Save');
final saveButton = find.widgetWithText(FilledButton, 'Save');
expect(
tester.widget<ElevatedButton>(saveButton).onPressed,
tester.widget<FilledButton>(saveButton).onPressed,
isNotNull,
reason: 'enabled for the unmodified, prefilled form',
);
@@ -53,19 +56,56 @@ void main() {
await tester.pump();
expect(
tester.widget<ElevatedButton>(saveButton).onPressed,
tester.widget<FilledButton>(saveButton).onPressed,
isNull,
reason: 'disabled until the new connection is tested',
);
});
testWidgets('saving the unmodified form shows a success message',
(tester) async {
testWidgets('saving the unmodified form shows a success message', (
tester,
) async {
await pumpScreen(tester, const Settings());
await tester.tap(find.widgetWithText(ElevatedButton, 'Save'));
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await pumpUntilFound(tester, find.text('Settings saved successfully'));
expect(find.text('Settings saved successfully'), findsOneWidget);
});
testWidgets('shows the welcome intro when no server is configured', (
tester,
) async {
await PrefUtil.setValue('base_url', '');
await pumpScreen(tester, const Settings());
expect(find.textContaining('Welcome to OOTT'), findsOneWidget);
});
testWidgets('hides the welcome intro once a server is configured', (
tester,
) async {
await PrefUtil.setValue('base_url', 'http://my.server/api');
await pumpScreen(tester, const Settings());
expect(find.textContaining('Welcome to OOTT'), findsNothing);
});
testWidgets('explains why Save is disabled after editing the connection', (
tester,
) async {
await pumpScreen(tester, const Settings());
// Editing the connection requires re-testing before saving.
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.pump();
expect(
find.textContaining('Test the connection before saving'),
findsOneWidget,
);
});
}
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/widgets/skeleton.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
setUp(() async {
await setUpBackendForTest();
});
testWidgets('ListSkeleton renders three placeholder blocks per row', (
tester,
) async {
await pumpScreen(tester, const ListSkeleton(rows: 2));
await tester.pump();
// Each row is an avatar block plus two text-line blocks.
expect(find.byType(Skeleton), findsNWidgets(6));
// Unmount so the pulsing animation controllers are disposed cleanly.
await tearDownTree(tester);
});
}
+4 -3
View File
@@ -43,14 +43,15 @@ void main() {
);
}
testWidgets('renders each scanner card with its resolved status',
(tester) async {
testWidgets('renders each scanner card with its resolved status', (
tester,
) async {
stubAllScanners();
await pumpScreen(tester, const StatusScreen());
await pumpUntilFound(tester, find.text('Running'));
expect(find.text('Status'), findsOneWidget);
// The screen title now lives in the shared shell AppBar, not the body.
expect(find.text('ARP Scanner'), findsOneWidget);
expect(find.text('mDNS Scanner'), findsOneWidget);
expect(find.text('Running'), findsWidgets);