feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
This commit is contained in:
commit
f61b7bf90b
120 files changed
+15229
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_keys.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// `uni.showToast({ title, icon })` 的等价实现。
|
||||
/// 不依赖调用方的 BuildContext,可在任意异步回调里安全使用。
|
||||
void showAppToast(String title, {bool success = false}) {
|
||||
final messenger = AppKeys.scaffoldMessenger.currentState;
|
||||
if (messenger == null) return;
|
||||
messenger
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (success) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.tertiaryFixed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.inverseSurface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(AppSpacing.md),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
duration: Duration(milliseconds: success ? 1500 : 2200),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `uni.showModal({ title, content })` 的等价实现,返回用户是否点了确认。
|
||||
Future<bool> showAppConfirm({
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = '确定',
|
||||
String cancelText = '取消',
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
final navigator = AppKeys.rootNavigator.currentState;
|
||||
if (navigator == null) return false;
|
||||
final result = await showDialog<bool>(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
title: Text(title, style: AppTypography.titleSm),
|
||||
content: Text(
|
||||
content,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: destructive
|
||||
? AppColors.statusError
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 底部悬浮胶囊导航,对应 `components/XBottomNav.vue`。
|
||||
///
|
||||
/// 距屏幕底部 12px(再叠加安全区),高 56,四周内边距 5,
|
||||
/// 四个 tab 等宽均分;激活项铺满所在分格。
|
||||
class XBottomNav extends StatelessWidget {
|
||||
const XBottomNav({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String current;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
static const items = <({String key, String label, String icon})>[
|
||||
(key: 'workbench', label: '工作台', icon: 'dashboard'),
|
||||
(key: 'customer', label: '客户', icon: 'groups'),
|
||||
(key: 'project', label: '项目', icon: 'calendar'),
|
||||
(key: 'mine', label: '我的', icon: 'person'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12 + bottomInset,
|
||||
),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
boxShadow: AppShadows.bottomNav,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (item.key == current) return;
|
||||
onChanged(item.key);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: item.key == current
|
||||
? AppColors.accentSubtle
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
XIcon(
|
||||
item.icon,
|
||||
size: 22,
|
||||
color: item.key == current
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.label,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: item.key == current
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
|
||||
/// 全局 `.card` 样式:白底 + 1px 描边 + 圆角 + 阴影。
|
||||
class XCard extends StatelessWidget {
|
||||
const XCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.radius,
|
||||
this.clip = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? radius;
|
||||
final bool clip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(radius ?? AppRadius.lg);
|
||||
final content = Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: r,
|
||||
boxShadow: AppShadows.card,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: margin ?? EdgeInsets.zero,
|
||||
child: onTap == null
|
||||
? (clip ? ClipRRect(borderRadius: r, child: content) : content)
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: r,
|
||||
child: clip
|
||||
? ClipRRect(borderRadius: r, child: content)
|
||||
: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// 全局 `.chip` 样式:胶囊标签,2px 8px 内边距,12/14 粗体。
|
||||
class XChip extends StatelessWidget {
|
||||
const XChip(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.tone = ChipTone.muted,
|
||||
this.leading,
|
||||
this.foreground,
|
||||
this.background,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final ChipTone tone;
|
||||
|
||||
/// 前置装饰(如状态圆点)
|
||||
final Widget? leading;
|
||||
final Color? foreground;
|
||||
final Color? background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? tone.background,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 4)],
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: foreground ?? tone.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可点选的筛选 chip(客户/项目列表顶部的过滤器)。
|
||||
class XFilterChip extends StatelessWidget {
|
||||
const XFilterChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.background = AppColors.surface,
|
||||
this.selectedBackground = AppColors.primaryContainer,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final Color background;
|
||||
final Color selectedBackground;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? selectedBackground : background,
|
||||
border: Border.all(
|
||||
color: selected ? selectedBackground : AppColors.borderRule,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: selected ? const Color(0xFFFEFCFF) : AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 页面骨架:固定顶栏 + 可滚动内容区。
|
||||
///
|
||||
/// 对应 uni-app 版「根容器铺满且不滚动,滚动只发生在内容区 scroll-view」的固定高度布局,
|
||||
/// 保证顶栏永远不动 —— 接近原生 App 的体验。
|
||||
class XPageShell extends StatelessWidget {
|
||||
const XPageShell({
|
||||
super.key,
|
||||
required this.topBar,
|
||||
required this.child,
|
||||
this.backgroundColor = AppColors.bgPage,
|
||||
});
|
||||
|
||||
final Widget topBar;
|
||||
final Widget child;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
body: Column(
|
||||
children: [topBar, Expanded(child: child)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索框,对应各页面 `.search-bar`。
|
||||
class XSearchField extends StatelessWidget {
|
||||
const XSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
this.onChanged,
|
||||
this.iconSize = 20,
|
||||
this.height = 44,
|
||||
this.radius = 12,
|
||||
this.background = AppColors.surface,
|
||||
this.iconColor = AppColors.textMuted,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final double iconSize;
|
||||
final double height;
|
||||
final double radius;
|
||||
final Color background;
|
||||
final Color iconColor;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? background : background,
|
||||
border: filled ? null : Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
XIcon('search', size: iconSize, color: iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态 / 加载中占位,对应 `.center` / `.empty-block`。
|
||||
class XPlaceholder extends StatelessWidget {
|
||||
const XPlaceholder({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.dashed = false,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 60),
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool dashed;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
);
|
||||
|
||||
if (!dashed) {
|
||||
return Padding(padding: padding, child: Center(child: label));
|
||||
}
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 实心主色按钮,对应 `.save-btn` / `.login-btn` 等胶囊按钮。
|
||||
class XPrimaryButton extends StatelessWidget {
|
||||
const XPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.loading = false,
|
||||
this.height = 48,
|
||||
this.margin,
|
||||
this.background = AppColors.primary,
|
||||
this.foreground = AppColors.onPrimary,
|
||||
this.fontSize = 16,
|
||||
this.pill = true,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final double height;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final double fontSize;
|
||||
final bool pill;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null || loading;
|
||||
|
||||
return Padding(
|
||||
padding: margin ?? EdgeInsets.zero,
|
||||
child: Opacity(
|
||||
opacity: disabled && loading ? 0.85 : 1,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(pill ? 9999 : AppRadius.lg),
|
||||
child: InkWell(
|
||||
onTap: disabled ? null : onPressed,
|
||||
borderRadius: BorderRadius.circular(pill ? 9999 : AppRadius.lg),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
pill ? 9999 : AppRadius.lg,
|
||||
),
|
||||
border: borderColor == null
|
||||
? null
|
||||
: Border.all(color: borderColor!),
|
||||
),
|
||||
child: loading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(foreground),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组小标题,对应 `.section-title`。
|
||||
class XSectionTitle extends StatelessWidget {
|
||||
const XSectionTitle(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.icon,
|
||||
this.iconColor = AppColors.primary,
|
||||
this.fontSize = 17,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final String? icon;
|
||||
final Color iconColor;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (icon == null) {
|
||||
return Text(
|
||||
text,
|
||||
style: AppTypography.titleSm.copyWith(fontSize: fontSize),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
XIcon(icon!, size: fontSize, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.titleSm.copyWith(fontSize: fontSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
/// 图标组件,逐像素对应 uni-app 版 `components/XIcon.vue` 的 SVG 路径表。
|
||||
///
|
||||
/// 源实现把 SVG 拼成 data URI 交给背景图渲染(为绕过 uni-app 端 svg 命名空间问题),
|
||||
/// Flutter 端直接用 [SvgPicture.string] 渲染同一份路径数据,视觉完全一致。
|
||||
class XIcon extends StatelessWidget {
|
||||
const XIcon(
|
||||
this.name, {
|
||||
super.key,
|
||||
this.size = 20,
|
||||
this.color = AppColors.textMuted,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
/// 形状与 XIcon.vue 的 `ICONS` 一一对应,未做任何视觉改动。
|
||||
/// `currentColor` 在生成 SVG 时替换为 [color] 的实际色值。
|
||||
static const Map<String, String> paths = {
|
||||
'dashboard':
|
||||
'<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>',
|
||||
'groups':
|
||||
'<circle cx="9" cy="8.5" r="3.5"/><circle cx="17" cy="9" r="2.8"/><path d="M9 13.5c-3 0-5.5 2-5.5 4.5v1h11V18c0-2.5-2.5-4.5-5.5-4.5z"/><path d="M16.7 13.6c2.4.6 3.8 2.3 3.8 4.4v1h-3.2"/>',
|
||||
'calendar':
|
||||
'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9.5h18M8 3v4M16 3v4"/>',
|
||||
'person': '<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.5-6.5 8-6.5s8 2.5 8 6.5v1H4z"/>',
|
||||
'bolt': '<path d="M13 2L4.5 13.5H11l-2 8.5L19 10h-6.5z"/>',
|
||||
'search':
|
||||
'<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M15.5 15.5L21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
'add': '<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'location':
|
||||
'<path d="M12 2C8.1 2 5 5.1 5 9c0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z"/><circle cx="12" cy="9" r="2.5"/>',
|
||||
'notification':
|
||||
'<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zM18 16v-5.5c0-3.1-1.6-5.6-4.5-6.3V3.5a1.5 1.5 0 0 0-3 0v.7C7.6 4.9 6 7.4 6 10.5V16l-2 2v1h16v-1l-2-2z"/>',
|
||||
'back':
|
||||
'<path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'forward':
|
||||
'<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'more': '<circle cx="6" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="18" cy="12" r="1.6"/>',
|
||||
'domain':
|
||||
'<rect x="4" y="4" width="16" height="16" rx="1.5"/><path d="M9 4v6h6V4M4 10h16M4 15h5M15 15h5M9 20v-5"/>',
|
||||
'clock':
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'warning': '<path d="M12 3L2 20h20zM12 9v5M12 17.5v.1"/>',
|
||||
'camera':
|
||||
'<rect x="3" y="6" width="18" height="14" rx="2"/><path d="M8 6l1.5-2h5L16 6"/><circle cx="12" cy="13" r="3.5"/>',
|
||||
'photo':
|
||||
'<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.6"/><path d="M3 17l5-5 4 4 3-3 6 6"/>',
|
||||
'send': '<path d="M3 11l18-7-7 18-3-8z"/>',
|
||||
'check':
|
||||
'<path d="M4 12.5l5 5L20 6.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'construction':
|
||||
'<path d="M14 3l7 7-3 3-2-2-8.5 8.5a2 2 0 0 1-2.8-2.8L13 8.5 11 6.5 13 4z"/>',
|
||||
'verified':
|
||||
'<path d="M12 2l2.5 2.5 3.5-.5 1 3.4 3 2-1 3.6 1 3.6-3 2-1 3.4-3.5-.5L12 22l-3.5-1-3.5.5-.5-3-3-2 1-4-.5-3H3l3-2 1-3.4 3.5.5z"/><path d="M4 12l5 5L20 6.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'edit': '<path d="M16.5 3a2 2 0 0 1 2.8 0l1.7 1.7a2 2 0 0 1 0 2.8L8 20.5 3 22l1.5-5z"/>',
|
||||
'delete': '<path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6"/>',
|
||||
'call':
|
||||
'<path d="M6.6 3c.6 0 1.2.4 1.4 1l1 3c.2.5.1 1.1-.3 1.5L7 9.6c1 1.9 2.5 3.4 4.4 4.4l1.1-1.7c.4-.4 1-.5 1.5-.3l3 1c.6.2 1 .8 1 1.4V18c0 1-1 2-2 2C8 20 4 16 4 5c0-1 1-2 2-2z"/>',
|
||||
'schedule': '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||||
'filter': '<path d="M4 5h16l-7 8v5l-2 1v-6z"/>',
|
||||
'chart': '<path d="M4 20V4M4 20h16"/><path d="M8 16v-5M13 16V8M18 16v-2"/>',
|
||||
'check-circle':
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'business':
|
||||
'<rect x="3" y="7" width="18" height="13" rx="1.5"/><path d="M7 7V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2M3 12h18M7 12v5M12 12v5M17 12v5"/>',
|
||||
'category': '<path d="M5 3h6v6H5zM13 3h6v10h-6zM5 11h6v10H5zM13 15h6v6h-6z"/>',
|
||||
'person-add':
|
||||
'<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19.5c0-3 2.5-4.8 5.5-4.8s5.5 1.8 5.5 4.8v.5h-11z"/><path d="M18 8v6M15 11h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'trend-up':
|
||||
'<path d="M4 17l5-5 3 3 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M13 9h5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'close':
|
||||
'<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
};
|
||||
|
||||
static final Map<String, String> _svgCache = {};
|
||||
|
||||
static String _hex(Color color) =>
|
||||
'#${(color.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
|
||||
|
||||
/// 生成 (图标, 颜色) 组合对应的 SVG 字符串,带缓存避免重复拼接。
|
||||
static String svgFor(String name, Color color) {
|
||||
final key = '$name|${color.toARGB32()}';
|
||||
return _svgCache.putIfAbsent(key, () {
|
||||
// 兼容旧调用:borg 与 bolt 是同一个闪电图标
|
||||
final body = (paths[name] ?? paths['close']!).replaceAll(
|
||||
'currentColor',
|
||||
_hex(color),
|
||||
);
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="${_hex(color)}">$body</svg>';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: SvgPicture.string(
|
||||
svgFor(name, color),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 固定顶栏。对应各页面 `.topbar`:高 56 + 状态栏内边距,白底 + 1px 下边框。
|
||||
///
|
||||
/// 两种形态:
|
||||
/// - [XTopBar.brand] 一级 Tab 顶栏:左侧闪电图标 + 品牌/模块名(左对齐),右侧通知图标
|
||||
/// - [XTopBar.nav] 二级页顶栏:左侧返回,中间标题,右侧操作位
|
||||
class XTopBar extends StatelessWidget {
|
||||
const XTopBar._({
|
||||
required this.title,
|
||||
required this.leading,
|
||||
required this.trailing,
|
||||
required this.titleStyle,
|
||||
required this.titleAlign,
|
||||
});
|
||||
|
||||
/// 一级 Tab 顶栏。
|
||||
factory XTopBar.brand({
|
||||
Key? key,
|
||||
required String title,
|
||||
Color iconColor = AppColors.primary,
|
||||
String trailingIcon = 'notification',
|
||||
Color trailingColor = AppColors.onSurfaceVariant,
|
||||
VoidCallback? onTrailingTap,
|
||||
}) {
|
||||
return XTopBar._(
|
||||
title: title,
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
XIcon('bolt', size: 24, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.headlineMdMobile.copyWith(
|
||||
color: iconColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: GestureDetector(
|
||||
onTap: onTrailingTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: XIcon(trailingIcon, size: 22, color: trailingColor),
|
||||
),
|
||||
titleStyle: AppTypography.headlineMdMobile,
|
||||
titleAlign: TextAlign.left,
|
||||
);
|
||||
}
|
||||
|
||||
/// 二级页顶栏:返回 + 居中标题 + 右侧操作位。
|
||||
factory XTopBar.nav({
|
||||
Key? key,
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return XTopBar._(
|
||||
title: title,
|
||||
leading: GestureDetector(
|
||||
onTap: onBack,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const XIcon('back', size: 22, color: AppColors.primary),
|
||||
Text(
|
||||
'返回',
|
||||
style: AppTypography.labelBold.copyWith(color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: trailing ?? const SizedBox.shrink(),
|
||||
titleStyle: AppTypography.headlineMdMobile,
|
||||
titleAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
final String title;
|
||||
final Widget leading;
|
||||
final Widget trailing;
|
||||
final TextStyle titleStyle;
|
||||
final TextAlign titleAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
final centered = titleAlign == TextAlign.center;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: topInset),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(bottom: BorderSide(color: AppColors.borderRule)),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: centered
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: leading,
|
||||
),
|
||||
),
|
||||
Text(title, style: titleStyle),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
leading,
|
||||
const Spacer(),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: trailing,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user