1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
681 lines
22 KiB
Dart
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}';
|
|
}
|
|
}
|