chore: prepare yanting monorepo handoff
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user