add
This commit is contained in:
@@ -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('点击重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('点击重试')),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('点击重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user