mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
5f6f5ad679
commit
9367c359d7
@@ -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: [
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'OOTT',
|
'OOTT',
|
||||||
style: GoogleFonts.barlowCondensed(
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
color: theme.colorScheme.onPrimary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 26,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest,
|
const SizedBox(width: Insets.md),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_destinations[selectedIndex].label,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: theme.colorScheme.surfaceContainerLowest,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 server’s 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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);
|
||||||
@@ -78,10 +79,9 @@ const ColorScheme _catppuccinMochaColorScheme = ColorScheme(
|
|||||||
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,
|
||||||
@@ -89,5 +89,4 @@ final ThemeData catppuccinMochaDarkTheme = ThemeData(
|
|||||||
info: CatppuccinMochaColors.blue,
|
info: CatppuccinMochaColors.blue,
|
||||||
onInfo: CatppuccinMochaColors.crust,
|
onInfo: CatppuccinMochaColors.crust,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
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);
|
||||||
@@ -75,10 +76,9 @@ const ColorScheme _gruvboxColorScheme = ColorScheme(
|
|||||||
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,
|
||||||
@@ -86,5 +86,4 @@ final ThemeData gruvboxDarkTheme = ThemeData(
|
|||||||
info: GruvboxColors.brightBlue,
|
info: GruvboxColors.brightBlue,
|
||||||
onInfo: GruvboxColors.bgHard,
|
onInfo: GruvboxColors.bgHard,
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user