feat(mobile): UI-01 theme system, shared components and navigation

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:55:29 +08:00
parent 8f4ad11bd5
commit c94b9e5e58
27 changed files with 2067 additions and 555 deletions
@@ -0,0 +1,76 @@
/// 空态(UI-01):图标 + 标题 + 说明 + 可选操作按钮,居中布局。
///
/// 用于列表/工作区「暂无内容」的引导(相册为空、队列为空等);标题与
/// 说明由调用方传入(本地化),操作按钮为次强调(tonal,不与页面主
/// CTA 争夺层级)。与 ErrorState 的区别:空态是正常状态,无重试语义。
library;
import 'package:flutter/material.dart';
import '../tokens.dart';
/// 空态视图。
class EmptyState extends StatelessWidget {
const EmptyState({
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
super.key,
});
/// 主题图标(如 [Icons.photo_library_outlined])。
final IconData icon;
/// 主文案(如「暂无相册」)。
final String title;
/// 补充说明;null 不占位。
final String? subtitle;
/// 可选操作按钮文案;null 不显示按钮。
final String? actionLabel;
/// 操作回调(与 [actionLabel] 成对提供)。
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(height: kSpace12),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: kSpace4),
Text(
subtitle!,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
if (actionLabel != null) ...[
const SizedBox(height: kSpace16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
),
);
}
}
@@ -0,0 +1,63 @@
/// 错误态(UI-01):错误图标 + 说明 + 重试按钮(主强调)。
///
/// 用于加载失败等需要用户处理的场景。与 EmptyState 的区别:错误态有
/// 明确的重试动作(主按钮 + 刷新图标),文案为错误色语义;空态无重试。
/// 重试文案来自语言资源([AppLocalizations.retry])。
library;
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../tokens.dart';
/// 错误态视图。
class ErrorState extends StatelessWidget {
const ErrorState({
required this.message,
required this.onRetry,
this.icon,
super.key,
});
/// 错误说明(调用方本地化,如错误码映射文案)。
final String message;
/// 重试回调。
final VoidCallback onRetry;
/// 覆盖主题图标(默认 [Icons.cloud_off],加载失败语义)。
final IconData? icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final errorColor = theme.brightness == Brightness.light
? kColorError
: kColorErrorDark;
return Center(
child: Padding(
padding: const EdgeInsets.all(kSpace24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon ?? Icons.cloud_off, size: 48, color: errorColor),
const SizedBox(height: kSpace12),
Text(
message,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).retry),
),
],
),
),
);
}
}
@@ -0,0 +1,67 @@
/// 网络照片缩略图(UI-01 §4.1):固定宽高比预留 + 占位 + 失败替代图。
///
/// 布局稳定性优先:占位不是转圈,而是同一宽高比的中性色块(网格/卡片
/// 不因加载状态跳动);加载完成前显示占位,失败显示中性色块 + 小图标
/// (不显示破图)。[url] 为 null(无封面/已删除)时常驻占位。使用
/// Image.network + errorBuilder(项目未引入 cached_network_image,保持
/// 零新增依赖)。
library;
import 'package:flutter/material.dart';
/// 网络缩略图:`AspectRatio` 包裹,cover 裁切。
class PhotoThumbnail extends StatelessWidget {
const PhotoThumbnail({
required this.url,
required this.aspectRatio,
this.fit = BoxFit.cover,
super.key,
});
/// 图片 URL;null 显示常驻占位(无封面/无预览语义)。
final String? url;
/// 宽高比(相册封面 16:9、网格 1:1 等)——占位与图片共用,尺寸恒定。
final double aspectRatio;
/// 图片适配(默认 cover 裁切;大图查看用 contain)。
final BoxFit fit;
@override
Widget build(BuildContext context) {
if (url == null) {
return AspectRatio(
aspectRatio: aspectRatio,
child: _placeholder(context),
);
}
return AspectRatio(
aspectRatio: aspectRatio,
child: Image.network(
url!,
fit: fit,
// 帧 ready 前显示中性占位(不是 spinner):布局稳定优先。
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
wasSynchronouslyLoaded || frame != null
? child
: _placeholder(context),
errorBuilder: (_, _, _) =>
_placeholder(context, icon: Icons.broken_image_outlined),
),
);
}
/// 中性占位色块 + 小图标(加载中 / 无封面 / 加载失败共用)。
Widget _placeholder(
BuildContext context, {
IconData icon = Icons.image_outlined,
}) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(icon, size: 24, color: theme.colorScheme.onSurfaceVariant),
),
);
}
}
@@ -0,0 +1,37 @@
/// 分组标题(UI-01 §3.1:分组标题 17-18):标题 + 可选行尾动作。
///
/// 标题取主题 titleMedium(w600);行尾动作由调用方构建(如「查看全部」
/// TextButton)。不自带内外边距——由页面布局决定(避免为每个 SizedBox
/// 建一层封装)。
library;
import 'package:flutter/material.dart';
/// 分组标题行。
class SectionHeader extends StatelessWidget {
const SectionHeader({required this.title, this.trailing, super.key});
/// 分组标题(本地化文案)。
final String title;
/// 行尾动作(可选)。
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
);
}
}
@@ -0,0 +1,112 @@
/// 状态徽章(UI-01 §3.1):图标 + 文案 + 颜色三重表达,不只靠颜色。
///
/// 跨页面复用的语义变体(上传状态、公开/关闭等);文案由调用方传入
/// (本地化),图标按变体内置、可显式覆盖。两档尺寸:compact 高 24
/// (卡片角标行)、默认高 32(列表/详情行)。
library;
import 'package:flutter/material.dart';
import '../tokens.dart';
/// 徽章语义变体。
enum BadgeVariant {
/// 成功/完成事实(绿)。
success,
/// 等待中(琥珀)。
warning,
/// 需要处理的错误(红)。
error,
/// 中性/关闭/未启用(灰)。
neutral,
/// 信息提示(主色蓝)。
info,
}
/// 状态徽章:`图标 + 文案`,浅底色块(语义色 12% 透明度)+ 语义色文字。
class StateBadge extends StatelessWidget {
const StateBadge({
required this.variant,
required this.label,
this.icon,
this.compact = false,
super.key,
});
/// 语义变体(决定图标与颜色)。
final BadgeVariant variant;
/// 展示文案(调用方本地化)。
final String label;
/// 显式覆盖图标(默认按变体内置)。
final IconData? icon;
/// 紧凑档:高 24、字号 12;默认高 32、字号 13。
final bool compact;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final (defaultIcon, color) = _resolve(variant, theme.brightness);
return Container(
height: compact ? 24.0 : 32.0,
padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon ?? defaultIcon, size: compact ? 14 : 16, color: color),
// 图标-文字光学间距(非布局节奏,不进令牌)。
SizedBox(width: compact ? kSpace4 : 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
(compact
? theme.textTheme.labelSmall
: theme.textTheme.bodySmall)
?.copyWith(color: color, fontWeight: FontWeight.w600),
),
),
],
),
);
}
/// 变体 → (图标, 语义色);深色主题取提亮变体保证对比度。
static (IconData, Color) _resolve(
BadgeVariant variant,
Brightness brightness,
) => switch (variant) {
BadgeVariant.success =>
brightness == Brightness.light
? (Icons.check_circle, kColorSuccess)
: (Icons.check_circle, kColorSuccessDark),
BadgeVariant.warning =>
brightness == Brightness.light
? (Icons.hourglass_top, kColorWaiting)
: (Icons.hourglass_top, kColorWaitingDark),
BadgeVariant.error =>
brightness == Brightness.light
? (Icons.error_outline, kColorError)
: (Icons.error_outline, kColorErrorDark),
BadgeVariant.neutral =>
brightness == Brightness.light
? (Icons.remove_circle_outline, kColorTextSecondary)
: (Icons.remove_circle_outline, kColorTextSecondaryDark),
BadgeVariant.info =>
brightness == Brightness.light
? (Icons.info_outline, kColorInfo)
: (Icons.info_outline, kColorInfoDark),
};
}
+83
View File
@@ -0,0 +1,83 @@
/// UI-01 设计令牌(方案 §3.1/§3.2):色彩、间距、圆角、动效时长。
///
/// - 语义色(成功/等待/错误)不占用 Material ColorScheme 角色(secondary
/// 虽承载等待色,但组件层统一走令牌),浅色值为方案基准;深色场景用
/// `*Dark` 变体——深色表面上直接使用浅色基准会跌破对比度目标,组件按
/// [Brightness] 选择(见 StateBadge)。
/// - 间距 4/8/12/16/24/32;页面左右默认 [kSpace16]。
/// - 圆角:卡片 16 / 输入框与按钮 12 / 小标签 8,避免全部做成胶囊。
/// - 动效:轻量状态 [kMotionFast]、轮播/照片过渡 [kMotionCarousel];
/// 系统「减少动态效果」开启时 [motion] 统一归零(禁用非必要动效)。
library;
import 'package:flutter/material.dart';
// ── 语义色(浅色基准,方案 §3.1)──────────────────────────────────────────
/// 成功:已上传等完成事实(#16794B)。
const Color kColorSuccess = Color(0xFF16794B);
/// 等待:等待网络、等待调度(#996300)。
const Color kColorWaiting = Color(0xFF996300);
/// 错误:需要用户处理的失败(#B42318)。
const Color kColorError = Color(0xFFB42318);
/// 信息:中性提示(与主色一致,#1A73E8)。
const Color kColorInfo = Color(0xFF1A73E8);
/// 分隔线:轻分隔,不用粗边框(#E5EAF1)。
const Color kColorDivider = Color(0xFFE5EAF1);
/// 次文字:时间、地点、说明(#5E6878)。
const Color kColorTextSecondary = Color(0xFF5E6878);
// ── 语义色(深色变体:深色表面对应提亮值)──────────────────────────────
/// 成功(深色表面用)。
const Color kColorSuccessDark = Color(0xFF34D399);
/// 等待(深色表面用)。
const Color kColorWaitingDark = Color(0xFFFCD34D);
/// 错误(深色表面用)。
const Color kColorErrorDark = Color(0xFFF87171);
/// 信息(深色表面用,与深色主色一致)。
const Color kColorInfoDark = Color(0xFF8AB4F8);
/// 次文字(深色表面用)。
const Color kColorTextSecondaryDark = Color(0xFFA6B0C0);
// ── 间距(4/8/12/16/24/32)───────────────────────────────────────────────
const double kSpace4 = 4.0;
const double kSpace8 = 8.0;
const double kSpace12 = 12.0;
const double kSpace16 = 16.0;
const double kSpace24 = 24.0;
const double kSpace32 = 32.0;
// ── 圆角(卡片 16 / 输入框与按钮 12 / 小标签 8)────────────────────────
const double kRadiusCard = 16.0;
const double kRadiusInput = 12.0;
const double kRadiusBadge = 8.0;
// ── 动效时长(轻量 150-220ms / 轮播 250-350ms)────────────────────────
/// 轻量状态过渡(选中态、chip 切换等)。
const Duration kMotionFast = Duration(milliseconds: 180);
/// 轮播/照片过渡。
const Duration kMotionCarousel = Duration(milliseconds: 300);
/// 「减少动态效果」开启时的时长(等价禁用非必要动效)。
const Duration kMotionReduce = Duration.zero;
/// 当前上下文的动效时长:默认 [kMotionFast],`carousel: true` 取
/// [kMotionCarousel];系统「减少动态效果」开启时一律 [kMotionReduce]。
Duration motion(BuildContext context, {bool carousel = false}) {
if (MediaQuery.disableAnimationsOf(context)) return kMotionReduce;
return carousel ? kMotionCarousel : kMotionFast;
}