Files
xianyan/lib/features/home/presentation/anonymous_submit_page.dart
Developer 83720002e6 feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
2026-06-19 06:43:55 +08:00

681 lines
22 KiB
Dart

/// ============================================================
/// 闲言APP — 匿名投稿页面
/// 创建时间: 2026-06-13
/// 更新时间: 2026-06-19
/// 作用: 匿名投稿 — 填写标题/分类/内容/作者,提交后审核中,记录仅本地保存
/// 上次更新: 类型安全修复(int vs num): fromMap 使用 SafeJson.parseInt
/// ============================================================
import 'dart:convert';
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:xianyan/core/utils/safe_json.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/storage/kv_storage.dart';
import '../../../core/utils/logger.dart';
import '../../../l10n/translations.dart';
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
import '../../../shared/widgets/feedback/app_toast.dart';
import '../../../shared/widgets/containers/glass_container.dart';
// ============================================================
// 数据模型
// ============================================================
/// 投稿记录状态
enum SubmitStatus { reviewing, approved, rejected }
/// 投稿记录
class SubmitRecord {
const SubmitRecord({
required this.id,
required this.category,
required this.content,
this.title,
this.author,
required this.status,
required this.createdAt,
});
final String id;
final String category;
final String content;
final String? title;
final String? author;
final SubmitStatus status;
final DateTime createdAt;
Map<String, dynamic> toMap() => {
'id': id,
'category': category,
'content': content,
'title': title,
'author': author,
'status': status.index,
'createdAt': createdAt.millisecondsSinceEpoch,
};
static SubmitRecord fromMap(Map<String, dynamic> map) => SubmitRecord(
id: map['id'] as String,
category: map['category'] as String,
content: map['content'] as String,
title: map['title'] as String?,
author: map['author'] as String?,
status: SubmitStatus.values[SafeJson.parseInt(map['status'])],
createdAt: DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(map['createdAt'])),
);
}
// ============================================================
// 状态管理
// ============================================================
class SubmitState {
const SubmitState({
this.records = const [],
this.isSubmitting = false,
});
final List<SubmitRecord> records;
final bool isSubmitting;
SubmitState copyWith({
List<SubmitRecord>? records,
bool? isSubmitting,
}) => SubmitState(
records: records ?? this.records,
isSubmitting: isSubmitting ?? this.isSubmitting,
);
}
class SubmitNotifier extends Notifier<SubmitState> {
static const _storageKey = 'anonymous_submit_records';
@override
SubmitState build() {
return SubmitState(records: _loadRecords());
}
List<SubmitRecord> _loadRecords() {
try {
final raw = KvStorage.getString(_storageKey);
if (raw != null && raw.isNotEmpty) {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => SubmitRecord.fromMap(e as Map<String, dynamic>))
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
} catch (e) {
Log.e('投稿记录加载失败', e);
}
return [];
}
void _saveRecords() {
try {
final json = jsonEncode(state.records.map((r) => r.toMap()).toList());
KvStorage.setString(_storageKey, json);
} catch (e) {
Log.e('投稿记录保存失败', e);
}
}
Future<void> submit({
required String category,
required String content,
String? title,
String? author,
}) async {
state = state.copyWith(isSubmitting: true);
await Future<void>.delayed(const Duration(milliseconds: 800));
final record = SubmitRecord(
id: DateTime.now().millisecondsSinceEpoch.toString(),
category: category,
content: content,
title: title?.trim().isEmpty == true ? null : title?.trim(),
author: author?.trim().isEmpty == true ? null : author?.trim(),
status: SubmitStatus.reviewing,
createdAt: DateTime.now(),
);
final updated = [record, ...state.records];
state = state.copyWith(records: updated, isSubmitting: false);
_saveRecords();
}
void deleteRecord(String id) {
final updated = state.records.where((r) => r.id != id).toList();
state = state.copyWith(records: updated);
_saveRecords();
}
}
final submitProvider =
NotifierProvider<SubmitNotifier, SubmitState>(SubmitNotifier.new);
// ============================================================
// 页面
// ============================================================
class AnonymousSubmitPage extends ConsumerStatefulWidget {
const AnonymousSubmitPage({super.key});
@override
ConsumerState<AnonymousSubmitPage> createState() =>
_AnonymousSubmitPageState();
}
class _AnonymousSubmitPageState extends ConsumerState<AnonymousSubmitPage> {
final _contentController = TextEditingController();
final _titleController = TextEditingController();
final _authorController = TextEditingController();
String _selectedCategory = 'yiyan';
@override
void initState() {
super.initState();
_contentController.addListener(() => setState(() {}));
}
@override
void dispose() {
_contentController.dispose();
_titleController.dispose();
_authorController.dispose();
super.dispose();
}
T get _t => ref.read(translationsProvider);
List<MapEntry<String, String>> get _categories => [
MapEntry('yiyan', _t.submit.catYiyan),
MapEntry('xinde', _t.submit.catXinde),
MapEntry('yiju', _t.submit.catYiju),
MapEntry('signature', _t.submit.catSignature),
];
// ── 提交 ──
Future<void> _handleSubmit() async {
final content = _contentController.text.trim();
if (content.isEmpty) {
AppToast.showInfo(_t.submit.contentRequired);
return;
}
if (content.length < 10) {
AppToast.showInfo(_t.submit.contentTooShort);
return;
}
if (content.length > 100) {
AppToast.showInfo(_t.submit.contentTooLong);
return;
}
await ref.read(submitProvider.notifier).submit(
category: _selectedCategory,
content: content,
title: _titleController.text.trim(),
author: _authorController.text.trim(),
);
_contentController.clear();
_titleController.clear();
_authorController.clear();
setState(() {});
if (mounted) {
AppToast.showInfo(_t.submit.reviewing);
}
}
void _deleteRecord(String id) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Text(_t.submit.deleteRecord),
content: Text(_t.submit.deleteConfirm),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.pop(ctx),
child: Text(_t.common.cancel),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
Navigator.pop(ctx);
ref.read(submitProvider.notifier).deleteRecord(id);
},
child: Text(_t.common.confirm),
),
],
),
);
}
// ── 构建 ──
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final submitState = ref.watch(submitProvider);
ref.watch(translationsProvider);
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
leading: const AdaptiveBackButton(),
middle: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(CupertinoIcons.pencil_ellipsis_rectangle,
color: ext.accent, size: 20),
const SizedBox(width: 6),
Text(
_t.submit.title,
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
],
),
border: null,
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
),
child: SafeArea(
bottom: false,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _buildForm(ext).animate().fadeIn(duration: 300.ms),
),
SliverToBoxAdapter(
child: _buildSubmitButton(ext, submitState)
.animate()
.fadeIn(duration: 300.ms, delay: 100.ms),
),
SliverToBoxAdapter(
child: _buildHistorySection(ext, submitState)
.animate()
.fadeIn(duration: 300.ms, delay: 200.ms),
),
const SliverToBoxAdapter(child: SizedBox(height: 120)),
],
),
),
);
}
// ── 表单 ──
Widget _buildForm(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.lg),
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCategoryPicker(ext),
const SizedBox(height: AppSpacing.md),
_buildField(
ext: ext,
label: _t.submit.titleLabel,
hint: _t.submit.titleHint,
controller: _titleController,
),
const SizedBox(height: AppSpacing.md),
_buildField(
ext: ext,
label: _t.submit.authorLabel,
hint: _t.submit.authorHint,
controller: _authorController,
),
const SizedBox(height: AppSpacing.md),
_buildContentField(ext),
],
),
),
);
}
Widget _buildCategoryPicker(AppThemeExtension ext) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.tag, size: 16, color: ext.textSecondary),
const SizedBox(width: 4),
Text(
_t.submit.categoryLabel,
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: 8,
runSpacing: 8,
children: _categories.map((entry) {
final isSelected = _selectedCategory == entry.key;
return GestureDetector(
onTap: () => setState(() => _selectedCategory = entry.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.12)
: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: isSelected
? ext.accent.withValues(alpha: 0.4)
: ext.textHint.withValues(alpha: 0.12),
width: isSelected ? 1.2 : 0.5,
),
),
child: Text(
entry.value,
style: AppTypography.subhead.copyWith(
color: isSelected ? ext.accent : ext.textSecondary,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}).toList(),
),
],
);
}
Widget _buildField({
required AppThemeExtension ext,
required String label,
required String hint,
required TextEditingController controller,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label,
style: AppTypography.subhead.copyWith(
color: ext.textSecondary, fontWeight: FontWeight.w600)),
const SizedBox(width: 4),
Text(hint,
style:
AppTypography.caption1.copyWith(color: ext.textHint)),
],
),
const SizedBox(height: AppSpacing.xs),
CupertinoTextField(
controller: controller,
style: AppTypography.body.copyWith(color: ext.textPrimary),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
padding: const EdgeInsets.all(AppSpacing.md),
maxLength: 20,
clearButtonMode: OverlayVisibilityMode.editing,
),
],
);
}
Widget _buildContentField(AppThemeExtension ext) {
final contentLength = _contentController.text.trim().length;
final isValid = contentLength >= 10 && contentLength <= 100;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(_t.submit.contentLabel,
style: AppTypography.subhead.copyWith(
color: ext.textSecondary, fontWeight: FontWeight.w600)),
const SizedBox(width: 4),
Text(_t.submit.contentHint,
style:
AppTypography.caption1.copyWith(color: ext.textHint)),
const Spacer(),
Text('$contentLength/100',
style: AppTypography.caption1.copyWith(
color: isValid
? ext.accent
: (contentLength > 100
? CupertinoColors.systemRed
: ext.textHint),
)),
],
),
const SizedBox(height: AppSpacing.xs),
CupertinoTextField(
controller: _contentController,
style: AppTypography.body.copyWith(color: ext.textPrimary),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
padding: const EdgeInsets.all(AppSpacing.md),
maxLength: 100,
maxLines: 4,
minLines: 3,
),
],
);
}
// ── 提交按钮 ──
Widget _buildSubmitButton(AppThemeExtension ext, SubmitState state) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md, vertical: AppSpacing.sm),
child: SizedBox(
width: double.infinity,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed: state.isSubmitting ? null : _handleSubmit,
child: state.isSubmitting
? const CupertinoActivityIndicator(color: CupertinoColors.white)
: Text(_t.submit.submit,
style: AppTypography.subhead.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600)),
),
),
);
}
// ── 投稿记录 ──
Widget _buildHistorySection(AppThemeExtension ext, SubmitState state) {
return Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.clock,
size: 16, color: ext.textSecondary),
const SizedBox(width: 4),
Text(_t.submit.historyTitle,
style: AppTypography.title3.copyWith(
color: ext.textPrimary, fontWeight: FontWeight.w700)),
const SizedBox(width: 6),
Text('${state.records.length}',
style: AppTypography.caption1
.copyWith(color: ext.textHint)),
],
),
const SizedBox(height: AppSpacing.sm),
if (state.records.isEmpty)
_buildEmptyHistory(ext)
else
...state.records.map((r) => _buildRecordCard(ext, r)),
],
),
);
}
Widget _buildEmptyHistory(AppThemeExtension ext) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xl),
child: Center(
child: Column(
children: [
Icon(CupertinoIcons.doc_text,
size: 40, color: ext.textHint.withValues(alpha: 0.3)),
const SizedBox(height: AppSpacing.sm),
Text(_t.submit.historyEmpty,
style: AppTypography.subhead.copyWith(color: ext.textHint)),
],
),
),
);
}
Widget _buildRecordCard(AppThemeExtension ext, SubmitRecord record) {
final statusLabel = switch (record.status) {
SubmitStatus.reviewing => _t.submit.statusReviewing,
SubmitStatus.approved => _t.submit.statusApproved,
SubmitStatus.rejected => _t.submit.statusRejected,
};
final statusColor = switch (record.status) {
SubmitStatus.reviewing => ext.accent,
SubmitStatus.approved => CupertinoColors.systemGreen,
SubmitStatus.rejected => CupertinoColors.systemRed,
};
final catLabel = _categories
.firstWhere((e) => e.key == record.category,
orElse: () => MapEntry(record.category, record.category))
.value;
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Dismissible(
key: ValueKey(record.id),
direction: DismissDirection.endToStart,
onDismissed: (_) =>
ref.read(submitProvider.notifier).deleteRecord(record.id),
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: AppSpacing.lg),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: const Icon(CupertinoIcons.delete,
color: CupertinoColors.systemRed, size: 20),
),
child: GlassContainer(
padding: const EdgeInsets.all(AppSpacing.md),
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.08),
borderRadius: AppRadius.smBorder,
),
child: Text(catLabel,
style: AppTypography.caption2.copyWith(
color: ext.accent, fontWeight: FontWeight.w600)),
),
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.08),
borderRadius: AppRadius.smBorder,
),
child: Text(statusLabel,
style: AppTypography.caption2.copyWith(
color: statusColor, fontWeight: FontWeight.w600)),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => _deleteRecord(record.id),
child: Icon(CupertinoIcons.xmark_circle,
size: 18, color: ext.textHint),
),
],
),
if (record.title != null) ...[
const SizedBox(height: 6),
Text(record.title!,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary, fontWeight: FontWeight.w600)),
],
const SizedBox(height: 4),
Text(record.content,
style:
AppTypography.body.copyWith(color: ext.textPrimary),
maxLines: 3,
overflow: TextOverflow.ellipsis),
const SizedBox(height: 6),
Row(
children: [
if (record.author != null) ...[
Icon(CupertinoIcons.person,
size: 12, color: ext.textHint),
const SizedBox(width: 2),
Text(record.author!,
style: AppTypography.caption2
.copyWith(color: ext.textHint)),
const SizedBox(width: 8),
],
const Spacer(),
Text(_formatDate(record.createdAt),
style: AppTypography.caption2
.copyWith(color: ext.textHint)),
],
),
],
),
),
),
);
}
String _formatDate(DateTime dt) {
final now = DateTime.now();
final diff = now.difference(dt);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
if (diff.inDays < 1) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${dt.month}/${dt.day}';
}
}