chore: prepare yanting monorepo handoff

This commit is contained in:
2026-06-03 10:39:03 +09:00
commit fde51468c6
106 changed files with 8171 additions and 0 deletions
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppButton extends StatelessWidget {
const AppButton({
required this.label,
required this.onPressed,
this.icon,
this.kind = AppButtonKind.primary,
this.expand = false,
super.key,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final AppButtonKind kind;
final bool expand;
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
AppButtonKind.primary => (WiseColors.secondary, WiseColors.primary),
AppButtonKind.dark => (WiseColors.primary, Colors.white),
AppButtonKind.accent => (WiseColors.accent, Colors.white),
AppButtonKind.ghost => (WiseColors.surface, WiseColors.primary),
};
final child = FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? const SizedBox.shrink() : Icon(icon, size: 18),
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: colors.$1,
foregroundColor: colors.$2,
disabledBackgroundColor: WiseColors.border,
disabledForegroundColor: WiseColors.textTertiary,
minimumSize: Size(expand ? double.infinity : 0, 44),
shape: const StadiumBorder(),
),
);
return expand ? SizedBox(width: double.infinity, child: child) : child;
}
}
enum AppButtonKind { primary, dark, accent, ghost }
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppCard extends StatelessWidget {
const AppCard({
required this.child,
this.onTap,
this.padding = const EdgeInsets.all(WiseSpacing.x4),
this.color = WiseColors.surface,
super.key,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsetsGeometry padding;
final Color color;
@override
Widget build(BuildContext context) {
final content = DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(WiseRadius.md),
boxShadow: WiseShadows.card,
),
child: Padding(padding: padding, child: child),
);
if (onTap == null) return content;
return InkWell(
borderRadius: BorderRadius.circular(WiseRadius.md),
onTap: onTap,
child: content,
);
}
}
class HeroReportCard extends StatelessWidget {
const HeroReportCard({required this.child, this.onTap, super.key});
final Widget child;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return AppCard(
onTap: onTap,
color: WiseColors.secondary200,
padding: const EdgeInsets.all(WiseSpacing.x5),
child: child,
);
}
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
class AppBadge extends StatelessWidget {
const AppBadge({
required this.text,
this.icon,
this.kind = BadgeKind.neutral,
super.key,
});
final String text;
final IconData? icon;
final BadgeKind kind;
@override
Widget build(BuildContext context) {
final colors = switch (kind) {
BadgeKind.brand => (WiseColors.secondary200, WiseColors.primarySoft),
BadgeKind.audio => (const Color(0x1F00A2DD), WiseColors.accent),
BadgeKind.tier => (const Color(0x1A008026), WiseColors.positive),
BadgeKind.warning => (const Color(0x209A6500), WiseColors.warning),
BadgeKind.neutral => (const Color(0x1286A7BD), WiseColors.textSecondary),
};
return DecoratedBox(
decoration: BoxDecoration(
color: colors.$1,
borderRadius: BorderRadius.circular(WiseRadius.pill),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: colors.$2),
const SizedBox(width: 4),
],
Text(
text,
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colors.$2),
),
],
),
),
);
}
}
enum BadgeKind { brand, audio, tier, warning, neutral }
class AppChip extends StatelessWidget {
const AppChip({
required this.label,
this.selected = false,
this.onTap,
super.key,
});
final String label;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ActionChip(
onPressed: onTap,
label: Text(label),
labelStyle: TextStyle(
color: selected ? Colors.white : WiseColors.textSecondary,
fontWeight: FontWeight.w700,
),
backgroundColor: selected ? WiseColors.primary : WiseColors.surface,
side: const BorderSide(color: WiseColors.border),
shape: const StadiumBorder(),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import '../data/models/models.dart';
import '../theme/wise_tokens.dart';
import 'app_card.dart';
class PlayerStateModel {
const PlayerStateModel({
this.audioId = '',
this.reportId = '',
this.title = '',
this.durationSec = 0,
this.positionSec = 0,
this.playing = false,
this.speed = 1.0,
});
final String audioId;
final String reportId;
final String title;
final int durationSec;
final int positionSec;
final bool playing;
final double speed;
bool get hasAudio => audioId.isNotEmpty;
PlayerStateModel copyWith({
String? audioId,
String? reportId,
String? title,
int? durationSec,
int? positionSec,
bool? playing,
double? speed,
}) {
return PlayerStateModel(
audioId: audioId ?? this.audioId,
reportId: reportId ?? this.reportId,
title: title ?? this.title,
durationSec: durationSec ?? this.durationSec,
positionSec: positionSec ?? this.positionSec,
playing: playing ?? this.playing,
speed: speed ?? this.speed,
);
}
}
class MiniPlayer extends StatelessWidget {
const MiniPlayer({
required this.player,
required this.onToggle,
super.key,
});
final PlayerStateModel player;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
if (!player.hasAudio) return const SizedBox.shrink();
final ratio = player.durationSec == 0 ? 0.0 : player.positionSec / player.durationSec;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: AppCard(
padding: const EdgeInsets.all(12),
color: WiseColors.primary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton.filled(
onPressed: onToggle,
icon: Icon(player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.secondary,
foregroundColor: WiseColors.primary,
),
),
const SizedBox(width: WiseSpacing.x2),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
player.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800),
),
Text(
'${formatDuration(player.positionSec)} / ${formatDuration(player.durationSec)} · ${player.speed.toStringAsFixed(1)}x',
style: const TextStyle(color: Color(0xCCFFFFFF), fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: WiseSpacing.x2),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 4,
backgroundColor: const Color(0x33FFFFFF),
color: WiseColors.secondary,
),
],
),
),
);
}
}
class PlayerCard extends StatelessWidget {
const PlayerCard({
required this.title,
required this.durationSec,
required this.player,
required this.onStart,
required this.onToggle,
required this.onSeek,
required this.onSpeed,
super.key,
});
final String title;
final int durationSec;
final PlayerStateModel player;
final VoidCallback onStart;
final VoidCallback onToggle;
final void Function(int delta) onSeek;
final VoidCallback onSpeed;
@override
Widget build(BuildContext context) {
final active = player.hasAudio && player.title == title;
final position = active ? player.positionSec : 0;
final ratio = durationSec == 0 ? 0.0 : position / durationSec;
return AppCard(
color: WiseColors.secondary200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('音频解读', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
Text(title, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x3),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
minHeight: 6,
backgroundColor: Colors.white,
color: WiseColors.accent,
),
const SizedBox(height: WiseSpacing.x2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formatDuration(position), style: Theme.of(context).textTheme.bodySmall),
Text(formatDuration(durationSec), style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: WiseSpacing.x3),
Row(
children: [
IconButton.outlined(onPressed: () => onSeek(-15), icon: const Icon(Icons.replay_10)),
IconButton.filled(
onPressed: active ? onToggle : onStart,
icon: Icon(active && player.playing ? Icons.pause : Icons.play_arrow),
style: IconButton.styleFrom(
backgroundColor: WiseColors.primary,
foregroundColor: Colors.white,
),
),
IconButton.outlined(onPressed: () => onSeek(15), icon: const Icon(Icons.forward_10)),
const Spacer(),
TextButton(onPressed: onSpeed, child: Text('${player.speed.toStringAsFixed(1)}x')),
],
),
Text(
'真实音频流待 /audio/{id}/stream 接入,当前为本地进度占位。',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'states.dart';
Future<void> showLoginSheet(BuildContext context, {String reason = '登录后保存当前动作'}) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor: WiseColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('登录研听', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(reason, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '使用手机号继续',
icon: Icons.phone_iphone,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '登录接口待接入,已保留当前页面');
},
),
const SizedBox(height: WiseSpacing.x2),
AppButton(
label: '微信 / Apple 登录占位',
icon: Icons.account_circle_outlined,
kind: AppButtonKind.ghost,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '真实 auth 待后端接入');
},
),
],
),
),
);
}
Future<void> showOutboundSheet(BuildContext context, {required String title}) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
backgroundColor: WiseColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(WiseRadius.lg)),
),
builder: (context) => Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('即将打开外部服务', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: WiseSpacing.x2),
Text(
'$title\n外跳仅用于了解原文或相关服务,本内容不构成投资建议。',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: WiseSpacing.x4),
AppButton(
label: '确认并记录占位事件',
icon: Icons.open_in_new,
kind: AppButtonKind.accent,
expand: true,
onPressed: () {
Navigator.pop(context);
showAppToast(context, '外跳事件接口待接入');
},
),
],
),
),
);
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import '../theme/wise_tokens.dart';
import 'app_buttons.dart';
import 'app_card.dart';
class LoadingState extends StatelessWidget {
const LoadingState({this.label = '正在加载研报解读', super.key});
final String label;
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.all(WiseSpacing.x4),
itemCount: 4,
separatorBuilder: (_, _) => const SizedBox(height: WiseSpacing.x3),
itemBuilder: (context, index) => const SkeletonCard(),
);
}
}
class SkeletonCard extends StatelessWidget {
const SkeletonCard({super.key});
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonLine(width: 96),
SizedBox(height: WiseSpacing.x3),
SkeletonLine(width: double.infinity, height: 18),
SizedBox(height: WiseSpacing.x2),
SkeletonLine(width: 240),
SizedBox(height: WiseSpacing.x3),
SkeletonLine(width: 160),
],
),
);
}
}
class SkeletonLine extends StatelessWidget {
const SkeletonLine({required this.width, this.height = 12, super.key});
final double width;
final double height;
@override
Widget build(BuildContext context) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: WiseColors.border,
borderRadius: BorderRadius.circular(WiseRadius.pill),
),
);
}
}
class EmptyState extends StatelessWidget {
const EmptyState({
required this.title,
required this.message,
this.icon = Icons.search_off,
this.actionLabel,
this.onAction,
super.key,
});
final String title;
final String message;
final IconData icon;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(WiseSpacing.x6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 42, color: WiseColors.primary),
const SizedBox(height: WiseSpacing.x3),
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: WiseSpacing.x2),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
if (actionLabel != null) ...[
const SizedBox(height: WiseSpacing.x4),
AppButton(label: actionLabel!, onPressed: onAction, kind: AppButtonKind.ghost),
],
],
),
),
);
}
}
class ErrorState extends StatelessWidget {
const ErrorState({required this.message, this.onRetry, super.key});
final String message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return EmptyState(
icon: Icons.cloud_off_outlined,
title: '内容暂时加载失败',
message: message,
actionLabel: onRetry == null ? null : '重试',
onAction: onRetry,
);
}
}
void showAppToast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
backgroundColor: WiseColors.primary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(WiseRadius.md)),
),
);
}