feat: 完成智能电网电工端 Flutter 应用初始化

创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
andyesfly committed 2026-10-06 11:54:10 +08:00
commit 56830e94ec
108 files changed
+9272

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
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.success,
),
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.showLoading({ title })` 的等价实现,返回关闭函数。
VoidCallback showAppLoading([String title = '加载中...']) {
final navigator = AppKeys.rootNavigator.currentState;
if (navigator == null) return () {};
var closed = false;
showDialog<void>(
context: navigator.context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
decoration: BoxDecoration(
color: const Color(0xCC000000),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
const SizedBox(height: 12),
Text(
title,
style: AppTypography.bodyMd.copyWith(color: Colors.white),
),
],
),
),
),
),
);
return () {
if (closed) return;
closed = true;
final current = AppKeys.rootNavigator.currentState;
if (current != null && current.canPop()) current.pop();
};
}
/// `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.surfaceContainerLowest,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl),
),
title: Text(
title,
textAlign: TextAlign.center,
style: AppTypography.bodyLg.copyWith(fontWeight: FontWeight.w700),
),
content: Text(
content,
textAlign: TextAlign.center,
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
),
actionsAlignment: MainAxisAlignment.spaceEvenly,
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(
cancelText,
style: AppTypography.bodyLg.copyWith(color: AppColors.textMuted),
),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(
confirmText,
style: AppTypography.bodyLg.copyWith(
color: destructive ? AppColors.tertiaryContainer : AppColors.primary,
),
),
),
],
),
);
return result ?? false;
}
+85
View File
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import 'x_icon.dart';
/// 底部导航,对应 `components/CustomTabBar.vue`。
///
/// 高 64,毛玻璃底色 rgba(250,249,254,0.95),上边框 + 轻微上投影,
/// 三个 Tab 均分,激活态整体变品牌蓝。底部安全区一并覆盖。
class XBottomNav extends StatelessWidget {
const XBottomNav({super.key, required this.current, required this.onChanged});
/// `order` | `earnings` | `profile`
final String current;
final ValueChanged<String> onChanged;
static const items = <({String key, String label, String icon})>[
(key: 'order', label: '订单', icon: 'dingdan'),
(key: 'earnings', label: '收益', icon: 'shouyi'),
(key: 'profile', label: '我的', icon: 'wode'),
];
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Container(
padding: EdgeInsets.only(bottom: bottomInset),
decoration: const BoxDecoration(
color: AppColors.navGlass,
border: Border(top: BorderSide(color: AppColors.borderSoft)),
boxShadow: AppShadows.topBar,
),
child: SizedBox(
height: 64,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (final item in items)
GestureDetector(
onTap: () {
if (item.key == current) return;
onChanged(item.key);
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
XIcon(
item.icon,
size: 24,
color: item.key == current
? AppColors.primary
: AppColors.textMuted,
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: item.key == current
? AppColors.primary
: AppColors.textMuted,
),
),
],
),
),
),
],
),
),
),
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
/// 各页面 `.ios-card` 的等价实现:白底 + 浅描边 + 圆角 + 极淡阴影。
class XCard extends StatelessWidget {
const XCard({
super.key,
required this.child,
this.padding,
this.margin,
this.radius = AppRadius.xl,
this.background = AppColors.surfaceContainerLowest,
this.borderColor = AppColors.borderSoft,
this.shadows = AppShadows.card,
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double radius;
final Color background;
final Color borderColor;
/// 订单卡片用 [AppShadows.orderCard](0 2px 8px),其余用 [AppShadows.card]。
final List<BoxShadow> shadows;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final r = BorderRadius.circular(radius);
final content = Container(
padding: padding,
decoration: BoxDecoration(
color: background,
border: Border.all(color: borderColor),
borderRadius: r,
boxShadow: shadows,
),
child: child,
);
return Padding(
padding: margin ?? EdgeInsets.zero,
child: onTap == null
? content
: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: r,
child: content,
),
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
/// 订单类型标签。对应源里的 `.order-type` / `.job-type`:
/// - `urgent` 紧急:浅红底 + 深红字
/// - `standard` 标准 / `regular` 常规:浅灰底 + 深灰字
///
/// 列表页字号 10,详情页 13,用 [compact] 区分。
class XTypeChip extends StatelessWidget {
const XTypeChip({
super.key,
required this.type,
required this.text,
this.compact = true,
});
/// `urgent` | `standard` | `regular`
final String type;
final String text;
final bool compact;
@override
Widget build(BuildContext context) {
final urgent = type == 'urgent';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: urgent ? AppColors.errorContainer : AppColors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: TextStyle(
fontSize: compact ? 10 : 13,
fontWeight: FontWeight.w600,
color: urgent
? (compact ? AppColors.onErrorContainer : AppColors.error)
: AppColors.onSurfaceVariant,
),
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_typography.dart';
/// 页面骨架:固定顶栏 + 内容区。对应源「根容器固定、滚动只发生在内容区」的布局。
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 Container(
color: backgroundColor,
child: Column(
children: [topBar, Expanded(child: child)],
),
);
}
}
/// 页面大标题 + 副标题,对应 `.page-header` / `.section-title`(28px/w700)。
class XPageTitle extends StatelessWidget {
const XPageTitle({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.navLargeTitle.copyWith(
color: AppColors.onSurface,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurfaceVariant,
),
),
],
],
);
}
}
/// 空态 / 加载中占位。
class XPlaceholder extends StatelessWidget {
const XPlaceholder({
super.key,
required this.text,
this.padding = const EdgeInsets.symmetric(vertical: 60),
});
final String text;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Center(
child: Text(
text,
textAlign: TextAlign.center,
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
),
),
);
}
}
/// 实心主按钮,对应 `.login-btn` / `.start-btn` 等。
class XPrimaryButton extends StatelessWidget {
const XPrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.leading,
this.loading = false,
this.height = 48,
this.radius = AppRadius.lg,
this.background = AppColors.primary,
this.foreground = AppColors.onPrimary,
this.fontSize = 17,
this.shadows,
});
final String label;
final VoidCallback? onPressed;
final Widget? leading;
final bool loading;
final double height;
final double radius;
final Color background;
final Color foreground;
final double fontSize;
final List<BoxShadow>? shadows;
@override
Widget build(BuildContext context) {
final disabled = onPressed == null || loading;
return Opacity(
opacity: disabled && !loading ? 0.55 : 1,
child: SizedBox(
height: height,
width: double.infinity,
child: Material(
color: background,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
onTap: disabled ? null : onPressed,
borderRadius: BorderRadius.circular(radius),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
boxShadow: shadows,
),
child: loading
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(foreground),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 8),
],
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: foreground,
),
),
],
),
),
),
),
),
);
}
}
/// 区块小标题(20/w600),对应 `.chart-title` / `.section-title`。
class XSectionTitle extends StatelessWidget {
const XSectionTitle(this.text, {super.key, this.trailing});
final String text;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final title = Text(
text,
style: AppTypography.headlineMd.copyWith(color: AppColors.onSurface),
);
if (trailing == null) return title;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [title, trailing!],
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../../core/theme/app_colors.dart';
/// 图标组件,对应 uni-app 版页面里的 `<text class="iconfont icon-xxx">`。
///
/// **为什么是混合方案**:源工程只提供了 8 个真实字形(`iconfont.ttf`),而页面里
/// 引用的 `icon-lightning` / `icon-location` / `icon-star` 等**在字体里并不存在**,
/// 源工程实际渲染为空白。为保证复刻后的界面可读可用,这里:
/// - 8 个真实字形继续用搬运过来的 `iconfont.ttf` 渲染,保持逐像素一致;
/// - 其余语义图标改用 SVG 路径绘制,线条风格与源设计语言保持一致。
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;
/// `iconfont.ttf` 中真实存在的字形 -> Unicode 码点(取自源 `iconfont.css`)。
static const Map<String, int> glyphs = {
'shandian': 0xe6a4,
'tongzhi': 0xe60a,
'qianbao': 0xe60b,
'yaoqing': 0x100a5,
'dingdan': 0xe62a,
'weixin': 0xe665,
'wode': 0xe607,
'shouyi': 0x10186,
};
/// 语义图标的 SVG 路径(24×24 视图框,`currentColor` 会被替换成实际色值)。
static const Map<String, String> paths = {
'bolt': '<path d="M13 2L4.5 13.5H11l-2 8.5L19 10h-6.5z"/>',
'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"/>',
'clock':
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'filter': '<path d="M4 5h16l-7 8v5l-2 1v-6z"/>',
'map':
'<path d="M9 4L3 7v13l6-3 6 3 6-3V4l-6 3z"/><path d="M9 4v13M15 7v13" stroke="currentColor" stroke-width="1.5" fill="none"/>',
'refresh':
'<path d="M20 11a8 8 0 1 0-1.5 5.6" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/><path d="M20 5v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'check':
'<path d="M4 12.5l5 5L20 6.5" stroke="currentColor" stroke-width="2.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"/>',
'back':
'<path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'arrow-down':
'<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'star': '<path d="M12 3l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.8 6.2 20.9l1.1-6.5L2.6 9.8l6.5-.9z"/>',
'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="M8 12l3 3 5-6" stroke="#ffffff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'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"/>',
'share':
'<path d="M18 16.1c-.8 0-1.5.3-2 .8l-7.1-4.1c.1-.3.1-.5.1-.8s0-.5-.1-.8l7-4.1c.5.5 1.3.8 2.1.8 1.7 0 3-1.3 3-3s-1.3-3-3-3-3 1.3-3 3c0 .3 0 .5.1.8L8.1 9.8C7.6 9.3 6.9 9 6 9c-1.7 0-3 1.3-3 3s1.3 3 3 3c.9 0 1.6-.3 2.1-.8l7.1 4.1c-.1.2-.1.5-.1.7 0 1.6 1.3 3 3 3s3-1.3 3-3-1.4-2.9-3.1-2.9z"/>',
'navigation':
'<path d="M12 2l10 10-10 10L2 12z"/><path d="M12 8.6l3.4 3.4-3.4 3.4" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
'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"/>',
'add-photo':
'<path d="M4 8h3l1.5-2h5L15 8h3a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2z"/><circle cx="10" cy="13.5" r="3"/><path d="M19 2v5M16.5 4.5h5" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'info':
'<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 11v6M12 7.4v.2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
'warning': '<path d="M12 3L2 20h20zM12 9v5M12 17.5v.1"/>',
'wallet':
'<rect x="3" y="6" width="18" height="13" rx="2.5"/><path d="M3 10.5h18" stroke="#ffffff" stroke-width="1.6" fill="none"/><circle cx="16.6" cy="14.4" r="1.3" fill="#ffffff"/>',
'list':
'<rect x="4" y="3" width="16" height="18" rx="2.5"/><path d="M8 8h8M8 12h8M8 16h5" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" fill="none"/>',
'settings':
'<g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></g>',
'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"/>',
'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"/>',
'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"/>',
};
/// 源里两种命名风格混用(uni-app 的 `icon-xxx` 与 Material Symbols 名),
/// 这里统一做别名收敛,调用方两种写法都能渲染。
static const Map<String, String> _aliases = {
'lightning': 'bolt',
'time': 'clock',
'cert': 'verified',
'notify': 'notification',
'arrow-right': 'forward',
'order': 'list',
'earnings': 'wallet',
'chevron_left': 'back',
'location_on': 'location',
'directions': 'navigation',
'add_a_photo': 'add-photo',
};
static final Map<String, String> _svgCache = {};
static String _hex(Color color) =>
'#${(color.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
/// 归一化后的语义名:别名 -> 目标名。
static String resolve(String name) => _aliases[name] ?? name;
/// 生成 (图标, 颜色) 组合对应的 SVG 字符串,带缓存避免重复拼接。
static String svgFor(String name, Color color) {
final key = '$name|${color.toARGB32()}';
return _svgCache.putIfAbsent(key, () {
final body = (paths[resolve(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) {
final glyph = glyphs[name];
if (glyph != null) {
// 源里的彩色字形:直接交给 iconfont.ttf 渲染,保留原字形配色。
return SizedBox(
width: size,
height: size,
child: Center(
child: Text(
String.fromCharCode(glyph),
style: TextStyle(
fontFamily: 'iconfont',
fontSize: size,
height: 1,
color: color,
),
),
),
);
}
return SizedBox(
width: size,
height: size,
child: SvgPicture.string(
svgFor(name, color),
width: size,
height: size,
fit: BoxFit.contain,
),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
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';
/// 固定顶栏,对应各页面 `.header.ios-blur` + `.header-content`。
///
/// 源里三种形态:
/// - 订单页 [XTopBar.brand]:高 64,闪电 + VoltLink(20/w700) + 通知
/// - 收益/我的 [XTopBar.brand]:高 56,闪电 + VoltLink(18/w600) + 通知
/// - 订单详情 [XTopBar.nav]:高 44,返回 + 居中标题 + 分享
///
/// 顶栏自带状态栏内边距,毛玻璃底色与源 `rgba(250,249,254,0.85)` 一致。
class XTopBar extends StatelessWidget {
const XTopBar._({
required this.height,
required this.leading,
required this.title,
required this.trailing,
required this.centerTitle,
});
/// 品牌顶栏(一级 Tab 页)。
///
/// [trailingBadge] 大于 0 时在铃铛右上角显示未读红点角标(>99 显示 99+)。
factory XTopBar.brand({
Key? key,
double height = 64,
double logoSize = 28,
double titleSize = 20,
FontWeight titleWeight = FontWeight.w700,
String title = 'VoltLink',
String trailingIcon = 'tongzhi',
VoidCallback? onTrailingTap,
int trailingBadge = 0,
}) {
return XTopBar._(
height: height,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon('shandian', size: logoSize, color: AppColors.primary),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontSize: titleSize,
fontWeight: titleWeight,
color: AppColors.onSurface,
letterSpacing: -0.2,
),
),
],
),
title: '',
centerTitle: false,
trailing: GestureDetector(
onTap: onTrailingTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(8),
child: _TrailingIcon(icon: trailingIcon, badge: trailingBadge),
),
),
);
}
/// 二级页顶栏:返回 + 居中标题 + 右侧操作位。
factory XTopBar.nav({
Key? key,
required String title,
double height = 44,
VoidCallback? onBack,
Widget? trailing,
}) {
return XTopBar._(
height: height,
centerTitle: true,
title: title,
leading: GestureDetector(
onTap: onBack,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon('chevron_left', size: 26, color: AppColors.primary),
Text(
'返回',
style: AppTypography.bodyLg.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
trailing: trailing ?? const SizedBox.shrink(),
);
}
final double height;
final Widget leading;
final String title;
final Widget trailing;
final bool centerTitle;
@override
Widget build(BuildContext context) {
final topInset = MediaQuery.paddingOf(context).top;
return Container(
padding: EdgeInsets.only(top: topInset),
decoration: const BoxDecoration(
color: AppColors.headerGlass,
border: Border(bottom: BorderSide(color: AppColors.borderSoft)),
boxShadow: AppShadows.topBar,
),
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: centerTitle
? Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: leading,
),
),
Text(
title,
style: AppTypography.bodyLg.copyWith(
fontWeight: FontWeight.w600,
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: trailing,
),
),
],
)
: Row(
children: [
leading,
const Spacer(),
trailing,
],
),
),
),
);
}
}
/// 顶栏右侧图标 + 可选未读角标。
class _TrailingIcon extends StatelessWidget {
const _TrailingIcon({required this.icon, required this.badge});
final String icon;
final int badge;
@override
Widget build(BuildContext context) {
final base = XIcon(icon, size: 24, color: AppColors.onSurfaceVariant);
if (badge <= 0) return base;
return Stack(
clipBehavior: Clip.none,
children: [
base,
Positioned(top: -3, right: -6, child: _UnreadBadge(count: badge)),
],
);
}
}
/// 未读红色角标:显示条数,超过 99 显示 `99+`。
class _UnreadBadge extends StatelessWidget {
const _UnreadBadge({required this.count});
final int count;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minWidth: 16),
height: 16,
padding: const EdgeInsets.symmetric(horizontal: 4),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.onPrimary, width: 1.5),
),
child: Text(
count > 99 ? '99+' : '$count',
style: const TextStyle(
fontSize: 10,
height: 1,
fontWeight: FontWeight.w600,
color: AppColors.onPrimary,
),
),
);
}
}