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

Frontend usability and aesthetics pass across web and mobile:

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 08:39:37 -04:00
co-authored by Claude Opus 4.8
parent 5f6f5ad679
commit 9367c359d7
20 changed files with 661 additions and 274 deletions
+18 -17
View File
@@ -5,10 +5,13 @@ import 'package:flutter/material.dart';
import '../model/notification.dart' as oott_model;
import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../widgets/empty_state.dart';
import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'notification_card.dart';
const _pageSize = 5;
@@ -190,6 +193,12 @@ class _NotificationsListState extends State<NotificationsList>
return false;
}
String _emptyMessage() => switch (_filter) {
_NotificationFilter.newOnly => 'No new notifications',
_NotificationFilter.oldOnly => 'No old notifications',
_NotificationFilter.all => 'No notifications yet',
};
@override
Widget build(BuildContext context) {
return Column(
@@ -229,9 +238,12 @@ class _NotificationsListState extends State<NotificationsList>
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.xs,
),
child: Wrap(
spacing: 8.0,
spacing: Insets.sm,
children: _NotificationFilter.values
.map(
(f) => ChoiceChip(
@@ -252,11 +264,7 @@ class _NotificationsListState extends State<NotificationsList>
List<Widget> _buildNotificationSlivers(BuildContext context) {
if (_isLoading) {
return [
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
];
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
}
if (_error != null) {
return [
@@ -273,16 +281,9 @@ class _NotificationsListState extends State<NotificationsList>
if (_items.isEmpty) {
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 12),
child: Center(
child: Text(
'No items found',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
child: EmptyState(
icon: Icons.notifications_off_outlined,
message: _emptyMessage(),
),
),
];