feat:参照研听的基础工程

This commit is contained in:
jingyun
2026-06-18 15:02:28 +08:00
commit 364fc837b3
367 changed files with 16495 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
import '../theme/spacing.dart';
/// iPad 响应式布局:
/// 宽 ≥ [breakpoint] 进入平板布局——屏边距 36、内容列统一限宽 920 居中
/// `padding: max(36, (宽-920)/2)`)、卡片列表两列(列距 18、行内等高)、
/// 底部 tab 行限宽 600 居中。手机布局完全不变。
abstract class Adaptive {
/// 平板断点(iPhone 最宽 ~440iPad 11″ 竖屏 834 起)
static const double breakpoint = 700;
/// 平板屏边距(demo `--pad-screen: 36px`
static const double padTablet = 36;
/// 内容列限宽(demo 所有页统一 920 居中)
static const double contentMaxWidth = 920;
/// 两列列表的列间距(demo `column-gap: 18px`
static const double columnGap = 18;
/// 底部 tab 行限宽(demo `.nav .tabs{max-width:600px}`
static const double tabsMaxWidth = 600;
static bool isTablet(BuildContext context) =>
MediaQuery.sizeOf(context).width >= breakpoint;
/// 屏水平边距:手机 [Spacing.page];平板 `max(36, (宽-920)/2)`
/// 即内容超 920 时左右对称留白实现居中限宽。
static double hPad(BuildContext context) {
final w = MediaQuery.sizeOf(context).width;
if (w < breakpoint) return Spacing.page;
return max(padTablet, (w - contentMaxWidth) / 2);
}
static EdgeInsets screenPadding(
BuildContext context, {
double top = 0,
double bottom = 0,
}) {
final h = hPad(context);
return EdgeInsets.fromLTRB(h, top, h, bottom);
}
}
/// 卡片列表自适应 sliver:手机单列(卡间距 [Spacing.cardGap]);
/// 平板两列——卡片两两成行、行内等高(IntrinsicHeight + stretch
/// 对应 demo CSS grid `1fr 1fr` 的等高行行为)。
SliverList adaptiveCardSliver(
BuildContext context, {
required int itemCount,
required Widget Function(BuildContext, int) itemBuilder,
double rowGap = Spacing.cardGap,
}) {
if (!Adaptive.isTablet(context)) {
return SliverList.separated(
itemCount: itemCount,
separatorBuilder: (_, _) => SizedBox(height: rowGap),
itemBuilder: itemBuilder,
);
}
final rows = (itemCount + 1) ~/ 2;
return SliverList.separated(
itemCount: rows,
separatorBuilder: (_, _) => SizedBox(height: rowGap),
itemBuilder: (c, r) {
final rightIndex = r * 2 + 1;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: itemBuilder(c, r * 2)),
const SizedBox(width: Adaptive.columnGap),
Expanded(
child: rightIndex < itemCount
? itemBuilder(c, rightIndex)
: const SizedBox(),
),
],
),
);
},
);
}
+112
View File
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../theme/app_text.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
enum AppStateButtonStyle { filled, outline }
class AppStateCard extends StatelessWidget {
const AppStateCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.buttonLabel,
this.onButtonPressed,
this.buttonStyle = AppStateButtonStyle.outline,
});
final IconData icon;
final String title;
final String subtitle;
final String? buttonLabel;
final VoidCallback? onButtonPressed;
final AppStateButtonStyle buttonStyle;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final hasAction = buttonLabel != null && onButtonPressed != null;
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 104,
height: 104,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.secondary,
shape: BoxShape.circle,
),
child: Icon(icon, size: 46, color: cs.mutedForeground),
),
const SizedBox(height: 22),
Text(
title,
textAlign: TextAlign.center,
style: AppText.cardTitle.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
height: 1.2,
),
),
const SizedBox(height: 10),
Text(
subtitle,
textAlign: TextAlign.center,
style: AppText.body.copyWith(
fontSize: 15,
color: cs.mutedForeground,
height: 1.5,
),
),
if (hasAction) ...[
const SizedBox(height: 20),
SizedBox(
height: 42,
child: buttonStyle == AppStateButtonStyle.filled
? FilledButton(
onPressed: onButtonPressed,
style: FilledButton.styleFrom(
backgroundColor: cs.foreground,
foregroundColor: cs.background,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 18),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: Text(buttonLabel!),
)
: OutlinedButton(
onPressed: onButtonPressed,
style: OutlinedButton.styleFrom(
foregroundColor: cs.foreground,
side: BorderSide(color: cs.border),
backgroundColor: cs.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 18),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: Text(buttonLabel!),
),
),
],
],
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
// void showAppToast(BuildContext context, String message) {
// ScaffoldMessenger.of(context)
// ..hideCurrentSnackBar()
// ..showSnackBar(
// SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
// );
// }
Future<bool?> toastInfo({
required String msg,
Color backgroundColor = const Color(0xCC111111),
Color textColor = Colors.white,
}) {
return Fluttertoast.showToast(
msg: msg,
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.BOTTOM,
timeInSecForIosWeb: 1,
backgroundColor: backgroundColor,
textColor: textColor,
fontSize: 16,
);
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 圆形返回钮(对齐 demo `.back`38×38 圆 + 1px 描边 + chevron)。
/// 左对齐到 12pxdemo `.dhead` padding-left),用作各页 AppBar 的 leading。
class CircleBackButton extends StatelessWidget {
const CircleBackButton({
super.key,
this.onTap,
this.fallbackLocation = '/assets',
});
final VoidCallback? onTap;
final String fallbackLocation;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 12),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
onTap ??
() {
if (context.canPop()) {
context.pop();
} else {
context.go(fallbackLocation);
}
},
child: Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: cs.border, width: 1),
),
child: Icon(
FlutterRemix.arrow_left_s_line,
size: 22,
color: cs.foreground,
),
),
),
),
);
}
}
+254
View File
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
import '../theme/spacing.dart';
// ─────────────────────────────────────────────────────────────────────────
// 详情/主页通用卡片语言。
// ─────────────────────────────────────────────────────────────────────────
/// 白底描边卡片(demo `.mcard`)。
class DetailCard extends StatelessWidget {
const DetailCard({super.key, required this.child, this.color});
final Widget child;
/// 卡片底色,默认白 `card`。
final Color? color;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(Spacing.cardPadding),
decoration: BoxDecoration(
color: color ?? cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: child,
);
}
}
/// 卡片头部:编号圆角块(或图标)+ 标题(demo `.mh > .mnum + h2`)。
class DetailSectionHeader extends StatelessWidget {
const DetailSectionHeader({
super.key,
this.number,
this.icon,
required this.title,
});
final int? number;
final IconData? icon;
final String title;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Row(
children: [
Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.accent,
borderRadius: BorderRadius.circular(7),
),
child: number != null
? Text(
'$number',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.accentForeground,
),
)
: Icon(icon, size: 15, color: cs.accentForeground),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: AppText.listTitle.copyWith(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
),
],
);
}
}
/// 徽标(demo `.badge` secondary / outline / 实底)。
class DetailBadge extends StatelessWidget {
const DetailBadge(
this.label, {
super.key,
this.bg,
this.fg,
this.outline = false,
this.icon,
});
final String label;
final Color? bg;
final Color? fg;
final bool outline;
final IconData? icon;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: outline ? Colors.transparent : (bg ?? cs.secondary),
borderRadius: BorderRadius.circular(Spacing.radiusBadge),
// 实底也带 1px 透明描边,与 outline 徽标等高等宽
border: outline
? Border.all(color: cs.border)
: Border.all(color: Colors.transparent),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 10,
color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
),
const SizedBox(width: 3),
],
Text(
label,
style: AppText.badge.copyWith(
color: outline ? cs.mutedForeground : (fg ?? cs.foreground),
),
),
],
),
);
}
}
/// 键值行(demo `.crow > .cl + .cv`)。value 可选链接样式 + 右上箭头,点击回调。
class DetailKeyValueRow extends StatelessWidget {
const DetailKeyValueRow({
super.key,
required this.label,
required this.value,
this.link = false,
this.onTap,
});
final String label;
final String value;
final bool link;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final valueWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
value,
textAlign: TextAlign.right,
style: AppText.meta.copyWith(
fontSize: 13.5,
// 链接用深绿 accentForegroundlime 只做底色,不做文字色)
color: link ? cs.accentForeground : cs.foreground,
fontWeight: FontWeight.w500,
),
),
),
if (link) ...[
const SizedBox(width: 3),
Icon(
FlutterRemix.external_link_line,
size: 13,
color: cs.accentForeground,
),
],
],
);
return Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: cs.border)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppText.meta.copyWith(
fontSize: 13.5,
color: cs.mutedForeground,
),
),
const SizedBox(width: 14),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: link && onTap != null
? GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: valueWidget,
)
: valueWidget,
),
),
],
),
);
}
}
/// 合规提示条(demo `.disc`:⚠ 图标 + 灰字)。
class DetailDisclaimer extends StatelessWidget {
const DetailDisclaimer({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: cs.secondary,
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
FlutterRemix.error_warning_line,
size: 15,
color: cs.mutedForeground,
),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: AppText.meta.copyWith(
fontSize: 12,
color: cs.mutedForeground,
height: 1.5,
),
),
),
],
),
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
import '../theme/spacing.dart';
Future<bool?> showLoginRequiredDialog(
BuildContext context, {
required String title,
required String description,
String cancelLabel = '取消',
String confirmLabel = '去登录',
}) {
final cs = ShadTheme.of(context).colorScheme;
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (ctx) => Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.background,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(Spacing.radiusCard),
topRight: Radius.circular(Spacing.radiusCard),
),
border: Border.all(color: cs.border),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(9999),
),
),
),
Text(
title,
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 8),
Text(
description,
style: AppText.body.copyWith(
fontSize: 13,
height: 1.6,
color: cs.mutedForeground,
),
),
const SizedBox(height: 22),
_LoginSheetButton(
label: confirmLabel,
primary: true,
onTap: () => Navigator.of(ctx).pop(true),
),
const SizedBox(height: 12),
_LoginSheetButton(
label: cancelLabel,
primary: false,
onTap: () => Navigator.of(ctx).pop(false),
),
],
),
),
),
);
}
class _LoginSheetButton extends StatelessWidget {
const _LoginSheetButton({
required this.label,
required this.primary,
required this.onTap,
});
final String label;
final bool primary;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary ? cs.primary : cs.secondary,
borderRadius: BorderRadius.circular(12),
border: primary ? null : Border.all(color: cs.border),
),
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: primary ? cs.primaryForeground : cs.foreground,
),
),
),
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// Keep markdown horizontal rules subtle so backend `---` separators read as
/// a light divider instead of a heavy line.
MarkdownStyleSheet withMarkdownHairlineRule(
MarkdownStyleSheet base,
Color ruleColor,
) {
return base.copyWith(
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(color: ruleColor.withValues(alpha: 0.85), width: 0.5),
),
),
);
}
MarkdownStyleSheet appMarkdownStyle(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return withMarkdownHairlineRule(
MarkdownStyleSheet(
// 正文:CJK 适宜行高 1.75,块间统一节奏
p: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
pPadding: EdgeInsets.zero,
blockSpacing: 14,
// 标题层级 + 上方留白形成分节(中文排版重段落分隔)
h1: TextStyle(
fontSize: 20,
height: 1.35,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
h1Padding: const EdgeInsets.only(top: 6, bottom: 2),
h2: TextStyle(
fontSize: 17,
height: 1.4,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
h2Padding: const EdgeInsets.only(top: 16, bottom: 2),
h3: TextStyle(
fontSize: 15,
height: 1.45,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
h3Padding: const EdgeInsets.only(top: 10),
// 列表:缩进 + 项目符号对齐正文
listIndent: 22,
listBullet: TextStyle(fontSize: 14.5, height: 1.75, color: cs.foreground),
listBulletPadding: const EdgeInsets.only(right: 6),
// 强调:加粗醒目;中文不用斜体(合成斜体难看),em 走常规体
strong: TextStyle(fontWeight: FontWeight.w700, color: cs.foreground),
em: TextStyle(fontStyle: FontStyle.normal, color: cs.foreground),
// 链接:accentForeground + 下划线(浅色深绿提升对比,深色仍 lime)
a: TextStyle(
color: cs.accentForeground,
decoration: TextDecoration.underline,
decorationColor: cs.accentForeground,
),
// 表格:表头加粗、单元格留白、宽表横向滚动(IntrinsicColumnWidth 触发)、
// 外角小圆角(嵌在 11.2 圆角卡片内,用更小一档的 8)
tableHead: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: cs.foreground,
),
// 表格数据用等宽数字(规范 3.1:tabular-nums),多行数值纵向对齐
tableBody: TextStyle(
fontSize: 13,
height: 1.5,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
tableHeadAlign: TextAlign.left,
tableColumnWidth: const IntrinsicColumnWidth(),
tableCellsPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
tablePadding: const EdgeInsets.only(bottom: 4),
tableBorder: TableBorder.all(
color: cs.border,
width: 1,
borderRadius: BorderRadius.circular(8),
),
// 引用块:中文不用斜体;浅底 + 主色左条 + 内距 + 右侧小圆角
blockquoteDecoration: BoxDecoration(
color: cs.muted,
border: Border(left: BorderSide(color: cs.primary, width: 3)),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(6),
bottomRight: Radius.circular(6),
),
),
blockquotePadding: const EdgeInsets.fromLTRB(14, 10, 12, 10),
blockquote: TextStyle(
fontSize: 13.5,
height: 1.7,
color: cs.mutedForeground,
),
// 行内 code(如 `机构名`):用正文字体(DM Sans),不用等宽 Menlo——
// 机构名/英文术语用等宽会显得突兀(demo 已改为正文字体)。仅保留浅底标记。
code: TextStyle(
fontSize: 13.5,
height: 1.75,
color: cs.foreground,
backgroundColor: cs.muted,
fontFamilyFallback: const ['PingFang SC', 'Noto Sans CJK SC'],
),
codeblockDecoration: BoxDecoration(
color: cs.muted,
borderRadius: BorderRadius.circular(8),
),
),
cs.border,
);
}
+299
View File
@@ -0,0 +1,299 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../util/external_link.dart';
/// GFM 表格的「填满 / 滚动」渲染。
///
/// flutter_markdown_plus 的内置 `_buildTable()` 二选一:FlexColumnWidth 填满但
/// 折行;IntrinsicColumnWidth 不折行但横向滚动里无法填满。自定义 builder 又被
/// 包强制覆盖。故把表格从 markdown 抽出,用本组件接管:
/// - 量出各列内容固有宽度;
/// - 总宽 ≤ 可用宽 → 按比例放大列宽**填满整行**(不滚动、不折行);
/// - 总宽 > 可用宽 → 用固有列宽 + 横向滚动查看(不折行)。
/// 单元格支持 `**加粗**` 与 `[文字](链接)`(点击走外部承接弹窗)。
class MarkdownTable extends StatelessWidget {
const MarkdownTable({
super.key,
required this.header,
required this.rows,
required this.aligns,
});
final List<String> header;
final List<List<String>> rows;
final List<TextAlign> aligns;
static const double _hPad = 12;
static const double _vPad = 8;
static const double _border = 1;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final n = header.length;
// 阅读字号倍率(表格在 ReaderScaled 子树内):量宽必须用同一 textScaler
// 否则缩放后列宽测算与实际渲染不符。
final textScaler = MediaQuery.textScalerOf(context);
final headStyle = TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: cs.foreground,
);
final bodyStyle = TextStyle(
fontSize: 13,
height: 1.5,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
);
// 各列固有宽 = 该列最宽单元格(含表头) + 左右内距;按加粗量(偏宽)更稳妥
final intrinsic = List<double>.filled(n, 0);
void acc(List<String> cells) {
for (var c = 0; c < n && c < cells.length; c++) {
final w = _measure(
_stripMd(cells[c]),
bodyStyle.copyWith(fontWeight: FontWeight.w700),
textScaler,
);
if (w > intrinsic[c]) intrinsic[c] = w;
}
}
acc(header);
for (final r in rows) {
acc(r);
}
for (var c = 0; c < n; c++) {
intrinsic[c] += _hPad * 2;
}
return LayoutBuilder(
builder: (context, constraints) {
final avail = constraints.maxWidth;
final sum =
intrinsic.fold<double>(0, (a, b) => a + b) + (n + 1) * _border;
List<double> widths;
bool scroll;
if (sum <= avail) {
// 够宽:把多余宽度按列固有宽比例分摊,填满整行
final extra = avail - sum;
final total = intrinsic.fold<double>(0, (a, b) => a + b);
widths = [
for (final w in intrinsic)
w + (total > 0 ? extra * (w / total) : extra / n),
];
scroll = false;
} else {
widths = intrinsic;
scroll = true;
}
final table = Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
columnWidths: {
for (var c = 0; c < n; c++) c: FixedColumnWidth(widths[c]),
},
border: TableBorder.all(
color: cs.border,
width: _border,
borderRadius: BorderRadius.circular(8),
),
children: [
_row(context, header, headStyle, cs, isHeader: true),
for (final r in rows) _row(context, r, bodyStyle, cs),
],
);
if (!scroll) return table;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: avail),
child: table,
),
);
},
);
}
TableRow _row(
BuildContext context,
List<String> cells,
TextStyle style,
ShadColorScheme cs, {
bool isHeader = false,
}) {
return TableRow(
decoration: isHeader ? BoxDecoration(color: cs.muted) : null,
children: [
for (var c = 0; c < header.length; c++)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: _hPad,
vertical: _vPad,
),
child: Align(
alignment: _alignment(c),
child: Text.rich(
_inlineSpans(
context,
c < cells.length ? cells[c] : '',
style,
cs,
),
textAlign: c < aligns.length ? aligns[c] : TextAlign.left,
softWrap: false,
overflow: TextOverflow.visible,
),
),
),
],
);
}
Alignment _alignment(int c) {
final a = c < aligns.length ? aligns[c] : TextAlign.left;
return switch (a) {
TextAlign.center => Alignment.center,
TextAlign.right => Alignment.centerRight,
_ => Alignment.centerLeft,
};
}
static double _measure(String text, TextStyle style, TextScaler textScaler) {
final tp = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
textScaler: textScaler,
)..layout();
return tp.width;
}
}
// ── 解析:从 markdown 抽出表格块,其余按原文保留 ──────────────────────────
class MdSegment {
const MdSegment.text(this.text) : table = null;
const MdSegment.table(this.table) : text = '';
final String text;
final MarkdownTable? table;
bool get isTable => table != null;
}
final RegExp _sepLine = RegExp(r'^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$');
bool _isRowLine(String l) => l.contains('|');
bool _isSepLine(String l) =>
l.contains('-') && l.contains('|') && _sepLine.hasMatch(l);
List<String> _splitCells(String line) {
var t = line.trim();
if (t.startsWith('|')) t = t.substring(1);
if (t.endsWith('|')) t = t.substring(0, t.length - 1);
return t.split('|').map((s) => s.trim()).toList();
}
/// 把 markdown 拆成「文本段」与「表格段」,顺序保留。
List<MdSegment> splitMarkdownTables(String md) {
final lines = md.split('\n');
final out = <MdSegment>[];
final buf = <String>[];
void flush() {
if (buf.isNotEmpty) {
out.add(MdSegment.text(buf.join('\n')));
buf.clear();
}
}
var i = 0;
while (i < lines.length) {
final isHeader =
_isRowLine(lines[i]) &&
i + 1 < lines.length &&
_isSepLine(lines[i + 1]);
if (!isHeader) {
buf.add(lines[i]);
i++;
continue;
}
flush();
final headerCells = _splitCells(lines[i]);
// demo:忽略 markdown 列对齐,表格内容统一左对齐
// (右对齐会让右列大片留白只显几个字)。
final aligns = List<TextAlign>.filled(headerCells.length, TextAlign.left);
final rows = <List<String>>[];
var j = i + 2;
while (j < lines.length && _isRowLine(lines[j]) && !_isSepLine(lines[j])) {
final cs = _splitCells(lines[j]);
rows.add([
for (var c = 0; c < headerCells.length; c++) c < cs.length ? cs[c] : '',
]);
j++;
}
out.add(
MdSegment.table(
MarkdownTable(header: headerCells, rows: rows, aligns: aligns),
),
);
i = j;
}
flush();
return out;
}
// ── 单元格内联:**加粗** 与 [文字](链接) ────────────────────────────────
final RegExp _inlineToken = RegExp(r'\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)');
String _stripMd(String s) => s
.replaceAllMapped(_inlineToken, (m) => m.group(1) ?? m.group(2) ?? '')
.replaceAll('*', '');
InlineSpan _inlineSpans(
BuildContext context,
String raw,
TextStyle base,
ShadColorScheme cs,
) {
final spans = <InlineSpan>[];
var last = 0;
for (final m in _inlineToken.allMatches(raw)) {
if (m.start > last) {
spans.add(TextSpan(text: raw.substring(last, m.start), style: base));
}
if (m.group(1) != null) {
spans.add(
TextSpan(
text: m.group(1),
style: base.copyWith(fontWeight: FontWeight.w700),
),
);
} else {
final text = m.group(2)!;
final url = m.group(3)!;
spans.add(
TextSpan(
text: text,
style: base.copyWith(
color: cs.accentForeground,
decoration: TextDecoration.underline,
decorationColor: cs.accentForeground,
),
recognizer: TapGestureRecognizer()
..onTap = () =>
openExternalUrl(context, url, fallbackToast: '链接即将上线'),
),
);
}
last = m.end;
}
if (last < raw.length) {
spans.add(TextSpan(text: raw.substring(last), style: base));
}
return TextSpan(children: spans);
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/app_text.dart';
Future<bool?> showOutboundServiceSheet(
BuildContext context, {
String title = '即将打开外部服务',
String description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
String cancelLabel = '取消',
String confirmLabel = '继续前往',
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (_) => OutboundServiceSheet(
title: title,
description: description,
cancelLabel: cancelLabel,
confirmLabel: confirmLabel,
),
);
}
class OutboundServiceSheet extends StatelessWidget {
const OutboundServiceSheet({
super.key,
this.title = '即将打开外部服务',
this.description = '你将离开金值,进入第三方服务页面。外部内容由对应服务提供。\n价格与资讯仅供参考,不构成投资建议。',
this.cancelLabel = '取消',
this.confirmLabel = '继续前往',
});
final String title;
final String description;
final String cancelLabel;
final String confirmLabel;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.background,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
border: Border.all(color: cs.border),
),
padding: EdgeInsets.fromLTRB(22, 12, 22, 22 + bottomInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 70,
height: 5,
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(999),
),
),
),
const SizedBox(height: 26),
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 12),
Text(
description,
style: AppText.body.copyWith(
fontSize: 14,
height: 1.65,
color: cs.mutedForeground,
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ShadButton.outline(
foregroundColor: cs.foreground,
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelLabel),
),
),
const SizedBox(width: 12),
Expanded(
child: ShadButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(confirmLabel),
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/spacing.dart';
import 'adaptive.dart';
class PlaceholderTabPage extends StatelessWidget {
const PlaceholderTabPage({
super.key,
required this.title,
required this.description,
});
final String title;
final String description;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 20, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: cs.foreground,
letterSpacing: 0,
),
),
const SizedBox(height: 10),
Text(
description,
style: TextStyle(
fontSize: 14,
height: 1.6,
color: cs.mutedForeground,
letterSpacing: 0,
),
),
],
),
),
),
),
),
],
),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../theme/spacing.dart';
import '../theme/app_text.dart';
import 'adaptive.dart';
/// 各 tab 屏顶部标题区(对齐 demo `.apphead`)。
/// 大标题 34/800/1.15 + 可选副标题 15/muted。
class ScreenHeader extends StatelessWidget {
const ScreenHeader({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: Adaptive.screenPadding(context, top: 10, bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppText.appTitle.copyWith(color: cs.foreground)),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: AppText.body.copyWith(
color: cs.mutedForeground,
height: 1.4,
),
),
],
],
),
);
}
}
/// 分区标题(对齐 demo `.section-title`):22/700 + 可选右尖角。
class SectionTitle extends StatelessWidget {
const SectionTitle(this.title, {super.key, this.showChevron = false});
final String title;
final bool showChevron;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: Adaptive.screenPadding(
context,
top: Spacing.sectionGap,
bottom: 16,
),
child: Row(
children: [
Text(
title,
style: AppText.sectionTitle.copyWith(color: cs.foreground),
),
if (showChevron) ...[
const SizedBox(width: 6),
Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
],
],
),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:shimmer/shimmer.dart';
import '../theme/spacing.dart';
class SkeletonLineCard extends StatelessWidget {
const SkeletonLineCard({
super.key,
this.lineWidths = const [0.4, 1, 0.8, 0.6],
this.height,
});
final List<double> lineWidths;
final double? height;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
height: height,
width: double.infinity,
padding: const EdgeInsets.all(Spacing.cardPadding),
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border, width: 1),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < lineWidths.length; i++) ...[
_SkeletonLine(widthFactor: lineWidths[i]),
if (i != lineWidths.length - 1) const SizedBox(height: 10),
],
],
),
);
}
}
class SkeletonInstitutionCard extends StatelessWidget {
const SkeletonInstitutionCard({super.key});
@override
Widget build(BuildContext context) {
return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
}
}
class SkeletonListenCard extends StatelessWidget {
const SkeletonListenCard({super.key});
@override
Widget build(BuildContext context) {
return const SkeletonLineCard(lineWidths: [0.4, 1, 0.6]);
}
}
class SkeletonSectionCard extends StatelessWidget {
const SkeletonSectionCard({
super.key,
required this.roman,
required this.lineWidths,
this.baseColor,
this.highlightColor,
});
final String roman;
final List<double> lineWidths;
final Color? baseColor;
final Color? highlightColor;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final brightness = ShadTheme.of(context).brightness;
final resolvedBaseColor =
baseColor ??
(brightness == Brightness.dark
? cs.secondary
: cs.border.withValues(alpha: 0.95));
final resolvedHighlightColor =
highlightColor ??
(brightness == Brightness.dark
? cs.card
: cs.secondary.withValues(alpha: 0.95));
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: Spacing.cardPadding,
vertical: 16,
),
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border, width: 1),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Shimmer.fromColors(
baseColor: resolvedBaseColor,
highlightColor: resolvedHighlightColor,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < lineWidths.length; i++) ...[
_SkeletonLine(
widthFactor: lineWidths[i],
height: i == 0 ? 14 : 12,
),
if (i != lineWidths.length - 1) const SizedBox(height: 10),
],
const SizedBox(height: 2),
],
),
),
);
}
}
class _SkeletonLine extends StatelessWidget {
const _SkeletonLine({required this.widthFactor, this.height = 12});
final double widthFactor;
final double height;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return FractionallySizedBox(
widthFactor: widthFactor,
child: Shimmer.fromColors(
baseColor: cs.secondary,
highlightColor: cs.muted,
child: Container(
height: height,
decoration: BoxDecoration(
color: cs.secondary,
borderRadius: BorderRadius.circular(6),
),
),
),
);
}
}
class SectionSkeletonList extends StatelessWidget {
const SectionSkeletonList({
super.key,
required this.count,
required this.lineWidthsForIndex,
this.romanLabels,
});
final int count;
final List<double> Function(int index) lineWidthsForIndex;
final List<String>? romanLabels;
@override
Widget build(BuildContext context) {
final labels =
romanLabels ??
List<String>.generate(count, (index) => _roman(index + 1));
return SliverPadding(
padding: const EdgeInsets.fromLTRB(Spacing.page, 4, Spacing.page, 24),
sliver: SliverList.separated(
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
itemBuilder: (context, index) => SkeletonSectionCard(
roman: labels[index],
lineWidths: lineWidthsForIndex(index),
),
),
);
}
}
class SectionSkeletonListView extends StatelessWidget {
const SectionSkeletonListView({
super.key,
required this.count,
required this.lineWidthsForIndex,
this.romanLabels,
});
final int count;
final List<double> Function(int index) lineWidthsForIndex;
final List<String>? romanLabels;
@override
Widget build(BuildContext context) {
final labels =
romanLabels ??
List<String>.generate(count, (index) => _roman(index + 1));
return ListView.separated(
padding: const EdgeInsets.only(bottom: 72),
itemCount: count,
separatorBuilder: (_, _) => const SizedBox(height: Spacing.cardGap),
itemBuilder: (context, index) => SkeletonSectionCard(
roman: labels[index],
lineWidths: lineWidthsForIndex(index),
),
);
}
}
String _roman(int value) {
const numerals = ['I.', 'II.', 'III.', 'IV.', 'V.', 'VI.', 'VII.', 'VIII.'];
if (value <= 0 || value > numerals.length) return '$value.';
return numerals[value - 1];
}