Files
video/app/lib/screens/upload/upload_screen.dart
T
2026-10-15 20:36:59 +08:00

327 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)),
),
],
),
),
);
}
}