feat:参照研听的基础工程
This commit is contained in:
@@ -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 最宽 ~440,iPad 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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!),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
/// 左对齐到 12px(demo `.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// 链接用深绿 accentForeground(lime 只做底色,不做文字色)
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user