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