feat(ui06): App line A0-A7 — nav pill removal, light segments, bare summary, token alignment to app-preview

- tokens:次文字 #69778E、soft/card-border/seg 色组、输入圆角 10(kRadiusField)
- theme:导航 indicatorColor 透明+显式选中蓝;卡片去阴影改 1px #EDF0F5 边框;输入边框同步
- 新组件 SegmentTabs(#EDF1F6 轨道/选中实蓝/light 浅蓝变体/null 无高亮)替换 M3 SegmentedButton
- 上传中心:摘要去白卡直排+主数字 32/40;completed 次级筛选无高亮语义保留
- 相册卡 scrim 30% 起 #0C1728E8;导航角标红底白字白边;登录主标题 27/700
- theme_test 三处旧规格断言随 UI06 值更新;397/397 全绿、analyze 0 issues
- 真机证据:MuMu + 测试服务器 API,登录/相册/详情/上传 4 页截图确认达标
- 登记偏差:语义色保持对比度合规深色版;视口矩阵随 G2;详见验收记录 §6

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 08:09:20 +08:00
parent 8dc0073597
commit 6074fc8ed3
13 changed files with 320 additions and 141 deletions
+23 -11
View File
@@ -31,7 +31,7 @@ abstract final class AppTheme {
static const Color _surface = Color(0xFFFFFFFF);
static const Color _onSurface = Color(0xFF172033);
static const Color _background = Color(0xFFF6F8FB);
static const Color _textSecondary = Color(0xFF5E6878);
static const Color _textSecondary = Color(0xFF69778E); // UI06: app-preview --muted
static const Color _divider = Color(0xFFE5EAF1);
static const Color _waiting = Color(0xFF996300);
static const Color _onWaiting = Color(0xFFFFFFFF);
@@ -141,14 +141,15 @@ abstract final class AppTheme {
),
),
// 卡片:白色表面(深色深表面)、圆角 16、轻阴影。
// 卡片:白色表面、圆角 16、细边框无阴影(app-preview .card 形态)。
cardTheme: CardThemeData(
color: colorScheme.surface,
surfaceTintColor: Colors.transparent,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: dark ? 0.4 : 0.08),
elevation: 0,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadiusCard),
side: BorderSide(color: dark ? _dividerDark : kColorCardBorder),
),
),
@@ -179,15 +180,26 @@ abstract final class AppTheme {
actionTextColor: colorScheme.inversePrimary,
),
// 底部导航:白色表面(深色深表面)+ 派生容器色指示器。
// 底部导航(app-preview .nav):白底 + 顶部细线 + **无胶囊指示器**;
// 选中 primary 蓝图标/文字,未选次文字色。
navigationBarTheme: NavigationBarThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
indicatorColor: colorScheme.secondaryContainer,
height: 80,
labelTextStyle: WidgetStatePropertyAll(
textTheme.labelMedium!.copyWith(
color: colorScheme.onSurface,
indicatorColor: Colors.transparent,
height: 68,
iconTheme: WidgetStateProperty.resolveWith(
(states) => IconThemeData(
size: 24,
color: states.contains(WidgetState.selected)
? colorScheme.primary
: (dark ? _textSecondaryDark : kColorTextSecondary),
),
),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => textTheme.labelMedium!.copyWith(
color: states.contains(WidgetState.selected)
? colorScheme.primary
: (dark ? _textSecondaryDark : kColorTextSecondary),
fontWeight: FontWeight.w500,
),
),
@@ -207,7 +219,7 @@ abstract final class AppTheme {
Color color, {
double width = 1,
}) => OutlineInputBorder(
borderRadius: BorderRadius.circular(kRadiusInput),
borderRadius: BorderRadius.circular(kRadiusField),
borderSide: BorderSide(color: color, width: width),
);
+3 -2
View File
@@ -192,11 +192,12 @@ class AlbumCard extends StatelessWidget {
final AlbumSummary album;
/// 底部渐变:透明 → black54,只压暗下半部(白字对比度)。
/// UI06:app-preview .scrim——30% 起渐变到 #0C1728E8(只压暗下部)。
static const LinearGradient _scrim = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.42, 1.0],
colors: [Color(0x00000000), Color(0x8A000000)],
stops: [0.30, 1.0],
colors: [Color(0x00000000), Color(0xE80C1728)],
);
@override
+12 -1
View File
@@ -88,7 +88,18 @@ class _UploadTabIcon extends StatelessWidget {
if (count <= 0) return Icon(icon);
return Semantics(
label: AppLocalizations.of(context).badgeUploading(count),
child: Badge.count(count: count, child: Icon(icon)),
// UI06:app-preview .badge——红底 #F15B64 白字 + 白边(与导航白底分隔)。
child: Badge.count(
count: count,
backgroundColor: const Color(0xFFF15B64),
textStyle: const TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w600,
),
smallSize: 16,
child: Icon(icon),
),
);
}
}
+6 -1
View File
@@ -119,7 +119,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
const SizedBox(height: kSpace16),
Text(
l10n.loginTitle,
style: theme.textTheme.titleLarge,
// UI06:app-preview .login-content h1——27px/700。
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 27,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: kSpace8),
Text(
+96 -112
View File
@@ -29,6 +29,7 @@ import '../features/uploads/queue_controller.dart';
import '../features/uploads/queue_l10n.dart';
import '../features/uploads/task_state.dart';
import '../features/uploads/task_store.dart';
import '../ui/components/segment_tabs.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/empty_state.dart';
import '../ui/components/error_state.dart';
@@ -102,63 +103,50 @@ class _SummaryCard extends ConsumerWidget {
].join(' · ');
final light = theme.brightness == Brightness.light;
return Card(
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
child: Padding(
padding: const EdgeInsets.all(kSpace16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.summarize_outlined,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
scope,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
),
],
),
const SizedBox(height: kSpace8),
// 已完成突出(§4 页面 14:摘要突出已完成数)。
_SummaryCount(
value: completedSummaryOf(counts),
label: l10n.uploadsCompleted,
color: light ? kColorSuccess : kColorSuccessDark,
emphasized: true,
),
const SizedBox(height: kSpace4),
Wrap(
spacing: kSpace16,
runSpacing: kSpace4,
children: [
_SummaryCount(
value: uploadingSummaryOf(counts),
label: l10n.uploadsUploading,
color: theme.colorScheme.primary,
),
_SummaryCount(
value: waitingSummaryOf(counts),
label: l10n.uploadsWaiting,
color: light ? kColorWaiting : kColorWaitingDark,
),
_SummaryCount(
value: failedCountOf(counts),
label: l10n.queueSummaryNeedAttention,
color: light ? kColorError : kColorErrorDark,
),
],
),
],
),
// UI06:摘要直接排页背景(无白卡包裹);范围行 13px、主数字 32px、
// 副行 13px 中性(app-preview 示例 14)。
return Padding(
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace16, kSpace16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
scope,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: kSpace8),
// 已完成突出(§4 页面 14:摘要突出已完成数)。
_SummaryCount(
value: completedSummaryOf(counts),
label: l10n.uploadsCompleted,
color: light ? kColorSuccess : kColorSuccessDark,
emphasized: true,
),
const SizedBox(height: kSpace4),
Wrap(
spacing: kSpace16,
runSpacing: kSpace4,
children: [
_SummaryCount(
value: uploadingSummaryOf(counts),
label: l10n.uploadsUploading,
color: theme.colorScheme.primary,
),
_SummaryCount(
value: waitingSummaryOf(counts),
label: l10n.uploadsWaiting,
color: light ? kColorWaiting : kColorWaitingDark,
),
_SummaryCount(
value: failedCountOf(counts),
label: l10n.queueSummaryNeedAttention,
color: light ? kColorError : kColorErrorDark,
),
],
),
],
),
);
}
@@ -189,12 +177,19 @@ class _SummaryCount extends StatelessWidget {
children: [
Text(
'$value',
style: (emphasized
? theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
)
: theme.textTheme.titleMedium)
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
style:
(emphasized
// UI06:主数字 32/40(app-preview 示例 14 .metric)
? theme.textTheme.titleLarge?.copyWith(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
)
: theme.textTheme.titleMedium)
?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(width: kSpace4),
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
@@ -232,34 +227,20 @@ class _PrimaryFilterBar extends ConsumerWidget {
child: Row(
children: [
Expanded(
child: SegmentedButton<QueueStateFilter>(
// 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。
selected: filter.state == QueueStateFilter.completed
? const <QueueStateFilter>{}
: <QueueStateFilter>{filter.state},
emptySelectionAllowed: true,
showSelectedIcon: false,
style: SegmentedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
padding: const EdgeInsets.symmetric(horizontal: kSpace12),
),
// UI06:轻量分段(app-preview .tabs——#EDF1F6 轨道/选中实蓝,
// 替换 M3 SegmentedButton 黑边胶囊)。
// 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。
child: SegmentTabs<QueueStateFilter>(
value: filter.state == QueueStateFilter.completed
? null
: filter.state,
segments: [
ButtonSegment(
value: QueueStateFilter.all,
label: Text(l10n.uploadsAll),
),
ButtonSegment(
value: QueueStateFilter.inProgress,
label: Text(l10n.uploadsActive),
),
ButtonSegment(
value: QueueStateFilter.needsAttention,
label: Text(l10n.uploadsNeedsAttention),
),
(QueueStateFilter.all, l10n.uploadsAll),
(QueueStateFilter.inProgress, l10n.uploadsActive),
(QueueStateFilter.needsAttention, l10n.uploadsNeedsAttention),
],
onSelectionChanged: (selection) => ref
.read(queueFilterProvider.notifier)
.setStateGroup(selection.first),
onChanged: (value) =>
ref.read(queueFilterProvider.notifier).setStateGroup(value),
),
),
IconButton(
@@ -295,8 +276,9 @@ class _FilterSheet extends ConsumerWidget {
void close() => Navigator.of(context).pop();
// 相册组值:'' = 全部相册(选中相册不在列表时回退全部)。
final albumValue =
albums.any((a) => a.id == filter.albumId) ? (filter.albumId ?? '') : '';
final albumValue = albums.any((a) => a.id == filter.albumId)
? (filter.albumId ?? '')
: '';
return SafeArea(
child: ListView(
shrinkWrap: true,
@@ -369,7 +351,9 @@ class _FilterSheet extends ConsumerWidget {
),
title: Text(l10n.queueStateCompleted),
onTap: () {
ref.read(queueFilterProvider.notifier).setStateGroup(
ref
.read(queueFilterProvider.notifier)
.setStateGroup(
filter.state == QueueStateFilter.completed
? QueueStateFilter.all
: QueueStateFilter.completed,
@@ -488,9 +472,7 @@ class _TaskTile extends ConsumerWidget {
return Padding(
// 行键(task.id)供 widget 测试行级锚定(状态徽章/动作图标定位)。
key: ValueKey('queue-task-${task.id}'),
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace8, kSpace16, kSpace8,
),
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -504,8 +486,9 @@ class _TaskTile extends ConsumerWidget {
task.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w600),
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
@@ -563,8 +546,8 @@ class _TaskTile extends ConsumerWidget {
task.state == UploadTaskState.uploadedConfirming)
_ProgressLine(
taskId: task.id,
confirming: task.state ==
UploadTaskState.uploadedConfirming,
confirming:
task.state == UploadTaskState.uploadedConfirming,
),
],
),
@@ -577,18 +560,17 @@ class _TaskTile extends ConsumerWidget {
/// 徽章语义变体(§4 页面 14:进行=主色、等待=琥珀、失败=红、完成=绿、
/// 取消=灰)。
static BadgeVariant _badgeVariant(UploadTaskState state) =>
switch (state) {
UploadTaskState.preparing ||
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => BadgeVariant.info,
UploadTaskState.queued ||
UploadTaskState.waitingNetwork => BadgeVariant.warning,
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => BadgeVariant.error,
UploadTaskState.completed => BadgeVariant.success,
UploadTaskState.cancelled => BadgeVariant.neutral,
};
static BadgeVariant _badgeVariant(UploadTaskState state) => switch (state) {
UploadTaskState.preparing ||
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => BadgeVariant.info,
UploadTaskState.queued ||
UploadTaskState.waitingNetwork => BadgeVariant.warning,
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => BadgeVariant.error,
UploadTaskState.completed => BadgeVariant.success,
UploadTaskState.cancelled => BadgeVariant.neutral,
};
/// 徽章文案 = §4.4 界面含义列(确认中不显示「已公开」等超集语义)。
static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) =>
@@ -911,7 +893,9 @@ class _BatchRetryBar extends ConsumerWidget {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
final tasks = await ref
.read(taskStoreProvider)
.listByAccount(
account,
states: const {UploadTaskState.retryableFailed},
);
@@ -0,0 +1,118 @@
/// 轻量分段选择器(UI06 对齐 app-preview .tabs)。
///
/// 形态:#EDF1F6 轨道、padding 4、整体圆角 9;选中实底、未选次文字色。
/// 两种选中风格:
/// - [SegmentTabsStyle.solid]:主蓝实底白字(上传中心筛选等状态筛选);
/// - [SegmentTabsStyle.light]:#DBEAFF 底 + #145BB9 字(Details/Access 类
/// 内容页签——与状态筛选区分,见 app-preview .tabs.light)。
///
/// 替换默认 [SegmentedButton](M3 黑边胶囊形态与示例不符)。
library;
import 'package:flutter/material.dart';
import '../tokens.dart';
enum SegmentTabsStyle { solid, light }
class SegmentTabs<T> extends StatelessWidget {
const SegmentTabs({
required this.value,
required this.onChanged,
required this.segments,
this.style = SegmentTabsStyle.solid,
super.key,
});
/// 当前值;null = 无高亮(次级筛选接管时的中间态)。
final T? value;
final ValueChanged<T> onChanged;
/// 选项:值 + 本地化标签。
final List<(T, String)> segments;
final SegmentTabsStyle style;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final light = style == SegmentTabsStyle.light;
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: kColorSegTrack,
borderRadius: BorderRadius.circular(9),
),
child: Row(
children: [
for (final (v, label) in segments)
Expanded(
child: _TabButton(
label: label,
selected: v == value,
light: light,
primary: theme.colorScheme.primary,
onTap: () => onChanged(v),
),
),
],
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
required this.label,
required this.selected,
required this.light,
required this.primary,
required this.onTap,
});
final String label;
final bool selected;
final bool light;
final Color primary;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final Color bg;
final Color fg;
if (selected) {
bg = light ? kColorSegLightActive : primary;
fg = light ? kColorSegLightActiveText : Colors.white;
} else {
bg = Colors.transparent;
fg = kColorTextSecondary;
}
return Semantics(
button: true,
selected: selected,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(7),
child: Container(
alignment: Alignment.center,
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(7),
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: fg,
),
),
),
),
);
}
}
+20 -3
View File
@@ -29,8 +29,24 @@ const Color kColorInfo = Color(0xFF1A73E8);
/// 分隔线:轻分隔,不用粗边框(#E5EAF1)。
const Color kColorDivider = Color(0xFFE5EAF1);
/// 次文字:时间、地点、说明(#5E6878)。
const Color kColorTextSecondary = Color(0xFF5E6878);
/// 次文字:时间、地点、说明(app-preview --muted #69778E)。
const Color kColorTextSecondary = Color(0xFF69778E);
/// 浅蓝软底(app-preview --soft #EDF4FF):信息提示、tag 底、选中页签。
const Color kColorSoft = Color(0xFFEDF4FF);
/// 卡片细边框(app-preview .card/.group border #EDF0F5;卡为边框形态非阴影)。
const Color kColorCardBorder = Color(0xFFEDF0F5);
/// 分段轨道底(app-preview .tabs #EDF1F6)。
const Color kColorSegTrack = Color(0xFFEDF1F6);
/// 输入框默认边框(app-preview .field input #DBE3EF)。
const Color kColorFieldBorder = Color(0xFFDBE3EF);
/// 浅蓝选中分段(app-preview .tabs.light active:#DBEAFF 底 + #145BB9 字)。
const Color kColorSegLightActive = Color(0xFFDBEAFF);
const Color kColorSegLightActiveText = Color(0xFF145BB9);
// ── 语义色(深色变体:深色表面对应提亮值)──────────────────────────────
@@ -64,10 +80,11 @@ const double kSpace16 = 16.0;
const double kSpace24 = 24.0;
const double kSpace32 = 32.0;
// ── 圆角(卡片 16 / 输入框与按钮 12 / 小标签 8)────────────────────────
// ── 圆角(卡片 16 / 按钮 12 / 输入框 10 / 小标签 8)──────────────────────
const double kRadiusCard = 16.0;
const double kRadiusInput = 12.0;
const double kRadiusField = 10.0;
const double kRadiusBadge = 8.0;
// ── 动效时长(轻量 150-220ms / 轮播 250-350ms)────────────────────────