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:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../theme/dimens.dart';
// The version is read at runtime from the bundled package metadata // The version is read at runtime from the bundled package metadata
// (frontend/pubspec.yaml), so it never needs to be hand-edited here. // (frontend/pubspec.yaml), so it never needs to be hand-edited here.
const _releaseDate = 'May 28, 2026'; const _releaseDate = 'May 28, 2026';
@@ -18,18 +20,16 @@ class About extends StatelessWidget {
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(Insets.xxl),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('About OOTT', style: textTheme.headlineSmall),
const SizedBox(height: 16),
Text( Text(
'Easy to setup and use network device discovery and alert system. ' 'Easy to setup and use network device discovery and alert system. '
'Notifies you when new or unknown devices join your local area network.', 'Notifies you when new or unknown devices join your local area network.',
style: textTheme.bodyLarge, style: textTheme.bodyLarge,
), ),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
FutureBuilder<PackageInfo>( FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(), future: PackageInfo.fromPlatform(),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -45,7 +45,7 @@ class About extends StatelessWidget {
); );
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: Insets.xxl),
_SurfaceContainer( _SurfaceContainer(
colorScheme: colorScheme, colorScheme: colorScheme,
child: Column( child: Column(
@@ -60,17 +60,17 @@ class About extends StatelessWidget {
Divider( Divider(
height: 1, height: 1,
color: colorScheme.outlineVariant, color: colorScheme.outlineVariant,
indent: 16, indent: Insets.lg,
endIndent: 16, endIndent: Insets.lg,
), ),
_LicenseRow(colorScheme: colorScheme, textTheme: textTheme), _LicenseRow(colorScheme: colorScheme, textTheme: textTheme),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: Insets.lg),
_SurfaceContainer( _SurfaceContainer(
colorScheme: colorScheme, colorScheme: colorScheme,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(Insets.lg),
child: _NoticesSection(textTheme: textTheme), child: _NoticesSection(textTheme: textTheme),
), ),
], ],
@@ -132,11 +132,14 @@ class _LinkRow extends StatelessWidget {
return InkWell( return InkWell(
onTap: _open, onTap: _open,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(
vertical: Insets.md,
horizontal: Insets.lg,
),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 18, color: colorScheme.primary), Icon(icon, size: 18, color: colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: Insets.md),
Text( Text(
label, label,
style: textTheme.bodyMedium?.copyWith( style: textTheme.bodyMedium?.copyWith(
@@ -168,12 +171,15 @@ class _LicenseRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), padding: const EdgeInsets.symmetric(
vertical: Insets.md,
horizontal: Insets.lg,
),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon(Icons.gavel, size: 18, color: colorScheme.primary), Icon(Icons.gavel, size: 18, color: colorScheme.primary),
const SizedBox(width: 12), const SizedBox(width: Insets.md),
Flexible( Flexible(
child: Wrap( child: Wrap(
children: [ children: [
@@ -214,7 +220,7 @@ class _NoticesSection extends StatelessWidget {
'OOTT, Copyright (C) 2024-2026 Ricardo Zuasti', 'OOTT, Copyright (C) 2024-2026 Ricardo Zuasti',
style: textTheme.bodyMedium, style: textTheme.bodyMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
Text( Text(
'This product includes software developed by third parties and distributed ' 'This product includes software developed by third parties and distributed '
'under the Apache License, Version 2.0.', 'under the Apache License, Version 2.0.',
@@ -222,7 +228,7 @@ class _NoticesSection extends StatelessWidget {
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: Insets.md),
..._thirdPartyComponents.map( ..._thirdPartyComponents.map(
(c) => _ThirdPartyEntry(component: c, textTheme: textTheme), (c) => _ThirdPartyEntry(component: c, textTheme: textTheme),
), ),
@@ -241,7 +247,7 @@ class _ThirdPartyEntry extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: Insets.sm),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
+59 -43
View File
@@ -2,19 +2,22 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_type.dart'; import '../model/device_type.dart';
import '../navigation.dart'; import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../widgets/empty_state.dart';
import '../widgets/pagination_bar.dart'; import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'device_list_filter.dart'; import 'device_list_filter.dart';
import 'device_list_rows.dart'; import 'device_list_rows.dart';
import 'device_list_sort.dart'; import 'device_list_sort.dart';
const _pageSize = 10; const _pageSize = 10;
const _wideLayoutBreakpoint = 600.0;
class DeviceList extends StatefulWidget { class DeviceList extends StatefulWidget {
const DeviceList({super.key}); const DeviceList({super.key});
@@ -147,11 +150,32 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
bool get _hasActiveDetailFilters => bool get _hasActiveDetailFilters =>
_ownerController.text.isNotEmpty || _typeFilter != null; _ownerController.text.isNotEmpty || _typeFilter != null;
void _showFilterSheet() { /// Presents [child] as a modal bottom sheet on narrow (phone) layouts and as
showModalBottomSheet( /// 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, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => DeviceFilterSheet( builder: (_) => child,
);
}
void _showFilterSheet() {
_showAdaptivePanel(
DeviceFilterSheet(
ownerController: _ownerController, ownerController: _ownerController,
typeFilter: _typeFilter, typeFilter: _typeFilter,
hasActiveFilters: _hasActiveDetailFilters, hasActiveFilters: _hasActiveDetailFilters,
@@ -171,10 +195,8 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
} }
void _showSortSheet() { void _showSortSheet() {
showModalBottomSheet( _showAdaptivePanel(
context: context, DeviceSortSheet(
isScrollControlled: true,
builder: (_) => DeviceSortSheet(
currentColumn: _sortColumn, currentColumn: _sortColumn,
ascending: _sortAscending, ascending: _sortAscending,
onChanged: _onSortSheetChanged, onChanged: _onSortSheetChanged,
@@ -184,18 +206,37 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= _wideLayoutBreakpoint; final isWide = constraints.maxWidth >= Breakpoints.medium;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Expanded( 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) if (!isWide)
IconButton( 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)), Expanded(child: _buildBody(context, isWide)),
], ],
); );
@@ -241,7 +263,7 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
Widget _buildBody(BuildContext context, bool isWide) { Widget _buildBody(BuildContext context, bool isWide) {
if (_isLoading) { if (_isLoading) {
return const Center(child: CircularProgressIndicator()); return const ListSkeleton();
} }
if (_error != null) { if (_error != null) {
return Center( return Center(
@@ -252,17 +274,11 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
); );
} }
if (_devices.isEmpty) { if (_devices.isEmpty) {
final theme = Theme.of(context); return EmptyState(
return Padding( icon: Icons.devices_other_outlined,
padding: const EdgeInsets.only(top: 16, bottom: 12), message: _emptyMessage(),
child: Center( actionLabel: 'Check scanner status',
child: Text( onAction: () => context.go('/status'),
_emptyMessage(),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
); );
} }
+6 -7
View File
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/dimens.dart';
import '../widgets/device_summary_card.dart'; import '../widgets/device_summary_card.dart';
import '../widgets/scanners_status_card.dart'; import '../widgets/scanners_status_card.dart';
import 'notifications_list.dart'; import 'notifications_list.dart';
const _twoColumnBreakpoint = 700.0;
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -13,7 +12,7 @@ class HomeScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint; final isTwoColumn = constraints.maxWidth >= Breakpoints.twoColumn;
return isTwoColumn ? _buildTwoColumn() : _buildSingleColumn(); return isTwoColumn ? _buildTwoColumn() : _buildSingleColumn();
}, },
); );
@@ -24,7 +23,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(flex: 3, child: NotificationsList()), Expanded(flex: 3, child: NotificationsList()),
VerticalDivider(width: 32), VerticalDivider(width: Insets.xxxl),
SizedBox( SizedBox(
width: 300, width: 300,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -32,7 +31,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
DeviceSummaryCard(), DeviceSummaryCard(),
SizedBox(height: 16), SizedBox(height: Insets.lg),
ScannersStatusCard(), ScannersStatusCard(),
], ],
), ),
@@ -47,13 +46,13 @@ class HomeScreen extends StatelessWidget {
trailingSlivers: [ trailingSlivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.only(top: 24), padding: EdgeInsets.only(top: Insets.xxl),
child: DeviceSummaryCard(), child: DeviceSummaryCard(),
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.only(top: 16, bottom: 20), padding: EdgeInsets.only(top: Insets.lg, bottom: Insets.xl),
child: ScannersStatusCard(), child: ScannersStatusCard(),
), ),
), ),
+8 -7
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../model/notification.dart' as oott_model; import '../model/notification.dart' as oott_model;
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
class NotificationCard extends StatefulWidget { class NotificationCard extends StatefulWidget {
@@ -25,7 +26,7 @@ class _NotificationCardState extends State<NotificationCard> {
Widget _buildActions(BuildContext context) { Widget _buildActions(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(Insets.sm, 0, Insets.sm, Insets.sm),
child: OverflowBar( child: OverflowBar(
alignment: MainAxisAlignment.end, alignment: MainAxisAlignment.end,
children: [ children: [
@@ -61,13 +62,13 @@ class _NotificationCardState extends State<NotificationCard> {
background: Container( background: Container(
color: theme.colorScheme.tertiaryContainer, color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: Insets.lg),
child: const Icon(Icons.mark_email_unread), child: const Icon(Icons.mark_email_unread),
), ),
secondaryBackground: Container( secondaryBackground: Container(
color: theme.colorScheme.primaryContainer, color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: Insets.lg),
child: const Icon(Icons.done), child: const Icon(Icons.done),
), ),
child: Column( child: Column(
@@ -76,8 +77,7 @@ class _NotificationCardState extends State<NotificationCard> {
ListTile( ListTile(
leading: Icon( leading: Icon(
widget.item.notificationType.icon, widget.item.notificationType.icon,
color: color: widget.item.isNew ? theme.colorScheme.primary : null,
widget.item.isNew ? theme.colorScheme.primary : null,
), ),
title: Text( title: Text(
'${widget.formatter.format(widget.item.createdOn)} - ${widget.item.title}', '${widget.formatter.format(widget.item.createdOn)} - ${widget.item.title}',
@@ -88,8 +88,9 @@ class _NotificationCardState extends State<NotificationCard> {
subtitle: Text( subtitle: Text(
widget.item.body, widget.item.body,
maxLines: _expanded ? null : 2, maxLines: _expanded ? null : 2,
overflow: overflow: _expanded
_expanded ? TextOverflow.visible : TextOverflow.ellipsis, ? TextOverflow.visible
: TextOverflow.ellipsis,
), ),
onTap: () => setState(() => _expanded = !_expanded), 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 '../model/notification.dart' as oott_model;
import '../navigation.dart'; import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart'; import '../utils/ui_snackbars.dart';
import '../widgets/empty_state.dart';
import '../widgets/pagination_bar.dart'; import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'notification_card.dart'; import 'notification_card.dart';
const _pageSize = 5; const _pageSize = 5;
@@ -190,6 +193,12 @@ class _NotificationsListState extends State<NotificationsList>
return false; return false;
} }
String _emptyMessage() => switch (_filter) {
_NotificationFilter.newOnly => 'No new notifications',
_NotificationFilter.oldOnly => 'No old notifications',
_NotificationFilter.all => 'No notifications yet',
};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@@ -229,9 +238,12 @@ class _NotificationsListState extends State<NotificationsList>
), ),
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.xs,
),
child: Wrap( child: Wrap(
spacing: 8.0, spacing: Insets.sm,
children: _NotificationFilter.values children: _NotificationFilter.values
.map( .map(
(f) => ChoiceChip( (f) => ChoiceChip(
@@ -252,11 +264,7 @@ class _NotificationsListState extends State<NotificationsList>
List<Widget> _buildNotificationSlivers(BuildContext context) { List<Widget> _buildNotificationSlivers(BuildContext context) {
if (_isLoading) { if (_isLoading) {
return [ return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
];
} }
if (_error != null) { if (_error != null) {
return [ return [
@@ -273,16 +281,9 @@ class _NotificationsListState extends State<NotificationsList>
if (_items.isEmpty) { if (_items.isEmpty) {
return [ return [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: EmptyState(
padding: const EdgeInsets.only(top: 16, bottom: 12), icon: Icons.notifications_off_outlined,
child: Center( message: _emptyMessage(),
child: Text(
'No items found',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
), ),
), ),
]; ];
+35 -29
View File
@@ -2,18 +2,14 @@ import 'package:flutter/material.dart';
import 'package:frontend/about/about.dart'; import 'package:frontend/about/about.dart';
import 'package:frontend/settings/settings.dart'; import 'package:frontend/settings/settings.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.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 'home/home_screen.dart'; import 'home/home_screen.dart';
import 'status/status_screen.dart'; import 'status/status_screen.dart';
import 'theme/dimens.dart';
import 'utils/pref_utils.dart'; import 'utils/pref_utils.dart';
import 'widgets/offline_banner.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}); typedef _NavDest = ({IconData icon, IconData activeIcon, String label});
const List<_NavDest> _destinations = [ const List<_NavDest> _destinations = [
@@ -107,18 +103,15 @@ class MainShell extends StatelessWidget {
final selectedIndex = _calculateSelectedIndex(context); final selectedIndex = _calculateSelectedIndex(context);
final width = constraints.maxWidth; final width = constraints.maxWidth;
if (width < _mediumBreakpoint) { if (width < Breakpoints.medium) {
return Scaffold( return Scaffold(
appBar: _buildAppBar(context), appBar: _buildAppBar(context, selectedIndex),
body: Column( body: Column(
children: [ children: [
const OfflineBanner(), const OfflineBanner(),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.all(Insets.lg),
horizontal: 16,
vertical: 12,
),
child: child, child: child,
), ),
), ),
@@ -142,7 +135,7 @@ class MainShell extends StatelessWidget {
} }
return Scaffold( return Scaffold(
appBar: _buildAppBar(context), appBar: _buildAppBar(context, selectedIndex),
body: Row( body: Row(
children: [ children: [
SafeArea( SafeArea(
@@ -150,7 +143,7 @@ class MainShell extends StatelessWidget {
backgroundColor: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.surfaceContainerLow, ).colorScheme.surfaceContainerLow,
extended: width >= _expandedBreakpoint, extended: width >= Breakpoints.expanded,
destinations: _destinations destinations: _destinations
.map( .map(
(d) => NavigationRailDestination( (d) => NavigationRailDestination(
@@ -173,7 +166,7 @@ class MainShell extends StatelessWidget {
const OfflineBanner(), const OfflineBanner(),
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(Insets.lg),
child: child, 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( return AppBar(
title: Container( titleSpacing: Insets.lg,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), title: Row(
decoration: BoxDecoration( children: [
color: Theme.of(context).colorScheme.primary, Container(
borderRadius: BorderRadius.circular(10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
), decoration: BoxDecoration(
child: Text( color: theme.colorScheme.primary,
'OOTT', borderRadius: BorderRadius.circular(10),
style: GoogleFonts.barlowCondensed( ),
color: Theme.of(context).colorScheme.onPrimary, child: Text(
fontWeight: FontWeight.bold, 'OOTT',
fontSize: 26, 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 '../main.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/pref_utils.dart'; import '../utils/pref_utils.dart';
import '../utils/ui_snackbars.dart'; import '../utils/ui_snackbars.dart';
@@ -21,6 +22,7 @@ class _SettingsState extends State<Settings> {
bool _apiKeyVisible = false; bool _apiKeyVisible = false;
bool _testOk = false; bool _testOk = false;
bool _connectionModified = false; bool _connectionModified = false;
bool _isFirstRun = false;
late String _selectedTheme; late String _selectedTheme;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
@@ -28,6 +30,7 @@ class _SettingsState extends State<Settings> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isFirstRun = (PrefUtil.getValue('base_url', '') as String).isEmpty;
_baseUrlController.text = PrefUtil.getValue('base_url', '') as String; _baseUrlController.text = PrefUtil.getValue('base_url', '') as String;
_apiKeyController.text = XOR().xorDecode( _apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue('api_key', '') as String, PrefUtil.getValue('api_key', '') as String,
@@ -100,6 +103,7 @@ class _SettingsState extends State<Settings> {
final appColors = Theme.of(context).extension<AppColorExtension>()!; final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final saveDisabled = _connectionModified && !_testOk;
return SingleChildScrollView( return SingleChildScrollView(
child: Form( child: Form(
@@ -107,8 +111,33 @@ class _SettingsState extends State<Settings> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Settings', style: textTheme.headlineSmall), if (_isFirstRun) ...[
const SizedBox(height: 16), 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( TextFormField(
controller: _baseUrlController, controller: _baseUrlController,
onChanged: _onConnectionChanged, onChanged: _onConnectionChanged,
@@ -121,7 +150,7 @@ class _SettingsState extends State<Settings> {
hintText: 'For example http://192.168.0.1:3000/api', hintText: 'For example http://192.168.0.1:3000/api',
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: Insets.lg),
TextFormField( TextFormField(
controller: _apiKeyController, controller: _apiKeyController,
onChanged: _onConnectionChanged, onChanged: _onConnectionChanged,
@@ -141,7 +170,7 @@ class _SettingsState extends State<Settings> {
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: Insets.lg),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
initialValue: _selectedTheme, initialValue: _selectedTheme,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -162,17 +191,17 @@ class _SettingsState extends State<Settings> {
if (value != null) setState(() => _selectedTheme = value); if (value != null) setState(() => _selectedTheme = value);
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: Insets.lg),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ElevatedButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
_testConnection(); _testConnection();
}, },
label: const Text('Test'), label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow), icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: ElevatedButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: _testOk backgroundColor: _testOk
? appColors.success ? appColors.success
: colorScheme.secondary, : colorScheme.secondary,
@@ -181,18 +210,26 @@ class _SettingsState extends State<Settings> {
: colorScheme.onSecondary, : colorScheme.onSecondary,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: Insets.sm),
ElevatedButton.icon( FilledButton.icon(
onPressed: (_connectionModified && !_testOk) ? null : _save, onPressed: saveDisabled ? null : _save,
label: const Text('Save'), label: const Text('Save'),
icon: const Icon(Icons.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 'package:flutter/material.dart';
import '../theme/dimens.dart';
import '../widgets/arp_scanner_card.dart'; import '../widgets/arp_scanner_card.dart';
import '../widgets/dhcp_scanner_card.dart'; import '../widgets/dhcp_scanner_card.dart';
import '../widgets/mdns_scanner_card.dart'; import '../widgets/mdns_scanner_card.dart';
@@ -15,16 +16,14 @@ class StatusScreen extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Status', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 20),
const ArpScannerCard(), const ArpScannerCard(),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
const MdnsScannerCard(), const MdnsScannerCard(),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
const SsdpScannerCard(), const SsdpScannerCard(),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
const DhcpScannerCard(), const DhcpScannerCard(),
const SizedBox(height: 8), const SizedBox(height: Insets.sm),
const SnmpScannerCard(), const SnmpScannerCard(),
], ],
), ),
+60 -61
View File
@@ -1,93 +1,92 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app_colors.dart'; import 'app_colors.dart';
import 'theme_builder.dart';
abstract final class CatppuccinMochaColors { abstract final class CatppuccinMochaColors {
static const Color crust = Color(0xFF11111b); static const Color crust = Color(0xFF11111b);
static const Color mantle = Color(0xFF181825); static const Color mantle = Color(0xFF181825);
static const Color base = Color(0xFF1e1e2e); static const Color base = Color(0xFF1e1e2e);
static const Color surface0 = Color(0xFF313244); static const Color surface0 = Color(0xFF313244);
static const Color surface1 = Color(0xFF45475a); static const Color surface1 = Color(0xFF45475a);
static const Color surface2 = Color(0xFF585b70); static const Color surface2 = Color(0xFF585b70);
static const Color overlay0 = Color(0xFF6c7086); static const Color overlay0 = Color(0xFF6c7086);
static const Color overlay1 = Color(0xFF7f849c); static const Color overlay1 = Color(0xFF7f849c);
static const Color overlay2 = Color(0xFF9399b2); static const Color overlay2 = Color(0xFF9399b2);
static const Color subtext0 = Color(0xFFa6adc8); static const Color subtext0 = Color(0xFFa6adc8);
static const Color subtext1 = Color(0xFFbac2de); static const Color subtext1 = Color(0xFFbac2de);
static const Color text = Color(0xFFcdd6f4); static const Color text = Color(0xFFcdd6f4);
static const Color rosewater = Color(0xFFf5e0dc); static const Color rosewater = Color(0xFFf5e0dc);
static const Color flamingo = Color(0xFFf2cdcd); static const Color flamingo = Color(0xFFf2cdcd);
static const Color pink = Color(0xFFf5c2e7); static const Color pink = Color(0xFFf5c2e7);
static const Color mauve = Color(0xFFcba6f7); static const Color mauve = Color(0xFFcba6f7);
static const Color red = Color(0xFFf38ba8); static const Color red = Color(0xFFf38ba8);
static const Color maroon = Color(0xFFeba0ac); static const Color maroon = Color(0xFFeba0ac);
static const Color peach = Color(0xFFfab387); static const Color peach = Color(0xFFfab387);
static const Color yellow = Color(0xFFf9e2af); static const Color yellow = Color(0xFFf9e2af);
static const Color green = Color(0xFFa6e3a1); static const Color green = Color(0xFFa6e3a1);
static const Color teal = Color(0xFF94e2d5); static const Color teal = Color(0xFF94e2d5);
static const Color sky = Color(0xFF89dceb); static const Color sky = Color(0xFF89dceb);
static const Color sapphire = Color(0xFF74c7ec); static const Color sapphire = Color(0xFF74c7ec);
static const Color blue = Color(0xFF89b4fa); static const Color blue = Color(0xFF89b4fa);
static const Color lavender = Color(0xFFb4befe); static const Color lavender = Color(0xFFb4befe);
} }
const ColorScheme _catppuccinMochaColorScheme = ColorScheme( const ColorScheme _catppuccinMochaColorScheme = ColorScheme(
brightness: Brightness.dark, brightness: Brightness.dark,
// Surfaces — AppBar (darkest) → NavRail → body (lightest) // Surfaces — AppBar (darkest) → NavRail → body (lightest)
surface: CatppuccinMochaColors.base, surface: CatppuccinMochaColors.base,
surfaceContainerLowest: CatppuccinMochaColors.crust, surfaceContainerLowest: CatppuccinMochaColors.crust,
surfaceContainerLow: CatppuccinMochaColors.mantle, surfaceContainerLow: CatppuccinMochaColors.mantle,
surfaceContainer: CatppuccinMochaColors.surface0, surfaceContainer: CatppuccinMochaColors.surface0,
surfaceContainerHigh: CatppuccinMochaColors.surface1, surfaceContainerHigh: CatppuccinMochaColors.surface1,
surfaceContainerHighest: CatppuccinMochaColors.surface2, surfaceContainerHighest: CatppuccinMochaColors.surface2,
onSurface: CatppuccinMochaColors.text, onSurface: CatppuccinMochaColors.text,
onSurfaceVariant: CatppuccinMochaColors.subtext1, onSurfaceVariant: CatppuccinMochaColors.subtext1,
outline: CatppuccinMochaColors.overlay1, outline: CatppuccinMochaColors.overlay1,
outlineVariant: CatppuccinMochaColors.overlay0, outlineVariant: CatppuccinMochaColors.overlay0,
// Primary — Save button, selected nav indicators // Primary — Save button, selected nav indicators
primary: CatppuccinMochaColors.mauve, primary: CatppuccinMochaColors.mauve,
onPrimary: CatppuccinMochaColors.crust, onPrimary: CatppuccinMochaColors.crust,
primaryContainer: CatppuccinMochaColors.surface0, primaryContainer: CatppuccinMochaColors.surface0,
onPrimaryContainer: CatppuccinMochaColors.text, onPrimaryContainer: CatppuccinMochaColors.text,
// Secondary — Test button (not-passing state) // Secondary — Test button (not-passing state)
secondary: CatppuccinMochaColors.blue, secondary: CatppuccinMochaColors.blue,
onSecondary: CatppuccinMochaColors.crust, onSecondary: CatppuccinMochaColors.crust,
secondaryContainer: CatppuccinMochaColors.surface1, secondaryContainer: CatppuccinMochaColors.surface1,
onSecondaryContainer: CatppuccinMochaColors.text, onSecondaryContainer: CatppuccinMochaColors.text,
// Tertiary — swipe-to-unread background // Tertiary — swipe-to-unread background
tertiary: CatppuccinMochaColors.teal, tertiary: CatppuccinMochaColors.teal,
onTertiary: CatppuccinMochaColors.crust, onTertiary: CatppuccinMochaColors.crust,
tertiaryContainer: CatppuccinMochaColors.surface0, tertiaryContainer: CatppuccinMochaColors.surface0,
onTertiaryContainer: CatppuccinMochaColors.text, onTertiaryContainer: CatppuccinMochaColors.text,
// Error // Error
error: CatppuccinMochaColors.red, error: CatppuccinMochaColors.red,
onError: CatppuccinMochaColors.crust, onError: CatppuccinMochaColors.crust,
errorContainer: CatppuccinMochaColors.maroon, errorContainer: CatppuccinMochaColors.maroon,
onErrorContainer: CatppuccinMochaColors.crust, onErrorContainer: CatppuccinMochaColors.crust,
// Inverse / scrim // Inverse / scrim
inverseSurface: CatppuccinMochaColors.text, inverseSurface: CatppuccinMochaColors.text,
onInverseSurface: CatppuccinMochaColors.crust, onInverseSurface: CatppuccinMochaColors.crust,
inversePrimary: CatppuccinMochaColors.mauve, inversePrimary: CatppuccinMochaColors.mauve,
scrim: CatppuccinMochaColors.crust, scrim: CatppuccinMochaColors.crust,
shadow: CatppuccinMochaColors.crust, shadow: CatppuccinMochaColors.crust,
); );
final ThemeData catppuccinMochaDarkTheme = ThemeData( final ThemeData catppuccinMochaDarkTheme = buildAppTheme(
colorScheme: _catppuccinMochaColorScheme, colorScheme: _catppuccinMochaColorScheme,
extensions: [ appColors: const AppColorExtension(
const AppColorExtension( success: CatppuccinMochaColors.green,
success: CatppuccinMochaColors.green, onSuccess: CatppuccinMochaColors.crust,
onSuccess: CatppuccinMochaColors.crust, warning: CatppuccinMochaColors.peach,
warning: CatppuccinMochaColors.peach, onWarning: CatppuccinMochaColors.crust,
onWarning: CatppuccinMochaColors.crust, info: CatppuccinMochaColors.blue,
info: CatppuccinMochaColors.blue, onInfo: CatppuccinMochaColors.crust,
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 'package:flutter/material.dart';
import 'app_colors.dart'; import 'app_colors.dart';
import 'theme_builder.dart';
abstract final class GruvboxColors { abstract final class GruvboxColors {
static const Color bgHard = Color(0xFF1d2021); static const Color bgHard = Color(0xFF1d2021);
static const Color bg = Color(0xFF282828); static const Color bg = Color(0xFF282828);
static const Color bgSoft = Color(0xFF32302f); static const Color bgSoft = Color(0xFF32302f);
static const Color bg1 = Color(0xFF3c3836); static const Color bg1 = Color(0xFF3c3836);
static const Color bg2 = Color(0xFF504945); static const Color bg2 = Color(0xFF504945);
static const Color bg3 = Color(0xFF665c54); static const Color bg3 = Color(0xFF665c54);
static const Color bg4 = Color(0xFF7c6f64); static const Color bg4 = Color(0xFF7c6f64);
static const Color fg = Color(0xFFebdbb2); static const Color fg = Color(0xFFebdbb2);
static const Color fg2 = Color(0xFFd5c4a1); static const Color fg2 = Color(0xFFd5c4a1);
static const Color gray = Color(0xFF928374); static const Color gray = Color(0xFF928374);
static const Color red = Color(0xFFcc241d); static const Color red = Color(0xFFcc241d);
static const Color brightRed = Color(0xFFfb4934); static const Color brightRed = Color(0xFFfb4934);
static const Color green = Color(0xFF98971a); static const Color green = Color(0xFF98971a);
static const Color brightGreen = Color(0xFFb8bb26); static const Color brightGreen = Color(0xFFb8bb26);
static const Color yellow = Color(0xFFd79921); static const Color yellow = Color(0xFFd79921);
static const Color brightYellow = Color(0xFFfabd2f); static const Color brightYellow = Color(0xFFfabd2f);
static const Color blue = Color(0xFF458588); static const Color blue = Color(0xFF458588);
static const Color brightBlue = Color(0xFF83a598); static const Color brightBlue = Color(0xFF83a598);
static const Color purple = Color(0xFFb16286); static const Color purple = Color(0xFFb16286);
static const Color brightPurple = Color(0xFFd3869b); static const Color brightPurple = Color(0xFFd3869b);
static const Color aqua = Color(0xFF689d6a); static const Color aqua = Color(0xFF689d6a);
static const Color brightAqua = Color(0xFF8ec07c); static const Color brightAqua = Color(0xFF8ec07c);
static const Color orange = Color(0xFFd65d0e); static const Color orange = Color(0xFFd65d0e);
static const Color brightOrange = Color(0xFFfe8019); static const Color brightOrange = Color(0xFFfe8019);
} }
@@ -32,59 +33,57 @@ const ColorScheme _gruvboxColorScheme = ColorScheme(
brightness: Brightness.dark, brightness: Brightness.dark,
// Surfaces — AppBar (darkest) → NavRail → body (lightest) // Surfaces — AppBar (darkest) → NavRail → body (lightest)
surface: GruvboxColors.bgSoft, surface: GruvboxColors.bgSoft,
surfaceContainerLowest: GruvboxColors.bgHard, surfaceContainerLowest: GruvboxColors.bgHard,
surfaceContainerLow: GruvboxColors.bg, surfaceContainerLow: GruvboxColors.bg,
surfaceContainer: GruvboxColors.bg1, surfaceContainer: GruvboxColors.bg1,
surfaceContainerHigh: GruvboxColors.bg1, surfaceContainerHigh: GruvboxColors.bg1,
surfaceContainerHighest: GruvboxColors.bg2, surfaceContainerHighest: GruvboxColors.bg2,
onSurface: GruvboxColors.fg, onSurface: GruvboxColors.fg,
onSurfaceVariant: GruvboxColors.fg2, onSurfaceVariant: GruvboxColors.fg2,
outline: GruvboxColors.gray, outline: GruvboxColors.gray,
outlineVariant: GruvboxColors.bg4, outlineVariant: GruvboxColors.bg4,
// Primary — Save button, selected nav indicators // Primary — Save button, selected nav indicators
primary: GruvboxColors.brightOrange, primary: GruvboxColors.brightOrange,
onPrimary: GruvboxColors.bgHard, onPrimary: GruvboxColors.bgHard,
primaryContainer: GruvboxColors.bg1, primaryContainer: GruvboxColors.bg1,
onPrimaryContainer: GruvboxColors.fg, onPrimaryContainer: GruvboxColors.fg,
// Secondary — Test button (not-passing state) // Secondary — Test button (not-passing state)
secondary: GruvboxColors.blue, secondary: GruvboxColors.blue,
onSecondary: GruvboxColors.fg, onSecondary: GruvboxColors.fg,
secondaryContainer: GruvboxColors.bg2, secondaryContainer: GruvboxColors.bg2,
onSecondaryContainer: GruvboxColors.fg, onSecondaryContainer: GruvboxColors.fg,
// Tertiary — swipe-to-unread background // Tertiary — swipe-to-unread background
tertiary: GruvboxColors.aqua, tertiary: GruvboxColors.aqua,
onTertiary: GruvboxColors.bgHard, onTertiary: GruvboxColors.bgHard,
tertiaryContainer: GruvboxColors.bg1, tertiaryContainer: GruvboxColors.bg1,
onTertiaryContainer: GruvboxColors.fg, onTertiaryContainer: GruvboxColors.fg,
// Error // Error
error: GruvboxColors.brightRed, error: GruvboxColors.brightRed,
onError: GruvboxColors.bgHard, onError: GruvboxColors.bgHard,
errorContainer: GruvboxColors.red, errorContainer: GruvboxColors.red,
onErrorContainer: GruvboxColors.fg, onErrorContainer: GruvboxColors.fg,
// Inverse / scrim // Inverse / scrim
inverseSurface: GruvboxColors.fg, inverseSurface: GruvboxColors.fg,
onInverseSurface: GruvboxColors.bgHard, onInverseSurface: GruvboxColors.bgHard,
inversePrimary: GruvboxColors.orange, inversePrimary: GruvboxColors.orange,
scrim: GruvboxColors.bgHard, scrim: GruvboxColors.bgHard,
shadow: GruvboxColors.bgHard, shadow: GruvboxColors.bgHard,
); );
final ThemeData gruvboxDarkTheme = ThemeData( final ThemeData gruvboxDarkTheme = buildAppTheme(
colorScheme: _gruvboxColorScheme, colorScheme: _gruvboxColorScheme,
extensions: [ appColors: const AppColorExtension(
const AppColorExtension( success: GruvboxColors.brightGreen,
success: GruvboxColors.brightGreen, onSuccess: GruvboxColors.bgHard,
onSuccess: GruvboxColors.bgHard, warning: GruvboxColors.brightYellow,
warning: GruvboxColors.brightYellow, onWarning: GruvboxColors.bgHard,
onWarning: GruvboxColors.bgHard, info: GruvboxColors.brightBlue,
info: GruvboxColors.brightBlue, onInfo: GruvboxColors.bgHard,
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, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Status', 'Scanners',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_scannerRow( _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. // falls back to the hard-coded release date.
await tester.pump(); 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 // Assert the fallback prefix only, not the release date literal (it changes
// every release). // every release).
expect(find.textContaining('Released'), findsOneWidget); 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/settings/settings.dart'; import 'package:frontend/settings/settings.dart';
import 'package:frontend/utils/pref_utils.dart';
import '../helpers/backend_test_harness.dart'; import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart'; import '../helpers/pump_app.dart';
@@ -24,24 +25,26 @@ void main() {
expect(find.text('Catppuccin Mocha'), findsOneWidget); expect(find.text('Catppuccin Mocha'), findsOneWidget);
}); });
testWidgets('validates an empty base URL when testing the connection', testWidgets('validates an empty base URL when testing the connection', (
(tester) async { tester,
) async {
await pumpScreen(tester, const Settings()); await pumpScreen(tester, const Settings());
await tester.enterText(find.byType(TextFormField).first, ''); await tester.enterText(find.byType(TextFormField).first, '');
await tester.tap(find.widgetWithText(ElevatedButton, 'Test')); await tester.tap(find.widgetWithText(FilledButton, 'Test'));
await tester.pump(); await tester.pump();
expect(find.text('The URL cannot be empty'), findsOneWidget); expect(find.text('The URL cannot be empty'), findsOneWidget);
}); });
testWidgets('disables Save once the connection details are edited', testWidgets('disables Save once the connection details are edited', (
(tester) async { tester,
) async {
await pumpScreen(tester, const Settings()); await pumpScreen(tester, const Settings());
final saveButton = find.widgetWithText(ElevatedButton, 'Save'); final saveButton = find.widgetWithText(FilledButton, 'Save');
expect( expect(
tester.widget<ElevatedButton>(saveButton).onPressed, tester.widget<FilledButton>(saveButton).onPressed,
isNotNull, isNotNull,
reason: 'enabled for the unmodified, prefilled form', reason: 'enabled for the unmodified, prefilled form',
); );
@@ -53,19 +56,56 @@ void main() {
await tester.pump(); await tester.pump();
expect( expect(
tester.widget<ElevatedButton>(saveButton).onPressed, tester.widget<FilledButton>(saveButton).onPressed,
isNull, isNull,
reason: 'disabled until the new connection is tested', reason: 'disabled until the new connection is tested',
); );
}); });
testWidgets('saving the unmodified form shows a success message', testWidgets('saving the unmodified form shows a success message', (
(tester) async { tester,
) async {
await pumpScreen(tester, const Settings()); 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')); await pumpUntilFound(tester, find.text('Settings saved successfully'));
expect(find.text('Settings saved successfully'), findsOneWidget); 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', testWidgets('renders each scanner card with its resolved status', (
(tester) async { tester,
) async {
stubAllScanners(); stubAllScanners();
await pumpScreen(tester, const StatusScreen()); await pumpScreen(tester, const StatusScreen());
await pumpUntilFound(tester, find.text('Running')); 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('ARP Scanner'), findsOneWidget);
expect(find.text('mDNS Scanner'), findsOneWidget); expect(find.text('mDNS Scanner'), findsOneWidget);
expect(find.text('Running'), findsWidgets); expect(find.text('Running'), findsWidgets);