This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/asset_provider.dart';
import '../../providers/auth_provider.dart';
import '../../services/asset_service.dart';
/// 资产数字展示:整数不带小数点,否则保留两位
String fmtAssets(double v) =>
v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2);
/// 收益资产页(《03-接口文档》第 9 章):
/// 资产余额(10 资产 = 1 元)/ 累计收益·提现 / 收益明细(筛选 + 触底分页)/ 提现入口
class AssetsScreen extends ConsumerWidget {
const AssetsScreen({super.key});
static const List<Map<String, dynamic>> _filters = [
{'label': '全部', 'value': null},
{'label': '充值返利', 'value': 1},
{'label': '观看收益', 'value': 2},
{'label': '点赞收益', 'value': 3},
{'label': '提现', 'value': 4},
{'label': '提现退回', 'value': 5},
{'label': '返利扣回', 'value': 6},
{'label': '后台调整', 'value': 7},
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
if (!isLoggedIn) {
return Scaffold(
appBar: AppBar(title: const Text('收益资产')),
body: _GuestGuide(onLogin: () => context.push('/login')),
);
}
final state = ref.watch(assetProvider);
return Scaffold(
appBar: AppBar(title: const Text('收益资产')),
body: state.isLoading && state.wallet == null
? const Center(child: CircularProgressIndicator())
: state.loadFailed && state.wallet == null
? _LoadFailed(
onRetry: () => ref.read(assetProvider.notifier).load(),
)
: RefreshIndicator(
onRefresh: () => ref.read(assetProvider.notifier).load(),
child: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels >= n.metrics.maxScrollExtent - 200) {
ref.read(assetProvider.notifier).loadMore();
}
return false;
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_BalanceCard(wallet: state.wallet),
const SizedBox(height: 12),
// 提现入口
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => context
.push('/assets/withdraw')
.then((_) =>
ref.read(assetProvider.notifier).load()),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 44),
),
child: const Text('申请提现'),
),
),
if (state.wallet?.hasPendingWithdraw ?? false) ...[
const SizedBox(height: 8),
const Text(
'有一笔提现审核中,审核完成后可再次申请',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
const SizedBox(height: 20),
// 明细筛选
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _filters.map((f) {
final active = state.bizType == f['value'];
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f['label'] as String),
selected: active,
onSelected: (_) => ref
.read(assetProvider.notifier)
.setBizType(f['value'] as int?),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active
? Colors.white
: AppColors.textSecondary,
),
),
);
}).toList(),
),
),
const SizedBox(height: 8),
if (state.records.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text('暂无收益明细',
style:
TextStyle(color: AppColors.textWeak)),
),
)
else ...[
for (final r in state.records) _RecordTile(record: r),
if (state.hasMore)
const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
),
),
),
],
],
),
),
),
);
}
}
/// 资产卡:余额 + 折合 + 累计
class _BalanceCard extends StatelessWidget {
const _BalanceCard({required this.wallet});
final AssetWallet? wallet;
@override
Widget build(BuildContext context) {
final w = wallet;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: AppColors.goldGradient,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'资产余额',
style: TextStyle(color: Colors.black54, fontSize: 13),
),
const SizedBox(height: 4),
Text(
fmtAssets(w?.assets ?? 0),
style: const TextStyle(
color: Colors.black,
fontSize: 32,
fontWeight: FontWeight.w800,
),
),
Text(
'约 ¥${(w?.assetsValueYuan ?? 0).toStringAsFixed(2)}(10 资产 = 1 元)',
style: const TextStyle(color: Colors.black54, fontSize: 12),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _Stat(label: '累计收益', value: fmtAssets(w?.totalEarned ?? 0)),
),
Expanded(
child:
_Stat(label: '累计提现', value: fmtAssets(w?.totalWithdrawn ?? 0)),
),
Expanded(
child: _Stat(
label: '提现门槛',
value: '${w?.minWithdrawAssets ?? 100} 资产',
),
),
],
),
],
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
color: Colors.black,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
Text(
label,
style: const TextStyle(color: Colors.black54, fontSize: 11),
),
],
);
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({required this.record});
final AssetRecord record;
@override
Widget build(BuildContext context) {
final isExpense = record.isExpense;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(record.bizTypeLabel, style: const TextStyle(fontSize: 15)),
if (record.remark.isNotEmpty)
Text(
record.remark,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
Text(
record.createdAt,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isExpense ? '' : '+'}${fmtAssets(record.changeAmount)}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isExpense ? Colors.white : AppColors.gold,
),
),
Text(
'余额 ${fmtAssets(record.balanceAfter)}',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
],
),
);
}
}
class _GuestGuide extends StatelessWidget {
const _GuestGuide({required this.onLogin});
final VoidCallback onLogin;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.savings_outlined,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后查看收益资产',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onLogin,
style: ElevatedButton.styleFrom(
minimumSize: const Size(160, 44),
),
child: const Text('去登录'),
),
],
),
);
}
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('点击重试')),
],
),
);
}
}
+286
View File
@@ -0,0 +1,286 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/asset_provider.dart';
import '../../services/asset_service.dart';
import '../feed/widgets/feed_overlays.dart';
import 'assets_screen.dart';
/// 提现页(《03-接口文档》9.5/9.6):提现表单 + 提现记录
class WithdrawScreen extends ConsumerStatefulWidget {
const WithdrawScreen({super.key});
@override
ConsumerState<WithdrawScreen> createState() => _WithdrawScreenState();
}
class _WithdrawScreenState extends ConsumerState<WithdrawScreen> {
final _amountCtrl = TextEditingController();
final _accountCtrl = TextEditingController();
int _channel = 1;
bool _submitting = false;
static const _channels = [
{'value': 1, 'label': '支付宝', 'hint': '支付宝账号(手机号/邮箱)'},
{'value': 2, 'label': '微信', 'hint': '微信收款账号'},
{'value': 3, 'label': 'USDT', 'hint': 'USDT 收款地址(TRC20)'},
];
@override
void dispose() {
_amountCtrl.dispose();
_accountCtrl.dispose();
super.dispose();
}
Map<String, Object> get _channelCfg =>
_channels.firstWhere((c) => c['value'] == _channel);
@override
Widget build(BuildContext context) {
final asset = ref.watch(assetProvider);
final records = ref.watch(withdrawRecordsProvider);
final wallet = asset.wallet;
return Scaffold(
appBar: AppBar(title: const Text('申请提现')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildForm(wallet),
const SizedBox(height: 24),
const Text(
'提现记录',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
if (records.isLoading && records.items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
)
else if (records.items.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(
child: Text('暂无提现记录',
style: TextStyle(color: AppColors.textWeak)),
),
)
else ...[
for (final r in records.items) _WithdrawTile(record: r),
if (records.hasMore)
TextButton(
onPressed: () =>
ref.read(withdrawRecordsProvider.notifier).loadMore(),
child: const Text('加载更多'),
),
],
],
),
);
}
Widget _buildForm(AssetWallet? wallet) {
final hasPending = wallet?.hasPendingWithdraw ?? false;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'可提资产 ${fmtAssets(wallet?.assets ?? 0)}',
style: const TextStyle(color: AppColors.textSecondary),
),
const Spacer(),
Text(
'最低 ${wallet?.minWithdrawAssets ?? 100} 资产',
style:
const TextStyle(color: AppColors.textWeak, fontSize: 12),
),
],
),
const SizedBox(height: 12),
// 金额
TextField(
controller: _amountCtrl,
enabled: !hasPending,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
decoration: InputDecoration(
labelText: '提现金额(资产)',
suffix: TextButton(
onPressed: hasPending || wallet == null
? null
: () => _amountCtrl.text = fmtAssets(wallet.assets),
child: const Text('全部提现'),
),
),
),
const SizedBox(height: 12),
// 渠道
Row(
children: _channels.map((c) {
final active = _channel == c['value'];
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(c['label'] as String),
selected: active,
onSelected: hasPending
? null
: (_) => setState(() => _channel = c['value'] as int),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active ? Colors.white : AppColors.textSecondary,
),
),
);
}).toList(),
),
const SizedBox(height: 12),
// 账号
TextField(
controller: _accountCtrl,
enabled: !hasPending,
decoration: InputDecoration(
labelText: '收款账号',
hintText: _channelCfg['hint'] as String,
),
),
if (hasPending) ...[
const SizedBox(height: 12),
const Text(
'有一笔提现审核中,审核完成后才能再次申请',
style: TextStyle(color: AppColors.gold, fontSize: 12),
),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
_submitting || hasPending ? null : () => _submit(wallet),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 44),
),
child: Text(_submitting ? '提交中…' : '提交申请'),
),
),
],
),
);
}
Future<void> _submit(AssetWallet? wallet) async {
final amount = double.tryParse(_amountCtrl.text.trim());
final account = _accountCtrl.text.trim();
final minAssets = wallet?.minWithdrawAssets ?? 100;
if (amount == null || amount <= 0) {
showTopToast(context, '请输入提现金额');
return;
}
if (amount < minAssets) {
showTopToast(context, '最低提现 $minAssets 资产');
return;
}
if (wallet != null && amount > wallet.assets) {
showTopToast(context, '超出可提资产余额');
return;
}
if (account.isEmpty) {
showTopToast(context, '请输入收款账号');
return;
}
setState(() => _submitting = true);
try {
final result = await AssetService.applyWithdraw(
assets: amount,
channel: _channel,
account: account,
);
if (!mounted) return;
showTopToast(
context,
'提现申请已提交:¥${result.amountYuan.toStringAsFixed(2)},等待审核',
);
_amountCtrl.clear();
// 刷新资产与记录
ref.read(assetProvider.notifier).load();
ref.read(withdrawRecordsProvider.notifier).load();
} on ApiException catch (e) {
if (mounted) showTopToast(context, e.message);
} catch (_) {
if (mounted) showTopToast(context, '提交失败,请稍后重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
}
class _WithdrawTile extends StatelessWidget {
const _WithdrawTile({required this.record});
final WithdrawRecord record;
Color get _statusColor => switch (record.status) {
1 => Colors.greenAccent,
2 => AppColors.primary,
_ => AppColors.gold,
};
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¥${record.amountYuan.toStringAsFixed(2)}(${fmtAssets(record.assets)} 资产)',
style: const TextStyle(fontSize: 15),
),
Text(
'${record.channelLabel} · ${record.withdrawNo}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
Text(
record.createdAt,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
),
Text(
record.statusText,
style: TextStyle(color: _statusColor, fontSize: 13),
),
],
),
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/auth_service.dart';
/// 登录页:骨架期提供手机号+密码登录,验证码/邮箱登录随迭代补充
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phone = TextEditingController();
final _password = TextEditingController();
bool _loading = false;
String? _error;
Future<void> _submit() async {
final phone = _phone.text.trim();
final password = _password.text;
if (phone.isEmpty || password.isEmpty) {
setState(() => _error = '请输入手机号和密码');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final result = await AuthService.login(
type: 'phone_password',
phone: phone,
password: password,
);
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
if (mounted) context.go('/home');
} catch (e) {
setState(() => _error = e.toString().replaceFirst('ApiException(', '').split(', ').last);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('登录')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 32),
const Text(
'欢迎回来',
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700),
),
const SizedBox(height: 32),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(hintText: '手机号'),
),
const SizedBox(height: 16),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(hintText: '密码'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: const TextStyle(color: AppColors.primary, fontSize: 13)),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.push('/register'),
child: const Text(
'没有账号?去注册',
style: TextStyle(color: AppColors.textSecondary),
),
),
],
),
),
);
}
}
+195
View File
@@ -0,0 +1,195 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/storage/local_store.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/auth_service.dart';
/// 注册页:手机号 + 验证码 + 密码;邀请码选填(口令/模糊归因命中时自动预填)
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _phone = TextEditingController();
final _code = TextEditingController();
final _password = TextEditingController();
final _nickname = TextEditingController();
late final TextEditingController _inviteCode;
bool _loading = false;
String? _error;
/// 验证码发送倒计时
int _countdown = 0;
Timer? _timer;
@override
void initState() {
super.initState();
// 首启归因命中的邀请码预填(用户可改可清空)
_inviteCode = TextEditingController(text: LocalStore.I.pendingInviteCode ?? '');
}
@override
void dispose() {
_timer?.cancel();
_phone.dispose();
_code.dispose();
_password.dispose();
_nickname.dispose();
_inviteCode.dispose();
super.dispose();
}
Future<void> _sendCode() async {
final phone = _phone.text.trim();
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
setState(() => _error = '请输入正确的手机号');
return;
}
try {
await AuthService.sendSmsCode(phone, 'register');
setState(() {
_error = null;
_countdown = 60;
});
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (_countdown <= 1) {
t.cancel();
setState(() => _countdown = 0);
} else {
setState(() => _countdown -= 1);
}
});
} catch (e) {
setState(() => _error = _errText(e));
}
}
Future<void> _submit() async {
final phone = _phone.text.trim();
final code = _code.text.trim();
final password = _password.text;
if (phone.isEmpty || code.isEmpty || password.isEmpty) {
setState(() => _error = '请填写手机号、验证码和密码');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final result = await AuthService.register(
type: 'phone',
account: phone,
code: code,
password: password,
nickname: _nickname.text.trim(),
inviteCode: _inviteCode.text.trim().toUpperCase(),
);
// 注册成功:pending 邀请码已被消费,清除本地标记
await LocalStore.I.clearPendingInviteCode();
await ref.read(authProvider.notifier).onLoginSuccess(result.user);
if (!mounted) return;
if (result.trialPlanName != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('注册成功,已赠送${result.trialPlanName}')),
);
}
context.go('/home');
} catch (e) {
setState(() => _error = _errText(e));
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _errText(Object e) =>
e.toString().replaceFirst('ApiException(', '').split(', ').last.replaceAll(')', '');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('注册')),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(24),
children: [
const Text('创建账号', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700)),
const SizedBox(height: 32),
TextField(
controller: _phone,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(hintText: '手机号'),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: _code,
keyboardType: TextInputType.number,
decoration: const InputDecoration(hintText: '短信验证码'),
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
child: OutlinedButton(
onPressed: _countdown > 0 ? null : _sendCode,
child: Text(_countdown > 0 ? '${_countdown}s 后重发' : '获取验证码'),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(hintText: '密码(8-32 位,含字母和数字)'),
),
const SizedBox(height: 16),
TextField(
controller: _nickname,
decoration: const InputDecoration(hintText: '昵称(选填)'),
),
const SizedBox(height: 16),
TextField(
controller: _inviteCode,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(hintText: '邀请码(选填)'),
),
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: const TextStyle(color: AppColors.primary, fontSize: 13)),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('注册并登录'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () => context.pop(),
child: const Text('已有账号?去登录', style: TextStyle(color: AppColors.textSecondary)),
),
],
),
),
);
}
}
+227
View File
@@ -0,0 +1,227 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/theme/app_theme.dart';
import '../../core/utils/share_text.dart';
import '../../models/video_item.dart';
import '../../providers/feed_provider.dart';
import 'widgets/comment_sheet.dart';
import 'widgets/feed_overlays.dart';
import 'widgets/feed_video_page.dart';
/// 首页 Feed:全屏竖向 PageView 沉浸式播放(《01-开发文档》5.2.1)
/// - 顶部:策略 Tab(推荐/最新/热度)+ 搜索图标(占位)
/// - 底部:游客/非会员免费剩余次数提示条
/// - 滑出暂停、滑入自动播放并上报观看计费
class FeedScreen extends ConsumerStatefulWidget {
const FeedScreen({super.key});
@override
ConsumerState<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends ConsumerState<FeedScreen>
with WidgetsBindingObserver {
FeedStrategy _strategy = FeedStrategy.recommend;
final PageController _pageController = PageController();
StreamSubscription<FeedEvent>? _eventSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// 监听计费/UI 事件(弹窗与 Toast 由 Notifier 事件流驱动)
_eventSub = ref
.read(feedProvider(_strategy).notifier)
.events
.listen(_handleEvent);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_eventSub?.cancel();
_pageController.dispose();
super.dispose();
}
/// App 退后台暂停、回前台恢复播放
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final notifier = ref.read(feedProvider(_strategy).notifier);
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
notifier.pool.pauseCurrent();
} else if (state == AppLifecycleState.resumed) {
notifier.pool.playCurrent();
}
}
void _handleEvent(FeedEvent event) {
if (!mounted) return;
switch (event) {
case FeedToastEvent(:final message):
showTopToast(context, message);
case FeedGuestLimitEvent():
showGuestLimitDialog(context);
case FeedPointsInsufficientEvent(:final points):
showPointsInsufficientDialog(context, points);
}
}
void _switchStrategy(FeedStrategy next) {
if (next == _strategy) return;
// 暂停旧策略播放,切换后新策略独立维护状态与播放器池
ref.read(feedProvider(_strategy).notifier).pool.pauseCurrent();
setState(() => _strategy = next);
_eventSub?.cancel();
_eventSub = ref
.read(feedProvider(_strategy).notifier)
.events
.listen(_handleEvent);
_pageController.jumpToPage(0);
}
/// 打开评论弹层:打开期间暂停当前播放,关闭后恢复
Future<void> _openComments(VideoItem item) async {
final notifier = ref.read(feedProvider(_strategy).notifier);
notifier.pool.pauseCurrent();
await showCommentSheet(
context,
videoId: item.videoId,
onCountChanged: (delta) =>
notifier.adjustCommentCount(item.videoId, delta),
);
if (mounted) notifier.pool.playCurrent();
}
/// 系统分享面板(分享文案:标题 + 标签 + 播放链接)
void _share(VideoItem item) => Share.share(buildShareText(item));
@override
Widget build(BuildContext context) {
final state = ref.watch(feedProvider(_strategy));
final notifier = ref.read(feedProvider(_strategy).notifier);
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
// 内容区
if (state.isRefreshing && state.items.isEmpty)
const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
else if (state.loadFailed && state.items.isEmpty)
_buildRetry(notifier)
else if (state.items.isEmpty)
const Center(
child: Text('暂无视频', style: TextStyle(color: AppColors.textSecondary)),
)
else
PageView.builder(
controller: _pageController,
scrollDirection: Axis.vertical,
itemCount: state.items.length,
onPageChanged: notifier.onPageChanged,
itemBuilder: (context, index) {
final item = state.items[index];
return FeedVideoPage(
key: ValueKey(item.videoId),
item: item,
slotIndex: notifier.pool.slotOf(index),
pool: notifier.pool,
isCurrent: index == state.currentIndex,
onLike: notifier.toggleLike,
onFavorite: notifier.toggleFavorite,
onComment: _openComments,
onShare: _share,
);
},
),
// 顶部:策略 Tab + 搜索(占位)
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Row(
children: [
const Spacer(),
for (final s in FeedStrategy.values) ...[
_StrategyTab(
label: s.label,
selected: s == _strategy,
onTap: () => _switchStrategy(s),
),
const SizedBox(width: 16),
],
IconButton(
icon: const Icon(Icons.search, color: Colors.white),
onPressed: () => context.push('/search'),
),
],
),
),
),
// 底部:免费剩余次数提示条(≤3 时显示)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: FreeQuotaBanner(remaining: state.freeRemainingToday),
),
],
),
);
}
Widget _buildRetry(FeedNotifier notifier) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('网络异常', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(
onPressed: notifier.refresh,
child: const Text('点击重试', style: TextStyle(color: AppColors.primary)),
),
],
),
);
}
}
/// 顶部策略 Tab
class _StrategyTab extends StatelessWidget {
const _StrategyTab({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
color: selected ? Colors.white : AppColors.textSecondary,
shadows: const [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
);
}
}
@@ -0,0 +1,483 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/comment_item.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/comment_provider.dart';
import 'feed_overlays.dart';
/// 打开评论弹层(《01-开发文档》2.6 评论:一级 + 楼中楼二级回复)
/// [onCountChanged] 发表/删除成功后回调(delta=±1),用于同步 Feed 评论计数
Future<void> showCommentSheet(
BuildContext context, {
required int videoId,
required void Function(int delta) onCountChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (_) => CommentSheet(
videoId: videoId,
onCountChanged: onCountChanged,
),
);
}
class CommentSheet extends ConsumerStatefulWidget {
const CommentSheet({
super.key,
required this.videoId,
required this.onCountChanged,
});
final int videoId;
/// 评论计数变化回调(发表 +1 / 删除 -1)
final void Function(int delta) onCountChanged;
@override
ConsumerState<CommentSheet> createState() => _CommentSheetState();
}
class _CommentSheetState extends ConsumerState<CommentSheet> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
final _inputFocus = FocusNode();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
_inputFocus.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 100) {
ref.read(commentProvider(widget.videoId).notifier).loadMore();
}
}
/// 发表(游客守卫:跳登录页)
Future<void> _send() async {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
final content = _inputController.text.trim();
if (content.isEmpty) return;
final notifier = ref.read(commentProvider(widget.videoId).notifier);
try {
final result = await notifier.post(content);
_inputController.clear();
_inputFocus.unfocus();
widget.onCountChanged(1);
if (!mounted) return;
final progress = result.rewardProgress;
if (progress.rewardedThisTime) {
showTopToast(context, '评论奖励 +${progress.rewardPoints} 积分');
}
} on ApiException catch (e) {
if (mounted) showTopToast(context, e.message);
} catch (_) {
if (mounted) showTopToast(context, '发表失败,请重试');
}
}
/// 删除确认(本人评论/本人视频下评论)
Future<void> _confirmDelete(CommentItem comment, {int parentId = 0}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('删除评论', style: TextStyle(fontSize: 16)),
content: const Text('确定删除这条评论吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (ok != true || !mounted) return;
try {
await ref
.read(commentProvider(widget.videoId).notifier)
.remove(comment, parentId: parentId);
widget.onCountChanged(-1);
} on ApiException catch (e) {
if (mounted) showTopToast(context, e.message);
} catch (_) {
if (mounted) showTopToast(context, '删除失败,请重试');
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(commentProvider(widget.videoId));
final notifier = ref.read(commentProvider(widget.videoId).notifier);
return SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
// 头部:标题 + 关闭
SizedBox(
height: 48,
child: Stack(
children: [
Center(
child: Text(
state.total > 0 ? '评论 ${state.total}' : '评论',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
right: 4,
top: 0,
bottom: 0,
child: IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
),
),
],
),
),
const Divider(height: 1),
// 评论列表
Expanded(child: _buildList(state, notifier)),
// 回复目标提示条
if (state.replyTarget != null)
Container(
width: double.infinity,
color: AppColors.background,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
children: [
Expanded(
child: Text(
'回复 @${state.replyTarget!.nickname}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
),
GestureDetector(
onTap: () => notifier.setReplyTarget(null),
child: const Icon(Icons.close,
size: 16, color: AppColors.textSecondary),
),
],
),
),
// 输入区
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(
left: 12,
right: 12,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 8,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _inputController,
focusNode: _inputFocus,
maxLength: 500,
maxLines: 1,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: state.replyTarget == null
? '说点什么...'
: '回复 @${state.replyTarget!.nickname}',
counterText: '',
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
onTap: () {
// 游客点击输入框:守卫跳登录
if (!ref.read(authProvider).isLoggedIn) {
_inputFocus.unfocus();
context.go('/login');
}
},
onSubmitted: (_) => _send(),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: state.posting ? null : _send,
child: Text(
state.posting ? '发送中' : '发送',
style: TextStyle(
color: state.posting
? AppColors.textWeak
: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
],
),
);
}
Widget _buildList(CommentState state, CommentNotifier notifier) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (state.loadFailed) {
return Center(
child: TextButton(
onPressed: notifier.load,
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
);
}
if (state.items.isEmpty) {
return const Center(
child: Text('还没有评论,快来抢沙发',
style: TextStyle(color: AppColors.textSecondary)),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: state.items.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(12),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
),
),
);
}
final comment = state.items[index];
final replies = state.replies[comment.commentId];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CommentTile(
comment: comment,
canDelete: _canDelete(comment),
onReply: () => _replyTo(comment, rootId: comment.commentId),
onDelete: () => _confirmDelete(comment),
),
// 回复展开入口
if (comment.replyCount > 0)
Padding(
padding: const EdgeInsets.only(left: 44),
child: GestureDetector(
onTap: () => notifier.toggleReplies(comment),
child: Text(
state.repliesLoading.contains(comment.commentId)
? '加载中...'
: replies == null
? '展开 ${comment.replyCount} 条回复'
: '收起回复',
style: const TextStyle(
color: AppColors.secondary,
fontSize: 12,
),
),
),
),
// 二级回复列表
if (replies != null)
Padding(
padding: const EdgeInsets.only(left: 44, top: 4),
child: Column(
children: [
for (final reply in replies)
CommentTile(
comment: reply,
compact: true,
canDelete: _canDelete(reply),
onReply: () => _replyTo(
reply,
rootId: comment.commentId,
),
onDelete: () => _confirmDelete(
reply,
parentId: comment.commentId,
),
),
if (comment.replyCount > replies.length)
Text(
'共 ${comment.replyCount} 条回复',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
],
);
},
);
}
/// 本人评论可删(本人视频下他人评论的删除依赖视频作者身份,一期仅支持自删)
bool _canDelete(CommentItem comment) {
final user = ref.read(authProvider).user;
return user != null && user.userId == comment.userId;
}
void _replyTo(CommentItem comment, {required int rootId}) {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
ref.read(commentProvider(widget.videoId).notifier).setReplyTarget(
ReplyTarget(
parentId: rootId,
replyToUserId: comment.userId,
nickname: comment.nickname,
),
);
_inputFocus.requestFocus();
}
}
/// 单条评论(一级/二级通用,compact 为二级样式)
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.comment,
required this.onReply,
required this.onDelete,
this.canDelete = false,
this.compact = false,
});
final CommentItem comment;
final VoidCallback onReply;
final VoidCallback onDelete;
/// 是否可删除(本人评论)
final bool canDelete;
/// 二级回复紧凑样式
final bool compact;
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPress: canDelete ? onDelete : null,
child: Padding(
padding: EdgeInsets.symmetric(vertical: compact ? 4 : 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头像
CircleAvatar(
radius: compact ? 12 : 16,
backgroundColor: AppColors.background,
backgroundImage: comment.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(comment.avatarUrl)
: null,
child: comment.avatarUrl.isEmpty
? Text(
comment.nickname.isNotEmpty
? comment.nickname.characters.first
: '友',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 10 : 13,
),
)
: null,
),
const SizedBox(width: 8),
// 昵称 + 内容 + 操作
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${comment.nickname} · ${_formatTime(comment.createdAt)}',
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 2),
Text(
comment.content,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 2),
GestureDetector(
onTap: onReply,
child: const Text(
'回复',
style: TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
),
],
),
),
],
),
),
);
}
/// 时间紧凑显示:"2026-10-14 10:00:00" → "10-14 10:00";"刚刚"原样
static String _formatTime(String raw) {
if (raw.length >= 16) return raw.substring(5, 16);
return raw;
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
/// 顶部轻提示(扣积分/操作失败等),自动消失,不依赖第三方 Toast 库
void showTopToast(BuildContext context, String message) {
final overlay = Overlay.of(context);
final entry = OverlayEntry(
builder: (_) => Positioned(
top: MediaQuery.of(context).padding.top + 16,
left: 48,
right: 48,
child: Material(
color: Colors.transparent,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(20),
),
child: Text(
message,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
),
),
),
);
overlay.insert(entry);
Future.delayed(const Duration(seconds: 2), entry.remove);
}
/// 游客今日免费次数已用完:登录引导(2002)
void showGuestLimitDialog(BuildContext context) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('今日免费次数已用完'),
content: const Text('注册解锁更多视频,今日已看 10/10'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('再逛逛', style: TextStyle(color: AppColors.textSecondary)),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
context.go('/login');
},
child: const Text('去登录/注册'),
),
],
),
);
}
/// 积分不足:引导充值 / 开通会员(2001)
void showPointsInsufficientDialog(BuildContext context, int points) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('积分不足'),
content: Text('当前积分 $points,请充值或开通会员后继续观看'),
actions: [
TextButton(
onPressed: () {
Navigator.of(ctx).pop();
context.push('/membership');
},
child: const Text('开通会员', style: TextStyle(color: AppColors.gold)),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
context.push('/recharge');
},
child: const Text('去充值'),
),
],
),
);
}
/// 底部免费剩余次数提示条:游客/非会员且剩余 ≤3 时显示(《01-开发文档》5.2.1)
class FreeQuotaBanner extends StatelessWidget {
const FreeQuotaBanner({super.key, required this.remaining});
/// 今日免费剩余次数;null(未上报过)或 >3 不显示
final int? remaining;
@override
Widget build(BuildContext context) {
final r = remaining;
if (r == null || r > 3) return const SizedBox.shrink();
return SafeArea(
child: Container(
width: double.infinity,
color: Colors.black.withValues(alpha: 0.6),
padding: const EdgeInsets.symmetric(vertical: 6),
child: Text(
r > 0 ? '今日免费观看剩余 $r 次' : '今日免费次数已用完,登录后继续观看',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.gold, fontSize: 12),
),
),
);
}
}
/// 双击点赞的大爱心飘出动画
class LikeBurst extends StatefulWidget {
const LikeBurst({super.key, required this.trigger});
/// 每次双击递增触发动画(0 不显示)
final int trigger;
@override
State<LikeBurst> createState() => LikeBurstState();
}
class LikeBurstState extends State<LikeBurst>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
late final Animation<double> _scale = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.2), weight: 30),
TweenSequenceItem(tween: Tween(begin: 1.2, end: 1.0), weight: 20),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 50),
]).animate(_controller);
late final Animation<double> _opacity = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 70),
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 30),
]).animate(_controller);
@override
void didUpdateWidget(LikeBurst oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.trigger != oldWidget.trigger && widget.trigger > 0) {
_controller.forward(from: 0);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.trigger <= 0) return const SizedBox.shrink();
return Center(
child: AnimatedBuilder(
animation: _controller,
builder: (_, __) => Opacity(
opacity: _opacity.value,
child: Transform.scale(
scale: _scale.value,
child: const Icon(Icons.favorite, size: 96, color: AppColors.primary),
),
),
),
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/player/feed_player_pool.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
import 'feed_overlays.dart';
import 'interaction_bar.dart';
import 'video_info_section.dart';
import 'video_player_view.dart';
/// Feed 单页:播放器 + 手势层 + 互动栏 + 信息区 + 双击爱心动画
/// 手势(《01-开发文档》5.3):单击暂停/继续,双击点赞
class FeedVideoPage extends ConsumerStatefulWidget {
const FeedVideoPage({
super.key,
required this.item,
required this.slotIndex,
required this.pool,
required this.isCurrent,
required this.onLike,
this.onFavorite,
this.onComment,
this.onShare,
});
final VideoItem item;
final int slotIndex;
final FeedPlayerPool pool;
final bool isCurrent;
/// 点赞回调(由上层接 feedProvider.toggleLike,内部判登录态)
final void Function(int videoId) onLike;
/// 收藏回调(由上层接 feedProvider.toggleFavorite)
final void Function(int videoId)? onFavorite;
/// 评论入口(打开评论弹层)
final void Function(VideoItem item)? onComment;
/// 分享入口(系统分享面板)
final void Function(VideoItem item)? onShare;
@override
ConsumerState<FeedVideoPage> createState() => _FeedVideoPageState();
}
class _FeedVideoPageState extends ConsumerState<FeedVideoPage> {
int _likeBurstTrigger = 0;
void _onDoubleTap() {
// 游客双击:与点赞按钮一致守卫登录(《01-开发文档》2.1.1 游客不可点赞)
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
setState(() => _likeBurstTrigger++);
// 双击:未点赞等同点赞;已点赞只播动画不改状态
if (!widget.item.isLiked) {
widget.onLike(widget.item.videoId);
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.pool.toggleCurrent,
onDoubleTap: _onDoubleTap,
child: Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
// 底部渐变(提升文字可读性)
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 160,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black54],
),
),
),
),
),
// 播放器
VideoPlayerView(
controller: widget.pool.controllerAt(widget.slotIndex),
coverUrl: widget.item.coverUrl,
playing: widget.isCurrent && widget.pool.isCurrentPlaying,
),
// 双击爱心动画
IgnorePointer(child: LikeBurst(trigger: _likeBurstTrigger)),
// 右侧互动栏
Positioned(
right: 8,
bottom: 96,
child: InteractionBar(
item: widget.item,
onLike: widget.onLike,
onFavorite: widget.onFavorite,
onComment: widget.onComment,
onShare: widget.onShare,
),
),
// 左下信息区
Positioned(
left: 12,
right: 88,
bottom: 24,
child: VideoInfoSection(item: widget.item),
),
],
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
import '../../../providers/auth_provider.dart';
/// 右侧互动栏(《01-开发文档》5.2.1):
/// 作者头像(+关注预留)、点赞(红心+计数)、评论(气泡+计数)、收藏(星标+计数)、分享
class InteractionBar extends ConsumerWidget {
const InteractionBar({
super.key,
required this.item,
required this.onLike,
this.onFavorite,
this.onComment,
this.onShare,
});
final VideoItem item;
final void Function(int videoId) onLike;
/// 收藏回调(与点赞一样由上层接当前策略的 feedProvider)
final void Function(int videoId)? onFavorite;
/// 评论入口(游客也可打开查看,发表时才守卫登录)
final void Function(VideoItem item)? onComment;
/// 分享入口(系统分享面板,无需登录)
final void Function(VideoItem item)? onShare;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 作者头像(关注预留:右下角 + 号占位)
_AuthorAvatar(item: item),
const SizedBox(height: 20),
_ActionItem(
icon: item.isLiked ? Icons.favorite : Icons.favorite_border,
color: item.isLiked ? AppColors.primary : Colors.white,
count: item.likeCount,
onTap: () => _guard(context, ref, () => onLike(item.videoId)),
),
const SizedBox(height: 16),
_ActionItem(
icon: Icons.chat_bubble_outline,
count: item.commentCount,
onTap: () => onComment?.call(item),
),
const SizedBox(height: 16),
_ActionItem(
icon: item.isFavorited ? Icons.star : Icons.star_border,
color: item.isFavorited ? AppColors.gold : Colors.white,
count: item.favoriteCount,
onTap: () => _guard(context, ref, () => onFavorite?.call(item.videoId)),
),
const SizedBox(height: 16),
_ActionItem(
icon: Icons.share_outlined,
count: null,
label: '分享',
onTap: () => onShare?.call(item),
),
],
);
}
/// 登录守卫:未登录跳登录页
void _guard(BuildContext context, WidgetRef ref, VoidCallback action) {
if (!ref.read(authProvider).isLoggedIn) {
context.go('/login');
return;
}
action();
}
}
class _AuthorAvatar extends StatelessWidget {
const _AuthorAvatar({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
// 点头像进作者公开主页(游客可看)
return GestureDetector(
onTap: () => context.push('/user/${item.author.userId}'),
child: SizedBox(
width: 48,
height: 56,
child: Stack(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColors.surface,
backgroundImage: item.author.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(item.author.avatarUrl)
: null,
child: item.author.avatarUrl.isEmpty
? Text(
item.author.nickname.isNotEmpty
? item.author.nickname.characters.first
: '友',
style: const TextStyle(color: Colors.white, fontSize: 18),
)
: null,
),
const Positioned(
bottom: 0,
left: 12,
child: Icon(Icons.add_circle, size: 20, color: AppColors.primary),
),
],
),
),
);
}
}
class _ActionItem extends StatelessWidget {
const _ActionItem({
required this.icon,
required this.onTap,
this.count,
this.label,
this.color = Colors.white,
});
final IconData icon;
final VoidCallback onTap;
final int? count;
final String? label;
final Color color;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: color),
const SizedBox(height: 2),
Text(
label ?? _formatCount(count ?? 0),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
shadows: [Shadow(color: Colors.black54, blurRadius: 2)],
),
),
],
),
);
}
/// 计数格式化:≥10000 显示 x.xw
static String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
return n.toString();
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_theme.dart';
import '../../../models/video_item.dart';
/// 左下信息区(《01-开发文档》5.2.1):
/// 作者昵称、标题(标题后 #标签,点击跳转标签视频页)、描述(展开/收起)
class VideoInfoSection extends StatefulWidget {
const VideoInfoSection({super.key, required this.item});
final VideoItem item;
@override
State<VideoInfoSection> createState() => _VideoInfoSectionState();
}
class _VideoInfoSectionState extends State<VideoInfoSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final item = widget.item;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 昵称
Text(
'@${item.author.nickname}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
const SizedBox(height: 8),
// 标题 + #标签(点击标签跳转标签视频页)
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
if (item.title.isNotEmpty)
Text(
item.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
for (final tag in item.tags)
GestureDetector(
onTap: () => context.push('/tags/${Uri.encodeComponent(tag)}'),
child: Text(
'#$tag',
style: const TextStyle(
color: AppColors.secondary,
fontSize: 15,
fontWeight: FontWeight.w600,
shadows: [Shadow(color: Colors.black54, blurRadius: 4)],
),
),
),
],
),
// 描述(展开/收起)
if (item.description.isNotEmpty) ...[
const SizedBox(height: 6),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Text(
item.description,
maxLines: _expanded ? null : 1,
overflow: _expanded ? null : TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
),
if (!_expanded && item.description.length > 30)
const Text(
'展开',
style: TextStyle(color: AppColors.textWeak, fontSize: 12),
),
],
],
);
}
}
@@ -0,0 +1,52 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit_video/media_kit_video.dart';
/// media_kit Video 包装:封面兜底、缓冲转圈、暂停态大播放图标
class VideoPlayerView extends StatelessWidget {
const VideoPlayerView({
super.key,
required this.controller,
required this.coverUrl,
required this.playing,
});
final VideoController controller;
final String coverUrl;
final bool playing;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
// 播放器(无控制条,纯画面)
Video(
controller: controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
// 封面兜底:加载/无地址时可见(HLS 起播后被画面覆盖)
if (coverUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// 暂停态大播放图标
if (!playing)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_arrow_rounded,
size: 88,
color: Colors.white70,
),
),
),
],
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_theme.dart';
import '../../screens/feed/feed_screen.dart';
import '../../screens/profile/mine_screen.dart';
/// 首页壳:底部 Tab(首页/分类/发布/消息/我的),02-技术文档信息架构
/// 首页 Tab 为 Feed 播放页,我的页已接入,其余 Tab 随迭代接入;IndexedStack 保活各 Tab 状态
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _index,
children: const [
FeedScreen(),
_TabPlaceholder('分类'),
_TabPlaceholder('发布'),
_TabPlaceholder('消息'),
MineScreen(),
],
),
bottomNavigationBar: NavigationBar(
backgroundColor: AppColors.surface,
indicatorColor: AppColors.primary.withValues(alpha: 0.2),
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home, color: AppColors.primary),
label: '首页',
),
NavigationDestination(
icon: Icon(Icons.grid_view_outlined),
label: '分类',
),
NavigationDestination(
icon: Icon(Icons.add_box_outlined),
label: '发布',
),
NavigationDestination(
icon: Icon(Icons.notifications_none),
label: '消息',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
label: '我的',
),
],
),
);
}
}
/// Tab 占位(迭代期)
class _TabPlaceholder extends StatelessWidget {
const _TabPlaceholder(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'$title(随迭代接入)',
style: const TextStyle(color: AppColors.textSecondary, fontSize: 15),
),
);
}
}
@@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../models/membership_models.dart';
import '../../providers/auth_provider.dart';
import '../../providers/member_provider.dart';
import '../feed/widgets/feed_overlays.dart';
/// 会员中心:当前状态卡 + 套餐列表 + 权益说明 + 开通历史
/// 游客可浏览套餐,点开通时引导登录。
class MembershipScreen extends ConsumerWidget {
const MembershipScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(memberProvider);
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
return Scaffold(
appBar: AppBar(title: const Text('会员中心')),
body: state.isLoading && state.plans.isEmpty
? const Center(child: CircularProgressIndicator())
: state.loadFailed && state.plans.isEmpty
? _LoadFailed(onRetry: () => ref.read(memberProvider.notifier).load())
: RefreshIndicator(
onRefresh: () => ref.read(memberProvider.notifier).load(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (isLoggedIn && state.me != null)
_StatusCard(me: state.me!),
if (isLoggedIn && state.me != null)
const SizedBox(height: 16),
// 套餐列表
...state.plans.map((plan) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _PlanCard(
plan: plan,
isLifetimeOwned:
state.me?.isLifetime == true,
redeeming: state.redeeming,
onRedeem: () =>
_onRedeem(context, ref, plan),
),
)),
// 权益说明
if (state.benefits.isNotEmpty) ...[
const SizedBox(height: 8),
const Text('会员权益',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
...state.benefits.map((b) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
const Icon(Icons.check_circle,
size: 14, color: AppColors.gold),
const SizedBox(width: 6),
Expanded(
child: Text(b,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13)),
),
],
),
)),
],
// 开通历史
if (isLoggedIn &&
state.me != null &&
state.me!.history.isNotEmpty) ...[
const SizedBox(height: 20),
const Text('开通记录',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
...state.me!.history.map(_HistoryTile.new),
],
],
),
),
);
}
Future<void> _onRedeem(
BuildContext context, WidgetRef ref, MembershipPlan plan) async {
// 游客 → 引导登录
if (!ref.read(authProvider).isLoggedIn) {
showTopToast(context, '请先登录后再开通会员');
context.push('/login');
return;
}
final points = ref.read(authProvider).user?.points ?? 0;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text('开通${plan.name}', style: const TextStyle(fontSize: 16)),
content: Text(
'需消耗 ${plan.points} 积分,当前积分 $points。\n'
'${plan.days == 0 ? '有效期:终身' : '有效期:${plan.days} 天'}',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child:
const Text('确认开通', style: TextStyle(color: AppColors.gold)),
),
],
),
);
if (ok != true || !context.mounted) return;
try {
final result =
await ref.read(memberProvider.notifier).redeem(plan.planCode);
if (context.mounted) {
showTopToast(context,
'开通成功:${result.planName},剩余积分 ${result.remainingPoints}');
}
} on ApiException catch (e) {
if (!context.mounted) return;
if (e.code == 2004) {
showTopToast(context, '终身卡已开通,无需重复购买');
} else if (e.isPointsInsufficient) {
showTopToast(context, '积分不足,已为你打开充值页');
context.push('/recharge');
} else {
showTopToast(context, e.message);
}
} catch (_) {
if (context.mounted) showTopToast(context, '开通失败,请稍后重试');
}
}
}
/// 当前会员状态卡
class _StatusCard extends StatelessWidget {
const _StatusCard({required this.me});
final MyMemberState me;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppColors.goldGradient,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.workspace_premium, size: 36, color: Colors.black87),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
me.isMember
? (me.isLifetime ? '终身会员' : (me.planName ?? '会员'))
: '暂未开通会员',
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
me.isMember
? (me.isLifetime
? '永久有效'
: '剩余 ${me.remainingDays} 天 · ${me.expireAt ?? ''} 到期')
: '开通会员尊享多项特权',
style:
const TextStyle(color: Colors.black54, fontSize: 12),
),
],
),
),
],
),
);
}
}
/// 套餐卡片
class _PlanCard extends StatelessWidget {
const _PlanCard({
required this.plan,
required this.isLifetimeOwned,
required this.redeeming,
required this.onRedeem,
});
final MembershipPlan plan;
/// 已持有终身卡:所有套餐不可再开通(终身卡重复开通 2004)
final bool isLifetimeOwned;
final bool redeeming;
final VoidCallback onRedeem;
@override
Widget build(BuildContext context) {
final disabled = !plan.enabled || isLifetimeOwned || redeeming;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: plan.days == 0
? Border.all(color: AppColors.goldDeep, width: 1.2)
: null,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(plan.name,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700)),
if (plan.days == 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5, vertical: 1),
decoration: BoxDecoration(
gradient: AppColors.goldGradient,
borderRadius: BorderRadius.circular(4),
),
child: const Text('推荐',
style: TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w700)),
),
],
],
),
const SizedBox(height: 4),
Text(
plan.days == 0 ? '终身有效' : '${plan.days} 天',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
if (plan.desc.isNotEmpty) ...[
const SizedBox(height: 2),
Text(plan.desc,
style: const TextStyle(
color: AppColors.textWeak, fontSize: 12)),
],
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${plan.points} 积分',
style: const TextStyle(
color: AppColors.gold,
fontSize: 15,
fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
SizedBox(
height: 34,
child: ElevatedButton(
onPressed: disabled ? null : onRedeem,
style: ElevatedButton.styleFrom(
minimumSize: const Size(84, 34),
padding: const EdgeInsets.symmetric(horizontal: 14),
textStyle: const TextStyle(fontSize: 13),
),
child: Text(isLifetimeOwned ? '已开通' : '开通'),
),
),
],
),
],
),
);
}
}
/// 开通历史条目
class _HistoryTile extends StatelessWidget {
const _HistoryTile(this.item);
final MemberHistoryItem item;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(item.planName, style: const TextStyle(fontSize: 14)),
subtitle: Text(
'${item.startAt} ~ ${item.endAt.isEmpty ? '终身' : item.endAt} · ${item.sourceLabel}',
style: const TextStyle(color: AppColors.textWeak, fontSize: 12),
),
trailing: Text('${item.pointsCost} 积分',
style: const TextStyle(color: AppColors.gold, fontSize: 13)),
);
}
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('点击重试')),
],
),
);
}
}
@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/notification_provider.dart';
import '../../services/notification_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 消息通知页(《03-接口文档》10.2~10.4):
/// 类型筛选 + 触底分页 + 单条/全部已读;审核结果跳我的作品,提现进度跳资产页
class NotificationsScreen extends ConsumerStatefulWidget {
const NotificationsScreen({super.key});
@override
ConsumerState<NotificationsScreen> createState() =>
_NotificationsScreenState();
}
class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final _scroll = ScrollController();
static const List<Map<String, dynamic>> _filters = [
{'label': '全部', 'value': null},
{'label': '审核', 'value': 1},
{'label': '提现', 'value': 2},
{'label': '公告', 'value': 3},
{'label': '回复', 'value': 4},
];
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _onScroll() {
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 200) {
ref.read(notificationsProvider.notifier).loadMore();
}
}
@override
Widget build(BuildContext context) {
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
if (!isLoggedIn) {
return Scaffold(
appBar: AppBar(title: const Text('消息通知')),
body: _GuestGuide(onLogin: () => context.push('/login')),
);
}
final state = ref.watch(notificationsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('消息通知'),
actions: [
TextButton(
onPressed: state.markingAll || state.items.isEmpty
? null
: () => _markAll(context),
child: const Text('全部已读'),
),
],
),
body: Column(
children: [
// 类型筛选
SizedBox(
height: 52,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: _filters.map((f) {
final active = state.type == f['value'];
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f['label'] as String),
selected: active,
onSelected: (_) => ref
.read(notificationsProvider.notifier)
.setType(f['value'] as int?),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active ? Colors.white : AppColors.textSecondary,
),
),
);
}).toList(),
),
),
Expanded(child: _buildBody(state)),
],
),
);
}
Widget _buildBody(NotificationsState state) {
if (state.isLoading && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.loadFailed && state.items.isEmpty) {
return _LoadFailed(
onRetry: () => ref.read(notificationsProvider.notifier).load(),
);
}
if (state.items.isEmpty) {
return const Center(
child: Text('暂无消息', style: TextStyle(color: AppColors.textWeak)),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(notificationsProvider.notifier).load(),
child: ListView.separated(
controller: _scroll,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: state.items.length + (state.hasMore ? 1 : 0),
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
final item = state.items[index];
return _NotificationTile(
item: item,
onTap: () => _onTapItem(item),
);
},
),
);
}
Future<void> _onTapItem(NotificationItem item) async {
await ref.read(notificationsProvider.notifier).markOneRead(item);
if (!mounted) return;
// 按类型跳关联页面
switch (item.type) {
case 1: // 审核结果 → 我的作品
context.push('/mine/works');
break;
case 2: // 提现进度 → 资产页
context.push('/assets');
break;
default:
break;
}
}
Future<void> _markAll(BuildContext context) async {
try {
await ref.read(notificationsProvider.notifier).markAllRead();
} catch (_) {
if (context.mounted) showTopToast(context, '操作失败,请稍后重试');
}
}
}
class _NotificationTile extends StatelessWidget {
const _NotificationTile({required this.item, required this.onTap});
final NotificationItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 未读红点
Padding(
padding: const EdgeInsets.only(top: 6),
child: Icon(
Icons.circle,
size: 8,
color: item.isRead ? Colors.transparent : AppColors.primary,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 类型标签
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 1,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(3),
),
child: Text(
item.typeLabel,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 10,
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
item.title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: item.isRead
? FontWeight.w400
: FontWeight.w600,
color: item.isRead
? AppColors.textSecondary
: Colors.white,
),
),
),
],
),
if (item.content.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
],
const SizedBox(height: 4),
Text(
item.createdAt,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
),
],
),
),
);
}
}
class _GuestGuide extends StatelessWidget {
const _GuestGuide({required this.onLogin});
final VoidCallback onLogin;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.notifications_none,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后查看消息通知',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onLogin,
style: ElevatedButton.styleFrom(
minimumSize: const Size(160, 44),
),
child: const Text('去登录'),
),
],
),
);
}
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('点击重试')),
],
),
);
}
}
@@ -0,0 +1,545 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/payment_provider.dart';
import '../../services/pay_launcher.dart';
import '../../services/payment_service.dart';
import '../../services/user_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 充值页:余额展示 / 套餐网格 / 自定义金额 / 支付方式 / 立即支付
class RechargeScreen extends ConsumerWidget {
const RechargeScreen({super.key});
static const Map<int, IconData> _methodIcons = {
1: Icons.account_balance,
2: Icons.chat_bubble,
3: Icons.paid,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(paymentProvider);
final user = ref.watch(authProvider).user;
return Scaffold(
appBar: AppBar(title: const Text('充值')),
body: state.isLoading && state.result == null
? const Center(child: CircularProgressIndicator())
: state.loadFailed && state.result == null
? _LoadFailed(
onRetry: () => ref.read(paymentProvider.notifier).load(),
)
: RefreshIndicator(
onRefresh: () => ref.read(paymentProvider.notifier).load(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 当前积分
_PointsHeader(points: user?.points ?? 0),
const SizedBox(height: 20),
// 套餐网格
if (state.result?.packages.isNotEmpty == true)
_PackageGrid(
packages: state.result!.packages,
selectedId: state.selectedPackageId,
onSelect: (id) =>
ref.read(paymentProvider.notifier).selectPackage(id),
),
// 自定义金额
if (state.result?.custom.enabled == true) ...[
const SizedBox(height: 12),
_CustomAmountInput(
custom: state.result!.custom,
value: state.customYuan,
onChanged: (v) => ref
.read(paymentProvider.notifier)
.setCustomYuan(v),
),
],
const SizedBox(height: 20),
// 支付方式
if (state.result?.payMethods.isNotEmpty == true)
_PayMethodSelector(
methods: state.result!.payMethods,
selected: state.selectedMethod,
onSelect: (m) =>
ref.read(paymentProvider.notifier).selectMethod(m),
),
const SizedBox(height: 24),
// 立即支付
ElevatedButton(
onPressed: state.creating ? null : () => _pay(context, ref),
child: state.creating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
'立即支付${state.amountYuan != null ? ' ¥${state.amountYuan}' : ''}',
style: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 12),
const Center(
child: Text(
'1 元 = 10 积分,充值后积分实时到账',
style: TextStyle(
color: AppColors.textWeak, fontSize: 12),
),
),
],
),
),
);
}
Future<void> _pay(BuildContext context, WidgetRef ref) async {
final notifier = ref.read(paymentProvider.notifier);
final current = ref.read(paymentProvider);
if (current.amountYuan == null || current.selectedMethod == null) {
showTopToast(context, '请选择充值金额与支付方式');
return;
}
try {
final order = await notifier.createOrder();
if (!context.mounted) return;
if (order.isUsdt) {
context.push('/recharge/usdt', extra: order);
return;
}
await _launchAndPoll(context, order);
} on ApiException catch (e) {
if (!context.mounted) return;
if (e.code == 1001) {
showTopToast(context, '充值金额不在可选档位,请重新选择');
} else {
showTopToast(context, e.message);
}
} catch (_) {
if (context.mounted) showTopToast(context, '下单失败,请稍后重试');
}
}
/// 调起原生 SDK;无有效调起参数时降级为「外部完成支付」轮询弹窗
Future<void> _launchAndPoll(
BuildContext context, RechargeOrderInfo order) async {
// 服务端未配置商户密钥(dev):返回 dev_mock,直接走轮询兜底
if (order.payParams['dev_mock'] == true) {
_showAliWechatSheet(context, order);
return;
}
final result = await PayLauncher.launch(order);
if (!context.mounted) return;
switch (result) {
case PayLaunchResult.success:
case PayLaunchResult.unsupported:
// 成功仍需服务端查单确认;参数不可用时降级到手动轮询
_showAliWechatSheet(context, order);
case PayLaunchResult.canceled:
showTopToast(context, '已取消支付');
case PayLaunchResult.failed:
showTopToast(context, '调起支付失败,请确认已安装对应 App');
}
}
/// 支付宝/微信:底部轮询弹窗(无 SDK 集成期以「我已完成支付」兜底)
void _showAliWechatSheet(BuildContext context, RechargeOrderInfo order) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => _AliWechatPollSheet(order: order),
);
}
}
/// 顶部积分余额
class _PointsHeader extends StatelessWidget {
const _PointsHeader({required this.points});
final int points;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.account_balance_wallet_outlined,
color: AppColors.gold),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前积分',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 12)),
Text('$points',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.gold)),
],
),
],
),
);
}
}
/// 套餐网格
class _PackageGrid extends StatelessWidget {
const _PackageGrid({
required this.packages,
required this.selectedId,
required this.onSelect,
});
final List<RechargePackage> packages;
final int? selectedId;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 10,
runSpacing: 10,
children: packages.map((pkg) {
final selected = selectedId == pkg.id;
return GestureDetector(
onTap: () => onSelect(pkg.id),
child: Container(
width: (MediaQuery.of(context).size.width - 42) / 2,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(10),
border: selected
? Border.all(color: AppColors.primary, width: 1.5)
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('¥${pkg.amountYuan}',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
if (pkg.bonus > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
gradient: AppColors.goldGradient,
borderRadius: BorderRadius.circular(4),
),
child: Text('赠 ${pkg.bonus}',
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w700)),
),
],
),
const SizedBox(height: 4),
Text('到账 ${pkg.totalPoints} 积分',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12)),
],
),
),
);
}).toList(),
);
}
}
/// 自定义金额输入
class _CustomAmountInput extends StatefulWidget {
const _CustomAmountInput({
required this.custom,
required this.value,
required this.onChanged,
});
final CustomRechargeConfig custom;
final int? value;
final ValueChanged<int?> onChanged;
@override
State<_CustomAmountInput> createState() => _CustomAmountInputState();
}
class _CustomAmountInputState extends State<_CustomAmountInput> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(
text: widget.value == null ? '' : widget.value.toString());
}
@override
void didUpdateWidget(covariant _CustomAmountInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.value != oldWidget.value) {
final newText = widget.value == null ? '' : widget.value.toString();
if (_ctrl.text != newText) {
_ctrl.text = newText;
}
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _ctrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: InputDecoration(
hintText: '自定义金额(${widget.custom.minYuan}-${widget.custom.maxYuan} 元)',
prefixIcon: const Icon(Icons.edit_outlined),
suffixText: '元',
),
onChanged: (v) {
final n = int.tryParse(v);
widget.onChanged(n);
},
);
}
}
/// 支付方式单选
class _PayMethodSelector extends StatelessWidget {
const _PayMethodSelector({
required this.methods,
required this.selected,
required this.onSelect,
});
final List<PayMethod> methods;
final int? selected;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('支付方式',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 10),
...methods.map((m) {
final active = selected == m.method;
final icon = RechargeScreen._methodIcons[m.method] ?? Icons.payment;
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: Icon(icon, color: AppColors.gold),
title: Text(m.name),
trailing: active
? const Icon(Icons.check_circle, color: AppColors.primary)
: const Icon(Icons.radio_button_off,
color: AppColors.textWeak),
onTap: () => onSelect(m.method),
);
}),
],
);
}
}
/// 支付宝/微信轮询弹窗(底部 Sheet)
class _AliWechatPollSheet extends ConsumerStatefulWidget {
const _AliWechatPollSheet({required this.order});
final RechargeOrderInfo order;
@override
ConsumerState<_AliWechatPollSheet> createState() =>
_AliWechatPollSheetState();
}
class _AliWechatPollSheetState
extends ConsumerState<_AliWechatPollSheet> {
@override
Widget build(BuildContext context) {
final poll = ref.watch(orderPollProvider(widget.order));
// 状态变化时自动关闭(成功/超时/关闭)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (poll.status == OrderPollStatus.paid) {
_refreshUser();
_showResult('支付成功', '已到账 ${widget.order.points} 积分');
} else if (poll.status == OrderPollStatus.closed) {
_showResult('订单已关闭', '该订单已被取消或超时');
} else if (poll.status == OrderPollStatus.timeout) {
_showResult('查询超时', '请前往「钱包-充值记录」稍后查看结果');
}
});
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.textWeak,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(
'支付中…',
style:
const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
'订单号:${widget.order.orderNo}',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
const SizedBox(height: 16),
const SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(strokeWidth: 3),
),
const SizedBox(height: 16),
Text(
widget.order.isUsdt
? '请完成 USDT 转账'
: '请在外部完成支付,完成后返回本页',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: 20),
TextButton(
onPressed: () => _cancel(context),
child: const Text('取消订单',
style: TextStyle(color: AppColors.textSecondary)),
),
],
),
),
);
}
void _cancel(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('取消订单', style: TextStyle(fontSize: 16)),
content: const Text('取消后该笔订单将关闭,是否继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('继续支付'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child:
const Text('取消订单', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (ok == true) {
await ref.read(orderPollProvider(widget.order).notifier).cancel();
}
}
void _refreshUser() async {
// 支付成功后刷新全局用户信息(积分余额)
try {
final user = await UserService.fetchMe();
await ref.read(authProvider.notifier).onLoginSuccess(user);
} catch (_) {}
}
void _showResult(String title, String body) {
if (!context.mounted) return;
Navigator.of(context).pop();
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text(title, style: const TextStyle(fontSize: 16)),
content: Text(body),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
context.pop();
},
child: const Text('确定'),
),
],
),
);
}
@override
void dispose() {
// 停止轮询避免后台泄漏
ref.read(orderPollProvider(widget.order).notifier).stop();
super.dispose();
}
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('点击重试')),
],
),
);
}
}
@@ -0,0 +1,290 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/payment_provider.dart';
import '../../providers/auth_provider.dart';
import '../../services/payment_service.dart';
import '../../services/user_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// USDT 支付页:金额(含尾差)/ 收款地址 / 网络 / 倒计时 / 轮询到账 / 取消订单
class UsdtPayScreen extends ConsumerStatefulWidget {
const UsdtPayScreen({super.key, required this.order});
final RechargeOrderInfo order;
@override
ConsumerState<UsdtPayScreen> createState() => _UsdtPayScreenState();
}
class _UsdtPayScreenState extends ConsumerState<UsdtPayScreen> {
Timer? _ticker;
Duration _remain = Duration.zero;
bool _resultHandled = false;
@override
void initState() {
super.initState();
_remain = _calcRemain();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
final r = _calcRemain();
if (!mounted) return;
setState(() => _remain = r);
});
}
Duration _calcRemain() {
final expire = DateTime.tryParse(widget.order.expireAt);
if (expire == null) return Duration.zero;
final diff = expire.difference(DateTime.now());
return diff.isNegative ? Duration.zero : diff;
}
@override
void dispose() {
_ticker?.cancel();
ref.read(orderPollProvider(widget.order).notifier).stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final poll = ref.watch(orderPollProvider(widget.order));
final order = widget.order;
// 轮询结果处理(仅一次)
if (!_resultHandled && poll.status != OrderPollStatus.polling) {
_resultHandled = true;
WidgetsBinding.instance.addPostFrameCallback((_) => _onResult(poll));
}
final mm = _remain.inMinutes.remainder(60).toString().padLeft(2, '0');
final ss = _remain.inSeconds.remainder(60).toString().padLeft(2, '0');
return Scaffold(
appBar: AppBar(title: const Text('USDT 支付')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 金额卡
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
const Text('请精确转账以下金额(含尾差用于订单匹配)',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 12)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
order.usdtAmount ?? '-',
style: const TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: AppColors.gold),
),
const SizedBox(width: 6),
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text('USDT',
style: TextStyle(
color: AppColors.textSecondary, fontSize: 14)),
),
],
),
const SizedBox(height: 6),
Text(
'≈ ¥${order.amountYuan}(汇率 ${order.usdtRate ?? '-'})· 到账 ${order.points} 积分',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
],
),
),
const SizedBox(height: 12),
// 收款信息
_InfoRow(
label: '收款地址',
value: order.receiveAddress ?? '-',
onCopy: () => _copy(order.receiveAddress, '地址已复制'),
),
_InfoRow(label: '网络', value: order.network ?? 'TRC20'),
_InfoRow(label: '订单号', value: order.orderNo),
const SizedBox(height: 12),
// 倒计时 + 轮询状态
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
_remain == Duration.zero
? '订单已过期,请重新下单'
: '等待链上确认(19 个区块)… 剩余 $mm:$ss',
style: const TextStyle(fontSize: 13),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'注意事项:\n'
'· 请务必按显示金额精确转账,金额不符将无法自动到账\n'
'· 仅支持 TRC20 网络,其他网络转账无法找回\n'
'· 满 19 个区块确认后积分自动到账',
style: TextStyle(color: AppColors.textWeak, fontSize: 12, height: 1.6),
),
const SizedBox(height: 20),
TextButton(
onPressed: () => _cancel(context),
child: const Text('取消订单',
style: TextStyle(color: AppColors.textSecondary)),
),
],
),
);
}
void _copy(String? text, String tip) {
if (text == null || text.isEmpty) return;
Clipboard.setData(ClipboardData(text: text));
showTopToast(context, tip);
}
void _refreshUser() async {
// 支付成功后刷新全局用户信息(积分余额)
try {
final user = await UserService.fetchMe();
await ref.read(authProvider.notifier).onLoginSuccess(user);
} catch (_) {}
}
Future<void> _cancel(BuildContext context) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('取消订单', style: TextStyle(fontSize: 16)),
content: const Text('取消后该笔订单将关闭,是否继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('继续支付'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child:
const Text('取消订单', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (ok != true || !context.mounted) return;
try {
await ref.read(orderPollProvider(widget.order).notifier).cancel();
} catch (_) {
if (context.mounted) showTopToast(context, '取消失败,请稍后重试');
}
}
void _onResult(OrderPollState poll) {
if (!mounted) return;
if (poll.status == OrderPollStatus.paid) {
_refreshUser();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('充值成功', style: TextStyle(fontSize: 16)),
content: Text(
'已到账 ${widget.order.points} 积分,当前余额 ${poll.walletPoints ?? '-'}'),
actions: [
TextButton(
onPressed: () {
Navigator.pop(ctx);
context.pop(true); // 返回充值页并标记已支付
},
child: const Text('确定'),
),
],
),
);
} else {
showTopToast(
context, poll.status == OrderPollStatus.timeout ? '查询超时,请稍后在钱包查看' : '订单已关闭');
context.pop();
}
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value, this.onCopy});
final String label;
final String value;
final VoidCallback? onCopy;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 1),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
color: AppColors.surface,
child: Row(
children: [
SizedBox(
width: 64,
child: Text(label,
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 13)),
),
Expanded(
child: Text(
value,
style: const TextStyle(fontSize: 13),
overflow: TextOverflow.ellipsis,
),
),
if (onCopy != null)
GestureDetector(
onTap: onCopy,
child: const Padding(
padding: EdgeInsets.only(left: 8),
child:
Icon(Icons.copy, size: 16, color: AppColors.textSecondary),
),
),
],
),
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../core/config/test_stream.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../core/storage/watch_dedup_store.dart';
import '../../models/video_item.dart';
import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 单视频播放页(搜索结果/用户主页/作品/收藏/历史共用):
/// 与 Feed 一致的观看计费(开始播放即上报,按 video_id+日期本地去重),
/// 游客超限 2002 / 积分不足 2001 弹窗逻辑复用 feed_overlays。
class SimplePlayerScreen extends StatefulWidget {
const SimplePlayerScreen({super.key, required this.item});
final VideoItem item;
@override
State<SimplePlayerScreen> createState() => _SimplePlayerScreenState();
}
class _SimplePlayerScreenState extends State<SimplePlayerScreen> {
late final Player _player = Player();
late final VideoController _controller = VideoController(_player);
bool _playing = true;
@override
void initState() {
super.initState();
_player.open(Media(resolvePlayUrl(widget.item)));
_reportWatch();
}
@override
void dispose() {
_player.dispose();
super.dispose();
}
/// 观看上报(计费):本地去重后上报,扣费 Toast / 超限弹窗
Future<void> _reportWatch() async {
final dedup = WatchDedupStore.I;
if (dedup.hasWatched(widget.item.videoId)) return;
await dedup.markWatched(widget.item.videoId);
try {
final result = await VideoService.reportWatch(widget.item.videoId);
if (!mounted) return;
if (result.isPointsCost) {
showTopToast(context, '本次消耗 ${result.costPoints} 积分');
}
} on ApiException catch (e) {
if (!mounted) return;
_player.pause();
if (e.isGuestLimit) {
showGuestLimitDialog(context);
} else if (e.isPointsInsufficient) {
showPointsInsufficientDialog(
context,
(e.data?['points'] as num?)?.toInt() ?? 0,
);
} else {
showTopToast(context, e.message);
}
} catch (_) {}
}
void _toggle() {
setState(() => _playing = !_playing);
_playing ? _player.play() : _player.pause();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggle,
child: Stack(
fit: StackFit.expand,
children: [
// 播放器
Center(
child: Video(
controller: _controller,
controls: NoVideoControls,
fit: BoxFit.contain,
),
),
// 暂停态大播放图标
if (!_playing)
const IgnorePointer(
child: Center(
child: Icon(
Icons.play_circle_outline,
size: 72,
color: Colors.white70,
),
),
),
// 返回按钮
const SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: BackButton(color: Colors.white),
),
),
// 底部标题/作者
Positioned(
left: 12,
right: 12,
bottom: 24,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'@${widget.item.author.nickname}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
widget.item.title,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/profile_provider.dart';
import '../../providers/upload_provider.dart';
import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 编辑作品(《03-接口文档》5.12 / 5.13,仅作者):
/// 可改标题/简介/分类/标签;删除为服务端软删除,二次确认后立即下架。
class EditVideoScreen extends ConsumerStatefulWidget {
const EditVideoScreen({super.key, required this.video});
final VideoItem video;
@override
ConsumerState<EditVideoScreen> createState() => _EditVideoScreenState();
}
class _EditVideoScreenState extends ConsumerState<EditVideoScreen> {
late final TextEditingController _titleCtrl;
late final TextEditingController _descCtrl;
late final TextEditingController _tagsCtrl;
late int _categoryId;
bool _submitting = false;
@override
void initState() {
super.initState();
final v = widget.video;
_titleCtrl = TextEditingController(text: v.title);
_descCtrl = TextEditingController(text: v.description);
_tagsCtrl = TextEditingController(text: v.tags.join(' '));
_categoryId = v.categoryId;
}
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
_tagsCtrl.dispose();
super.dispose();
}
/// 空格/逗号/顿号分隔,去重,最多 5 个
List<String> _parseTags() => _tagsCtrl.text
.split(RegExp(r'[\s,,、]+'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet()
.take(5)
.toList();
void _toast(String message) {
if (mounted) showTopToast(context, message);
}
Future<void> _save() async {
final title = _titleCtrl.text.trim();
if (title.isEmpty) {
_toast('请填写标题');
return;
}
setState(() => _submitting = true);
try {
await VideoService.updateVideo(
widget.video.videoId,
title: title,
description: _descCtrl.text.trim(),
categoryId: _categoryId,
tags: _parseTags(),
);
// 刷新我的作品列表,返回后展示最新信息
ref.invalidate(myVideosProvider);
_toast('已保存');
if (mounted) Navigator.of(context).maybePop();
} on ApiException catch (e) {
_toast(e.message);
} catch (_) {
_toast('网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('删除作品'),
content: const Text('删除后作品立即下架且不可恢复,确定删除?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消',
style: TextStyle(color: AppColors.textSecondary)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child:
const Text('删除', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (confirmed != true) return;
setState(() => _submitting = true);
try {
await VideoService.deleteVideo(widget.video.videoId);
ref.invalidate(myVideosProvider);
_toast('已删除');
if (mounted) Navigator.of(context).maybePop();
} on ApiException catch (e) {
_toast(e.message);
} catch (_) {
_toast('网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final categories = ref.watch(uploadCategoriesProvider);
return Scaffold(
appBar: AppBar(title: const Text('编辑作品')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _titleCtrl,
enabled: !_submitting,
maxLength: 100,
decoration: const InputDecoration(labelText: '标题'),
),
const SizedBox(height: 8),
TextField(
controller: _descCtrl,
enabled: !_submitting,
maxLength: 500,
maxLines: 3,
decoration: const InputDecoration(labelText: '简介'),
),
const SizedBox(height: 8),
const Text('分类', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 8),
categories.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('分类加载失败',
style: TextStyle(color: AppColors.textWeak, fontSize: 12)),
data: (list) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final c in list)
ChoiceChip(
label: Text(c.name),
selected: _categoryId == c.categoryId,
onSelected: _submitting
? null
: (_) => setState(() => _categoryId = c.categoryId),
),
],
),
),
const SizedBox(height: 16),
TextField(
controller: _tagsCtrl,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: '标签',
hintText: '空格分隔,最多 5 个',
),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submitting ? null : _save,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 48)),
child: const Text('保存'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: _submitting ? null : _confirmDelete,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 48),
side: const BorderSide(color: AppColors.primary),
),
child: const Text('删除作品',
style: TextStyle(color: AppColors.primary)),
),
],
),
);
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/theme/app_theme.dart';
import '../../services/invite_service.dart';
/// 邀请好友页:我的邀请码 + 推广统计 + 一键复制推广文案(含口令与下载链接)
class InviteScreen extends StatefulWidget {
const InviteScreen({super.key});
@override
State<InviteScreen> createState() => _InviteScreenState();
}
class _InviteScreenState extends State<InviteScreen> {
late Future<ReferralInfo> _future;
@override
void initState() {
super.initState();
_future = InviteService.fetchMyReferral();
}
Future<void> _copyShareText(ReferralInfo info) async {
final text = InviteService.buildShareText(
inviteCode: info.inviteCode,
inviteUrl: info.inviteUrl,
);
await Clipboard.setData(ClipboardData(text: text));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('推广文案已复制,快去分享给好友吧')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('邀请好友')),
body: FutureBuilder<ReferralInfo>(
future: _future,
builder: (context, snap) {
if (snap.hasError) {
return Center(
child: TextButton(
onPressed: () => setState(() => _future = InviteService.fetchMyReferral()),
child: const Text('加载失败,点击重试'),
),
);
}
final info = snap.data;
if (info == null) {
return const Center(child: CircularProgressIndicator());
}
return ListView(
padding: const EdgeInsets.all(20),
children: [
// 邀请码卡片
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primary, AppColors.secondary],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
const Text('我的邀请码', style: TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 8),
Text(
info.inviteCode,
style: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w800,
letterSpacing: 4,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _copyShareText(info),
icon: const Icon(Icons.copy, size: 18),
label: const Text('复制推广文案'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.primary,
),
),
),
],
),
),
const SizedBox(height: 24),
// 推广统计
Row(
children: [
_Stat(label: '累计邀请', value: '${info.totalInvitees}'),
_Stat(label: '已激活', value: '${info.activatedCount}'),
_Stat(label: '奖励积分', value: '${info.rewardPointsTotal}'),
_Stat(label: '返利资产', value: info.rebateAssetsTotal.toStringAsFixed(1)),
],
),
const SizedBox(height: 24),
// 规则说明
const Text('邀请规则', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
const _Rule(text: '好友通过你的推广链接或口令注册,即绑定为你的下级'),
const _Rule(text: '下级 72 小时内观看满 3 个视频即激活,你得邀请积分奖励'),
const _Rule(text: '下级充值积分,你按比例的返利资产(可提现)'),
if (!info.eligible) ...[
const SizedBox(height: 8),
const _Rule(text: '开通会员后才可享受邀请奖励资格', highlight: true),
],
],
);
},
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(label, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
],
),
);
}
}
class _Rule extends StatelessWidget {
const _Rule({required this.text, this.highlight = false});
final String text;
final bool highlight;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('· ', style: TextStyle(color: highlight ? AppColors.gold : AppColors.textSecondary)),
Expanded(
child: Text(
text,
style: TextStyle(
color: highlight ? AppColors.gold : AppColors.textSecondary,
fontSize: 13,
height: 1.5,
),
),
),
],
),
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/notification_provider.dart';
/// 我的页(首页 Tab4):
/// 游客 → 登录引导;已登录 → 资料卡 + 功能入口(作品/收藏/历史/钱包/会员/消息)
class MineScreen extends ConsumerStatefulWidget {
const MineScreen({super.key});
@override
ConsumerState<MineScreen> createState() => _MineScreenState();
}
class _MineScreenState extends ConsumerState<MineScreen> {
@override
void initState() {
super.initState();
// 进入我的页刷新未读消息数(角标)
Future.microtask(
() => ref.read(unreadCountProvider.notifier).refresh(),
);
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
if (!auth.isLoggedIn) return const _GuestGuide();
final unread = ref.watch(unreadCountProvider);
final user = auth.user!;
return SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 资料卡(点击进入编辑资料)
GestureDetector(
onTap: () => context.push('/profile/edit'),
behavior: HitTestBehavior.opaque,
child: Row(
children: [
CircleAvatar(
radius: 32,
backgroundColor: AppColors.surface,
backgroundImage: user.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(user.avatarUrl)
: null,
child: user.avatarUrl.isEmpty
? Text(
user.nickname.isNotEmpty
? user.nickname.characters.first
: '友',
style: const TextStyle(fontSize: 22),
)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
user.nickname,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
if (user.isMember) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
gradient: AppColors.goldGradient,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'会员',
style: TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
'积分 ${user.points}',
style: const TextStyle(
color: AppColors.gold,
fontSize: 13,
),
),
],
),
),
const Icon(Icons.arrow_forward_ios,
size: 14, color: AppColors.textWeak),
],
),
),
const SizedBox(height: 24),
// 功能入口
_Entry(
icon: Icons.edit_outlined,
label: '编辑资料',
onTap: () => context.push('/profile/edit'),
),
_Entry(
icon: Icons.add_circle_outline,
label: '发布作品',
onTap: () => context.push('/upload'),
),
_Entry(
icon: Icons.video_library_outlined,
label: '我的作品',
onTap: () => context.push('/mine/works'),
),
_Entry(
icon: Icons.star_outline,
label: '我的收藏',
onTap: () => context.push('/mine/favorites'),
),
_Entry(
icon: Icons.history,
label: '观看历史',
onTap: () => context.push('/mine/history'),
),
_Entry(
icon: Icons.account_balance_wallet_outlined,
label: '钱包',
onTap: () => context.push('/wallet'),
),
_Entry(
icon: Icons.workspace_premium_outlined,
label: '会员中心',
onTap: () => context.push('/membership'),
),
_Entry(
icon: Icons.card_giftcard,
label: '邀请好友',
onTap: () => context.push('/invite'),
),
_Entry(
icon: Icons.savings_outlined,
label: '收益资产',
onTap: () => context.push('/assets'),
),
_Entry(
icon: Icons.notifications_none,
label: '消息通知',
badge: unread,
onTap: () => context
.push('/notifications')
.then((_) => ref.read(unreadCountProvider.notifier).refresh()),
),
_Entry(
icon: Icons.settings_outlined,
label: '设置',
onTap: () => context.push('/settings'),
),
],
),
);
}
}
/// 游客引导
class _GuestGuide extends StatelessWidget {
const _GuestGuide();
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_outline,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后查看个人中心',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/login'),
style: ElevatedButton.styleFrom(
minimumSize: const Size(160, 44),
),
child: const Text('去登录 / 注册'),
),
],
),
);
}
}
class _Entry extends StatelessWidget {
const _Entry({
required this.icon,
required this.label,
required this.onTap,
this.badge = 0,
});
final IconData icon;
final String label;
final VoidCallback onTap;
/// 未读角标数,0 不显示
final int badge;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: AppColors.textSecondary),
title: Text(label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (badge > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(9),
),
child: Text(
badge > 99 ? '99+' : '$badge',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward_ios,
size: 14, color: AppColors.textWeak),
],
),
onTap: onTap,
);
}
}
@@ -0,0 +1,221 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/user_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 编辑资料(《03-接口文档》4.2 / 4.3):
/// 头像点击即选图上传(jpg/png,≤5MB);昵称/简介/性别点「保存」提交。
class ProfileEditScreen extends ConsumerStatefulWidget {
const ProfileEditScreen({super.key});
@override
ConsumerState<ProfileEditScreen> createState() => _ProfileEditScreenState();
}
class _ProfileEditScreenState extends ConsumerState<ProfileEditScreen> {
late final TextEditingController _nicknameCtrl;
late final TextEditingController _bioCtrl;
late String _avatarUrl;
late int _gender;
bool _uploadingAvatar = false;
bool _saving = false;
final _picker = ImagePicker();
@override
void initState() {
super.initState();
final user = ref.read(authProvider).user;
_nicknameCtrl = TextEditingController(text: user?.nickname ?? '');
_bioCtrl = TextEditingController(text: user?.bio ?? '');
_avatarUrl = user?.avatarUrl ?? '';
_gender = user?.gender ?? 0;
}
@override
void dispose() {
_nicknameCtrl.dispose();
_bioCtrl.dispose();
super.dispose();
}
void _toast(String message) {
if (mounted) showTopToast(context, message);
}
/// 选头像 → 立即上传(上传成功即生效,无需再点保存)
Future<void> _pickAvatar() async {
if (_uploadingAvatar) return;
final picked = await _picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
);
if (picked == null) return;
setState(() => _uploadingAvatar = true);
try {
final url = await UserService.uploadAvatar(
filePath: picked.path,
fileName: picked.name,
);
if (!mounted) return;
setState(() => _avatarUrl = url);
// 拉取最新用户信息同步我的页资料卡
ref.read(authProvider.notifier).updateUser(await UserService.fetchMe());
_toast('头像已更新');
} on ApiException catch (e) {
_toast(e.message);
} catch (_) {
_toast('网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _uploadingAvatar = false);
}
}
Future<void> _save() async {
final nickname = _nicknameCtrl.text.trim();
if (nickname.isEmpty) {
_toast('请填写昵称');
return;
}
setState(() => _saving = true);
try {
final updated = await UserService.updateMe(
nickname: nickname,
bio: _bioCtrl.text.trim(),
gender: _gender,
);
ref.read(authProvider.notifier).updateUser(updated);
_toast('已保存');
if (mounted) Navigator.of(context).maybePop();
} on ApiException catch (e) {
_toast(e.message);
} catch (_) {
_toast('网络异常,请稍后重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
// 未登录兜底(正常由入口守卫拦截)
if (!ref.watch(authProvider).isLoggedIn) {
return Scaffold(
appBar: AppBar(title: const Text('编辑资料')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.person_outline,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后编辑资料',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.push('/login'),
style:
ElevatedButton.styleFrom(minimumSize: const Size(160, 44)),
child: const Text('去登录 / 注册'),
),
],
),
),
);
}
final busy = _saving || _uploadingAvatar;
return Scaffold(
appBar: AppBar(title: const Text('编辑资料')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 头像(点击即选图上传)
Center(
child: GestureDetector(
onTap: _pickAvatar,
child: Stack(
alignment: Alignment.center,
children: [
CircleAvatar(
radius: 44,
backgroundColor: AppColors.surface,
backgroundImage: _avatarUrl.isNotEmpty
? CachedNetworkImageProvider(_avatarUrl)
: null,
child: _avatarUrl.isEmpty
? const Icon(Icons.person_outline,
size: 40, color: AppColors.textWeak)
: null,
),
if (_uploadingAvatar)
const SizedBox(
width: 88,
height: 88,
child: CircularProgressIndicator(
color: AppColors.primary, strokeWidth: 2),
),
],
),
),
),
const SizedBox(height: 8),
const Center(
child: Text('点击更换头像(jpg/png,≤5MB)',
style: TextStyle(color: AppColors.textWeak, fontSize: 12)),
),
const SizedBox(height: 24),
TextField(
controller: _nicknameCtrl,
enabled: !busy,
maxLength: 20,
decoration: const InputDecoration(labelText: '昵称'),
),
const SizedBox(height: 8),
TextField(
controller: _bioCtrl,
enabled: !busy,
maxLength: 200,
maxLines: 3,
decoration: const InputDecoration(labelText: '简介'),
),
const SizedBox(height: 8),
const Text('性别', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final option in const [
(0, '不公开'),
(1, '男'),
(2, '女'),
])
ChoiceChip(
label: Text(option.$2),
selected: _gender == option.$1,
onSelected: busy
? null
: (_) => setState(() => _gender = option.$1),
),
],
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: busy ? null : _save,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 48)),
child: const Text('保存'),
),
],
),
);
}
}
@@ -0,0 +1,211 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/profile_provider.dart';
/// 他人公开主页(《03-接口文档》4.7/4.8,游客可看):
/// 头像/昵称/简介/作品数/获赞数 + 已发布作品双列网格,点击进单视频播放页
class UserProfileScreen extends ConsumerWidget {
const UserProfileScreen({super.key, required this.userId});
final int userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(profileProvider(userId));
final notifier = ref.read(profileProvider(userId).notifier);
return Scaffold(
appBar: AppBar(title: Text(state.profile?.nickname ?? '用户主页')),
body: state.isLoading && state.profile == null
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.loadFailed
? Center(
child: TextButton(
onPressed: notifier.load,
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
)
: CustomScrollView(
slivers: [
// 头部资料卡
SliverToBoxAdapter(child: _ProfileHeader(state: state)),
// 作品网格
if (state.works.isEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(
child: Text('暂无作品',
style: TextStyle(
color: AppColors.textSecondary)),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount: state.works.length,
itemBuilder: (_, index) =>
_WorkCard(item: state.works[index]),
),
),
],
),
);
}
}
class _ProfileHeader extends StatelessWidget {
const _ProfileHeader({required this.state});
final ProfileState state;
@override
Widget build(BuildContext context) {
final profile = state.profile;
if (profile == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: AppColors.surface,
backgroundImage: profile.avatarUrl.isNotEmpty
? CachedNetworkImageProvider(profile.avatarUrl)
: null,
child: profile.avatarUrl.isEmpty
? Text(
profile.nickname.isNotEmpty
? profile.nickname.characters.first
: '友',
style: const TextStyle(fontSize: 28),
)
: null,
),
const SizedBox(height: 12),
Text(
profile.nickname,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
if (profile.bio.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
profile.bio,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
],
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Stat(count: profile.videoCount, label: '作品'),
const SizedBox(width: 40),
_Stat(count: profile.totalLikes, label: '获赞'),
],
),
],
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.count, required this.label});
final int count;
final String label;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
count >= 10000
? '${(count / 10000).toStringAsFixed(1)}w'
: '$count',
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
Text(
label,
style: const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
],
);
}
}
class _WorkCard extends StatelessWidget {
const _WorkCard({required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
)
: const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
),
// 底部标题条
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.all(6),
child: Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,305 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/network/api_exception.dart';
import '../../core/theme/app_theme.dart';
import '../../models/user_profile.dart';
import '../../models/video_item.dart';
import '../../providers/profile_provider.dart';
import '../../services/user_service.dart';
import '../../services/video_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 我的作品 / 我的收藏(双列网格,结构相同仅数据源不同)
class VideoGridScreen extends ConsumerWidget {
VideoGridScreen.works({super.key})
: title = '我的作品',
provider = myVideosProvider,
editable = true;
VideoGridScreen.favorites({super.key})
: title = '我的收藏',
provider = myFavoritesProvider,
editable = false;
final String title;
final AutoDisposeFutureProvider<PagedVideos> provider;
/// 我的作品可长按管理(编辑/删除),收藏不可
final bool editable;
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncPage = ref.watch(provider);
return Scaffold(
appBar: AppBar(title: Text(title)),
body: asyncPage.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
error: (_, __) => Center(
child: TextButton(
onPressed: () => ref.invalidate(provider),
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
),
data: (page) => page.items.isEmpty
? const Center(
child: Text('暂无内容',
style: TextStyle(color: AppColors.textSecondary)),
)
: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount: page.items.length,
itemBuilder: (_, index) =>
_GridCard(item: page.items[index], editable: editable),
),
),
);
}
}
class _GridCard extends ConsumerWidget {
const _GridCard({required this.item, this.editable = false});
final VideoItem item;
/// 长按弹出管理菜单(仅我的作品)
final bool editable;
/// 我的作品卡片点击:仅已发布可播放,其余状态提示原因
void _onTap(BuildContext context) {
if (item.status == -1 || item.status == 2) {
context.push('/search/play', extra: item);
return;
}
showTopToast(
context,
item.status == 3 && item.rejectReason.isNotEmpty
? '审核拒绝:${item.rejectReason}'
: '视频${item.statusText},暂不可播放',
);
}
void _showManageSheet(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>(
context: context,
backgroundColor: AppColors.surface,
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('编辑信息'),
onTap: () {
Navigator.pop(sheetCtx);
context.push('/mine/works/edit', extra: item);
},
),
ListTile(
leading: const Icon(Icons.delete_outline,
color: AppColors.primary),
title: const Text('删除作品',
style: TextStyle(color: AppColors.primary)),
onTap: () {
Navigator.pop(sheetCtx);
_confirmDelete(context, ref);
},
),
],
),
),
);
}
Future<void> _confirmDelete(BuildContext context, WidgetRef ref) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('删除作品'),
content: const Text('删除后作品立即下架且不可恢复,确定删除?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消',
style: TextStyle(color: AppColors.textSecondary)),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child:
const Text('删除', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await VideoService.deleteVideo(item.videoId);
ref.invalidate(myVideosProvider);
if (context.mounted) showTopToast(context, '已删除');
} on ApiException catch (e) {
if (context.mounted) showTopToast(context, e.message);
} catch (_) {
if (context.mounted) showTopToast(context, '网络异常,请稍后重试');
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () => _onTap(context),
onLongPress: editable ? () => _showManageSheet(context, ref) : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.expand,
children: [
Container(
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
)
: const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
),
// 作品状态角标(我的作品:待审核/拒绝/下架等)
if (item.statusText.isNotEmpty)
Positioned(
left: 6,
top: 6,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: item.status == 3
? AppColors.primary
: item.status == 2
? AppColors.secondary
: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
item.statusText,
style: const TextStyle(
color: Colors.black,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.all(6),
child: Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
),
),
),
],
),
),
);
}
}
/// 观看历史(列表:封面缩略图 + 标题 + 观看时间 + 计费方式)
class HistoryScreen extends ConsumerWidget {
const HistoryScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncList = ref.watch(myHistoryProvider);
return Scaffold(
appBar: AppBar(title: const Text('观看历史')),
body: asyncList.when(
loading: () => const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
error: (_, __) => Center(
child: TextButton(
onPressed: () => ref.invalidate(myHistoryProvider),
child: const Text('加载失败,点击重试',
style: TextStyle(color: AppColors.primary)),
),
),
data: (items) => items.isEmpty
? const Center(
child: Text('暂无观看记录',
style: TextStyle(color: AppColors.textSecondary)),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (_, index) => _HistoryTile(item: items[index]),
),
),
);
}
}
class _HistoryTile extends StatelessWidget {
const _HistoryTile({required this.item});
final HistoryItem item;
@override
Widget build(BuildContext context) {
return ListTile(
onTap: () => context.push('/search/play', extra: item.video),
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Container(
width: 72,
height: 48,
color: AppColors.surface,
child: item.video.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.video.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
const Icon(Icons.play_circle_outline, size: 24),
)
: const Icon(Icons.play_circle_outline, size: 24),
),
),
title: Text(
item.video.title.isNotEmpty ? item.video.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${item.watchedAt.length >= 16 ? item.watchedAt.substring(5, 16) : item.watchedAt} · ${item.costLabel}',
style: const TextStyle(color: AppColors.textWeak, fontSize: 12),
),
);
}
}
+358
View File
@@ -0,0 +1,358 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../models/video_item.dart';
import '../../providers/search_provider.dart';
/// 搜索页(《01-开发文档》2.5.8):
/// 默认页:本地搜索历史(最近 10 条)+ 热搜榜 Top10
/// 结果页:热度/最新排序切换 + 双列封面网格,点击进单视频播放页
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _inputController = TextEditingController();
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
ref.read(searchProvider.notifier).loadHot();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(searchProvider.notifier).loadMore();
}
}
void _submit(String keyword) =>
ref.read(searchProvider.notifier).search(keyword);
@override
Widget build(BuildContext context) {
final state = ref.watch(searchProvider);
final notifier = ref.read(searchProvider.notifier);
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 搜索输入栏
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 20),
onPressed: () => context.pop(),
),
Expanded(
child: TextField(
controller: _inputController,
autofocus: true,
maxLength: 30,
textInputAction: TextInputAction.search,
onSubmitted: _submit,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
hintText: '搜索视频标题或标签',
counterText: '',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
),
),
TextButton(
onPressed: () => _submit(_inputController.text),
child: const Text(
'搜索',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
// 内容:默认页 / 结果页
Expanded(
child: state.searched
? _buildResults(state, notifier)
: _buildDefault(state, notifier),
),
],
),
),
);
}
/// 默认页:搜索历史 + 热搜榜
Widget _buildDefault(SearchState state, SearchNotifier notifier) {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
if (state.history.isNotEmpty) ...[
Row(
children: [
const Expanded(
child: Text(
'搜索历史',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20, color: AppColors.textSecondary),
onPressed: notifier.clearHistory,
),
],
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final word in state.history)
GestureDetector(
onTap: () {
_inputController.text = word;
_submit(word);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
),
child: Text(
word,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 13,
),
),
),
),
],
),
const SizedBox(height: 24),
],
const Text(
'热搜榜',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
if (state.hotWords.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'暂无热搜',
style: TextStyle(color: AppColors.textWeak),
),
),
)
else
for (var i = 0; i < state.hotWords.length; i++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: SizedBox(
width: 24,
child: Text(
'${i + 1}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
// 前三名品牌红,其余灰色
color: i < 3 ? AppColors.primary : AppColors.textWeak,
),
),
),
title: Text(state.hotWords[i]),
onTap: () {
_inputController.text = state.hotWords[i];
_submit(state.hotWords[i]);
},
),
],
);
}
/// 结果页:排序切换 + 双列网格
Widget _buildResults(SearchState state, SearchNotifier notifier) {
return Column(
children: [
// 排序切换
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
for (final s in SearchSort.values) ...[
GestureDetector(
onTap: () => notifier.search(state.keyword, sort: s),
child: Text(
s.label,
style: TextStyle(
fontSize: 14,
fontWeight: s == state.sort
? FontWeight.w700
: FontWeight.w400,
color: s == state.sort
? Colors.white
: AppColors.textSecondary,
),
),
),
const SizedBox(width: 20),
],
const Spacer(),
if (state.total > 0)
Text(
'共 ${state.total} 条',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: state.isSearching
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.items.isEmpty
? const Center(
child: Text(
'未找到相关视频',
style: TextStyle(color: AppColors.textSecondary),
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount:
state.items.length + (state.isLoadingMore ? 2 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
return VideoGridCard(item: state.items[index]);
},
),
),
],
);
}
}
/// 视频网格卡片:封面 + 标题 + 作者 + 播放量(搜索结果 / 标签视频页共用)
class VideoGridCard extends StatelessWidget {
const VideoGridCard({super.key, required this.item});
final VideoItem item;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.push('/search/play', extra: item),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 封面
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
width: double.infinity,
color: AppColors.surface,
child: item.coverUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
)
: const Icon(
Icons.play_circle_outline,
color: AppColors.textWeak,
size: 40,
),
),
),
),
const SizedBox(height: 4),
Text(
item.title.isNotEmpty ? item.title : '未命名视频',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
Text(
'${item.author.nickname} · ${_formatCount(item.viewCount)}次播放',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 11,
),
),
],
),
);
}
static String _formatCount(int n) {
if (n >= 10000) return '${(n / 10000).toStringAsFixed(1)}w';
return n.toString();
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/search_provider.dart';
import '../../providers/tag_videos_provider.dart';
import 'search_screen.dart';
/// 标签视频页(《01-开发文档》5.1 /tags/:name,《03-接口文档》5.9,游客可):
/// 展示该标签下全部已发布视频,支持 热度/最新 排序切换 + 触底加载更多
class TagVideosScreen extends ConsumerStatefulWidget {
const TagVideosScreen({super.key, required this.tagName});
final String tagName;
@override
ConsumerState<TagVideosScreen> createState() => _TagVideosScreenState();
}
class _TagVideosScreenState extends ConsumerState<TagVideosScreen> {
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(tagVideosProvider(widget.tagName).notifier).loadMore();
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(tagVideosProvider(widget.tagName));
final notifier = ref.read(tagVideosProvider(widget.tagName).notifier);
return Scaffold(
appBar: AppBar(title: Text('# ${widget.tagName}')),
body: Column(
children: [
// 排序切换 + 总数
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
for (final s in SearchSort.values) ...[
GestureDetector(
onTap: () => notifier.load(sort: s),
child: Text(
s.label,
style: TextStyle(
fontSize: 14,
fontWeight: s == state.sort
? FontWeight.w700
: FontWeight.w400,
color: s == state.sort
? Colors.white
: AppColors.textSecondary,
),
),
),
const SizedBox(width: 20),
],
const Spacer(),
if (state.total > 0)
Text(
'共 ${state.total} 条',
style: const TextStyle(
color: AppColors.textWeak,
fontSize: 12,
),
),
],
),
),
Expanded(
child: state.isLoading
? const Center(
child: CircularProgressIndicator(color: AppColors.primary),
)
: state.items.isEmpty
? Center(
child: state.loadFailed
? TextButton(
onPressed: () => notifier.load(),
child: const Text(
'加载失败,点击重试',
style: TextStyle(color: AppColors.primary),
),
)
: const Text(
'该标签下暂无视频',
style: TextStyle(
color: AppColors.textSecondary),
),
)
: GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount:
state.items.length + (state.isLoadingMore ? 2 : 0),
itemBuilder: (context, index) {
if (index >= state.items.length) {
return const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
);
}
return VideoGridCard(item: state.items[index]);
},
),
),
],
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/config/app_config.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
/// 设置页(《01-开发文档》5.1 /settings):版本信息 + 退出登录
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
return Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(
children: [
// 版本信息
const ListTile(
leading: Icon(Icons.info_outline, color: AppColors.textSecondary),
title: Text('当前版本'),
trailing: Text(
'v${AppConfig.appVersion} (${AppConfig.appVersionCode})',
style: TextStyle(color: AppColors.textWeak, fontSize: 13),
),
),
if (isLoggedIn) ...[
const Divider(height: 32, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.logout, color: AppColors.primary),
title: const Text('退出登录',
style: TextStyle(color: AppColors.primary)),
onTap: () => _confirmLogout(context, ref),
),
],
],
),
);
}
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('退出登录', style: TextStyle(fontSize: 16)),
content: const Text('确定退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('退出', style: TextStyle(color: AppColors.primary)),
),
],
),
);
if (ok == true && context.mounted) {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go('/home');
}
}
}
+209
View File
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:uuid/uuid.dart';
import '../../core/config/app_config.dart';
import '../../core/network/api_client.dart';
import '../../core/storage/local_store.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../services/invite_service.dart';
import '../../services/version_service.dart';
/// 启动页:依次执行 ①设备指纹 ②域名发现+ApiClient 初始化 ③登录态恢复 ④版本/公告检查
class SplashScreen extends ConsumerStatefulWidget {
const SplashScreen({super.key});
@override
ConsumerState<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends ConsumerState<SplashScreen> {
String _step = '正在初始化…';
String? _notice;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
// ① 设备指纹(UUID v4,持久化)
_setStep('初始化设备…');
LocalStore.I.ensureDeviceId(() => const Uuid().v4());
// ② 域名发现(TXT → 兜底)+ 网络客户端初始化
_setStep('连接服务器…');
await ApiClient.init();
// ②.5 推广归因:剪贴板口令(主)→ 无口令则首启模糊匹配上报(辅)
try {
await _attributeInvite();
} catch (_) {
// 归因失败不阻塞启动
}
// ③ 登录态恢复(安全存储 Token → /users/me)
_setStep('恢复登录态…');
await ref.read(authProvider.notifier).restore();
// ④ 版本检查与维护公告(失败不阻塞进入首页)
try {
final info = await VersionService.check();
if (!mounted) return;
// 强制更新(当前 version_code 低于服务端最低要求):阻断使用
if (info.forceUpdate) {
await _showForceUpdateDialog(info);
return;
}
// 非强制新版本:可跳过的提示弹窗
if (info.latestVersionCode > AppConfig.appVersionCode) {
await _showUpdatePrompt(info);
if (!mounted) return;
}
setState(() => _notice = info.notice.isNotEmpty ? info.notice : null);
if (_notice == null) {
_goHome();
return;
}
// 有公告:展示 2 秒后进入
Future.delayed(const Duration(seconds: 2), _goHome);
} catch (_) {
_goHome();
}
} catch (e) {
_setStep('网络异常,请检查后重试');
// 域名发现彻底失败:提供重试入口
}
}
/// 推广归因:
/// - 剪贴板命中分享口令 → 存 pending 邀请码(注册页预填,准确率 100%)
/// - 无口令且未上报过 → 模糊匹配上报(每安装仅一次,命中结果服务端缓存)
Future<void> _attributeInvite() async {
final clip = await Clipboard.getData(Clipboard.kTextPlain);
final code = InviteService.parseInviteCommand(clip?.text);
if (code != null) {
await LocalStore.I.savePendingInviteCode(code);
return;
}
if (LocalStore.I.inviteMatchReported) return;
final matched = await InviteService.reportMatch(LocalStore.I.deviceId);
await LocalStore.I.markInviteMatchReported();
if (matched != null && matched.isNotEmpty) {
await LocalStore.I.savePendingInviteCode(matched);
}
}
void _setStep(String s) {
if (mounted) setState(() => _step = s);
}
void _goHome() {
if (!mounted) return;
// 游客可直接浏览(计费接口按 device_id 走),登录页由路由守卫按需唤起
context.go('/home');
}
/// 跳转下载页(商店/外部浏览器)
Future<void> _openUpdateUrl(String url) async {
if (url.isEmpty) return;
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// 调起失败不阻塞(强制更新场景弹窗仍在,可重试)
}
}
/// 强制更新弹窗:不可关闭、不可返回,仅可跳下载页(《01-开发文档》5.7)
Future<void> _showForceUpdateDialog(VersionInfo info) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => PopScope(
canPop: false,
child: AlertDialog(
backgroundColor: AppColors.surface,
title: const Text('版本过低', style: TextStyle(fontSize: 16)),
content: Text(
'当前版本已停止服务,请更新到 v${info.latestVersion} 后继续使用。',
),
actions: [
ElevatedButton(
onPressed: () => _openUpdateUrl(info.updateUrl),
child: const Text('立即更新'),
),
],
),
),
);
}
/// 非强制更新弹窗:可跳过;点更新跳下载页后继续进入 App
Future<void> _showUpdatePrompt(VersionInfo info) async {
final update = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: AppColors.surface,
title: Text('发现新版本 v${info.latestVersion}',
style: const TextStyle(fontSize: 16)),
content: const Text('建议更新到最新版本以获得更好的体验。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('跳过'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('立即更新'),
),
],
),
);
if (update == true) await _openUpdateUrl(info.updateUrl);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [AppColors.primary, AppColors.secondary]),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(Icons.play_arrow_rounded, size: 56, color: Colors.white),
),
const SizedBox(height: 24),
const Text('短视频', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700)),
const SizedBox(height: 40),
if (_notice != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(_notice!, textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.gold)),
)
else
Text(_step, style: const TextStyle(color: AppColors.textSecondary)),
if (_step.contains('异常')) ...[
const SizedBox(height: 16),
TextButton(onPressed: _bootstrap, child: const Text('点击重试')),
],
],
),
),
);
}
}
+326
View File
@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/upload_provider.dart';
import '../../services/upload_service.dart';
import '../feed/widgets/feed_overlays.dart';
/// 发布作品页(《03-接口文档》5.5/5.6):
/// 三步:选视频 → 填信息(标题/描述/分类/标签) → 发布结果(进我的作品查看审核状态)
class UploadScreen extends ConsumerStatefulWidget {
const UploadScreen({super.key});
@override
ConsumerState<UploadScreen> createState() => _UploadScreenState();
}
class _UploadScreenState extends ConsumerState<UploadScreen> {
final _titleCtrl = TextEditingController();
final _descCtrl = TextEditingController();
final _tagsCtrl = TextEditingController();
int? _categoryId;
@override
void dispose() {
_titleCtrl.dispose();
_descCtrl.dispose();
_tagsCtrl.dispose();
super.dispose();
}
/// 解析标签输入:空格/逗号/顿号分隔,去重,最多 5 个
List<String> _parseTags() {
return _tagsCtrl.text
.split(RegExp(r'[\s,,、]+'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toSet()
.take(5)
.toList();
}
void _submit() {
final title = _titleCtrl.text.trim();
if (title.isEmpty) {
showTopToast(context, '请填写标题');
return;
}
if (_categoryId == null) {
showTopToast(context, '请选择分类');
return;
}
ref.read(uploadProvider.notifier).submit(
title: title,
description: _descCtrl.text.trim(),
categoryId: _categoryId!,
tags: _parseTags(),
);
}
@override
Widget build(BuildContext context) {
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
if (!isLoggedIn) {
return Scaffold(
appBar: AppBar(title: const Text('发布作品')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.video_call_outlined,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后发布你的作品',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.push('/login'),
style:
ElevatedButton.styleFrom(minimumSize: const Size(160, 44)),
child: const Text('去登录 / 注册'),
),
],
),
),
);
}
// 发布失败:停留表单并提示服务端文案
ref.listen(uploadProvider, (prev, next) {
if (next.error.isNotEmpty && next.error != prev?.error) {
showTopToast(context, next.error);
}
});
final state = ref.watch(uploadProvider);
return Scaffold(
appBar: AppBar(title: const Text('发布作品')),
body: switch (state.step) {
UploadStep.pick => _PickStep(
onPick: () => ref.read(uploadProvider.notifier).pickVideo(),
),
UploadStep.edit || UploadStep.publishing => _EditStep(
state: state,
titleCtrl: _titleCtrl,
descCtrl: _descCtrl,
tagsCtrl: _tagsCtrl,
categoryId: _categoryId,
onCategoryChanged: (id) => setState(() => _categoryId = id),
onReselect: () => ref.read(uploadProvider.notifier).reset(),
onSubmit: _submit,
),
UploadStep.done => _DoneStep(result: state.result),
},
);
}
}
/// 第一步:选择视频
class _PickStep extends StatelessWidget {
const _PickStep({required this.onPick});
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.cloud_upload_outlined,
size: 72, color: AppColors.textWeak),
const SizedBox(height: 16),
const Text('选择相册中的视频',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 4),
const Text('支持 mp4 / mov 等常见格式,不超过 500MB',
style: TextStyle(color: AppColors.textWeak, fontSize: 12)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: onPick,
style: ElevatedButton.styleFrom(minimumSize: const Size(180, 48)),
child: const Text('选择视频'),
),
],
),
);
}
}
/// 第二步:填写作品信息(发布中显示进度)
class _EditStep extends ConsumerWidget {
const _EditStep({
required this.state,
required this.titleCtrl,
required this.descCtrl,
required this.tagsCtrl,
required this.categoryId,
required this.onCategoryChanged,
required this.onReselect,
required this.onSubmit,
});
final UploadState state;
final TextEditingController titleCtrl;
final TextEditingController descCtrl;
final TextEditingController tagsCtrl;
final int? categoryId;
final ValueChanged<int> onCategoryChanged;
final VoidCallback onReselect;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context, WidgetRef ref) {
final publishing = state.publishing;
final fileName = state.file?.name ?? '';
final sizeMB = (state.fileSize / 1024 / 1024).toStringAsFixed(1);
final asyncCategories = ref.watch(uploadCategoriesProvider);
return ListView(
padding: const EdgeInsets.all(16),
children: [
// 已选视频信息
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.videocam_outlined,
color: AppColors.textSecondary),
const SizedBox(width: 8),
Expanded(
child: Text(
'$fileName($sizeMB MB)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
),
if (!publishing)
TextButton(
onPressed: onReselect, child: const Text('重新选择')),
],
),
),
const SizedBox(height: 16),
TextField(
controller: titleCtrl,
enabled: !publishing,
maxLength: 100,
decoration: const InputDecoration(
labelText: '标题',
hintText: '给你的作品起个标题(必填)',
),
),
const SizedBox(height: 8),
TextField(
controller: descCtrl,
enabled: !publishing,
maxLength: 500,
maxLines: 3,
decoration: const InputDecoration(
labelText: '简介',
hintText: '补充介绍一下(选填)',
),
),
const SizedBox(height: 8),
const Text('分类', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 8),
asyncCategories.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('分类加载失败',
style: TextStyle(color: AppColors.textWeak, fontSize: 12)),
data: (categories) => Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final c in categories)
ChoiceChip(
label: Text(c.name),
selected: categoryId == c.categoryId,
onSelected:
publishing ? null : (_) => onCategoryChanged(c.categoryId),
),
],
),
),
const SizedBox(height: 16),
TextField(
controller: tagsCtrl,
enabled: !publishing,
decoration: const InputDecoration(
labelText: '标签',
hintText: '空格分隔,最多 5 个(选填)',
),
),
const SizedBox(height: 24),
if (publishing) ...[
LinearProgressIndicator(value: state.progress / 100),
const SizedBox(height: 8),
Text(
state.progress < 100 ? '上传中 ${state.progress}%' : '发布中…',
textAlign: TextAlign.center,
style:
const TextStyle(color: AppColors.textSecondary, fontSize: 12),
),
] else
ElevatedButton(
onPressed: onSubmit,
style: ElevatedButton.styleFrom(minimumSize: const Size(0, 48)),
child: const Text('发布'),
),
],
);
}
}
/// 第三步:发布结果(引导去我的作品查看审核进度)
class _DoneStep extends StatelessWidget {
const _DoneStep({required this.result});
final PublishResult? result;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.check_circle_outline,
size: 72, color: AppColors.secondary),
const SizedBox(height: 16),
const Text('发布成功',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
Text(
'当前状态:${result?.statusText ?? '待审核'},审核通过后自动上架',
textAlign: TextAlign.center,
style:
const TextStyle(color: AppColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => context.pushReplacement('/mine/works'),
style:
ElevatedButton.styleFrom(minimumSize: const Size(180, 44)),
child: const Text('查看我的作品'),
),
TextButton(
onPressed: () => Navigator.of(context).maybePop(),
child: const Text('返回',
style: TextStyle(color: AppColors.textSecondary)),
),
],
),
),
);
}
}
+362
View File
@@ -0,0 +1,362 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/wallet_provider.dart';
import '../../services/wallet_service.dart';
/// 钱包页:余额 / 累计充值·获得·消耗 / 评论奖励进度 / 积分明细(biz_type 筛选 + 触底分页)
class WalletScreen extends ConsumerWidget {
const WalletScreen({super.key});
static const List<Map<String, dynamic>> _filters = [
{'label': '全部', 'value': null},
{'label': '充值', 'value': 1},
{'label': '观看', 'value': 2},
{'label': '评论奖励', 'value': 3},
{'label': '会员', 'value': 4},
{'label': '赠送', 'value': 5},
{'label': '调整', 'value': 6},
{'label': '扣回', 'value': 7},
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(walletProvider);
final isLoggedIn = ref.watch(authProvider).isLoggedIn;
// 未登录时引导(钱包页入口 mine_screen 已做登录守卫,此处兜底)
if (!isLoggedIn) {
return Scaffold(
appBar: AppBar(title: const Text('钱包')),
body: _GuestGuide(onLogin: () => context.push('/login')),
);
}
return Scaffold(
appBar: AppBar(title: const Text('钱包')),
body: state.isLoading && state.wallet == null
? const Center(child: CircularProgressIndicator())
: state.loadFailed && state.wallet == null
? _LoadFailed(
onRetry: () => ref.read(walletProvider.notifier).load(),
)
: RefreshIndicator(
onRefresh: () => ref.read(walletProvider.notifier).load(),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 1 +
(state.records.isEmpty
? 1
: state.records.length + 1),
itemBuilder: (context, index) {
// 头部:余额 + 进度
if (index == 0) {
return Column(
children: [
_BalanceCard(wallet: state.wallet),
const SizedBox(height: 16),
_RewardProgress(
progress: state.wallet?.rewardProgress),
const SizedBox(height: 20),
// 筛选 chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _filters.map((f) {
final active = state.bizType == f['value'];
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f['label'] as String),
selected: active,
onSelected: (_) => ref
.read(walletProvider.notifier)
.setBizType(f['value'] as int?),
selectedColor: AppColors.primary,
backgroundColor: AppColors.surface,
labelStyle: TextStyle(
color: active
? Colors.white
: AppColors.textSecondary,
fontSize: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
);
}).toList(),
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 4),
],
);
}
// 明细列表
final listIndex = index - 1;
if (listIndex < state.records.length) {
final item = state.records[listIndex];
return _RecordTile(item: item);
}
if (listIndex == state.records.length) {
// 空态 / 加载更多
if (state.records.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Text(
'暂无明细',
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 14),
),
),
);
}
if (state.loadingMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)),
);
}
if (state.hasMore) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(walletProvider.notifier).loadMore();
});
}
return const SizedBox.shrink();
}
return const SizedBox.shrink();
},
),
),
);
}
}
/// 余额 + 累计概览
class _BalanceCard extends StatelessWidget {
const _BalanceCard({this.wallet});
final WalletInfo? wallet;
@override
Widget build(BuildContext context) {
final points = wallet?.points ?? 0;
final totalRecharge = wallet?.totalRecharge ?? 0;
final totalEarned = wallet?.totalEarned ?? 0;
final totalConsumed = wallet?.totalConsumed ?? 0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('当前积分',
style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
const SizedBox(height: 6),
Text('$points',
style: const TextStyle(
fontSize: 36, fontWeight: FontWeight.w800, color: AppColors.gold)),
const SizedBox(height: 14),
Row(
children: [
_StatColumn(label: '累计充值', value: '$totalRecharge'),
_StatColumn(label: '累计获得', value: '$totalEarned'),
_StatColumn(label: '累计消耗', value: '$totalConsumed'),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 40,
child: ElevatedButton(
onPressed: () => context.push('/recharge'),
child: const Text('充值', style: TextStyle(fontSize: 14)),
),
),
],
),
);
}
}
class _StatColumn extends StatelessWidget {
const _StatColumn({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
color: AppColors.textWeak, fontSize: 11)),
const SizedBox(height: 2),
Text(value,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w700)),
],
),
);
}
}
/// 评论奖励进度条
class _RewardProgress extends StatelessWidget {
const _RewardProgress({this.progress});
final CommentRewardProgress? progress;
@override
Widget build(BuildContext context) {
final cur = progress?.current ?? 0;
final target = progress?.target ?? 10;
final today = progress?.todayRewarded ?? 0;
final limit = progress?.dailyLimit ?? 5;
final ratio = target == 0 ? 0.0 : (cur / target).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('今日评论奖励进度',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
Text('$today / $limit 次',
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12)),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: ratio,
minHeight: 8,
backgroundColor: AppColors.textWeak.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.primary),
),
),
const SizedBox(height: 6),
Text(
'已评论 $cur 条,再评论 ${(target - cur).clamp(0, target)} 条即可领取奖励',
style: const TextStyle(
color: AppColors.textWeak, fontSize: 12),
),
],
),
);
}
}
/// 明细条目
class _RecordTile extends StatelessWidget {
const _RecordTile({required this.item});
final PointsRecord item;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(item.bizTypeLabel, style: const TextStyle(fontSize: 14)),
subtitle: item.remark.isNotEmpty
? Text(item.remark,
style: const TextStyle(
color: AppColors.textWeak, fontSize: 12))
: null,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${item.isExpense ? '' : '+'}${item.changeAmount}',
style: TextStyle(
color: item.isExpense ? AppColors.textSecondary : AppColors.gold,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
Text(item.createdAt,
style: const TextStyle(
color: AppColors.textWeak, fontSize: 11)),
],
),
);
}
}
class _GuestGuide extends StatelessWidget {
const _GuestGuide({required this.onLogin});
final VoidCallback onLogin;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.account_balance_wallet_outlined,
size: 64, color: AppColors.textWeak),
const SizedBox(height: 12),
const Text('登录后查看钱包',
style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: onLogin,
style: ElevatedButton.styleFrom(
minimumSize: const Size(160, 44),
),
child: const Text('去登录 / 注册'),
),
],
),
);
}
}
class _LoadFailed extends StatelessWidget {
const _LoadFailed({required this.onRetry});
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('加载失败', style: TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 12),
TextButton(onPressed: onRetry, child: const Text('点击重试')),
],
),
);
}
}