feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user