feat(mobile): UI-01 upload center, capture and auxiliary pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -204,7 +204,10 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
if (album == null) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => ShareQrDialog(publicToken: album.publicToken),
|
||||
builder: (_) => ShareQrDialog(
|
||||
publicToken: album.publicToken,
|
||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// 相册创建/编辑表单页(P3 计划 Task 4)。
|
||||
/// 相册创建/编辑表单页(UI-01 §4.5 分组重构,P3 计划 Task 4 基础)。
|
||||
///
|
||||
/// zh-CN / en 双语 tab(标题/描述/场地/地址)、默认语言分段选择、
|
||||
/// 日期时间选择器(开始必填;结束可选、可显式清除)、时区文本(缺省
|
||||
/// Asia/Shanghai)、开关组:创建仅 download/original(契约缺省
|
||||
/// upload/public=true 不可创建时覆盖),编辑为四开关独立。
|
||||
/// 三分组(SectionHeader):活动信息(开始/结束时间 + 时区)/ 多语言
|
||||
/// 内容(zh-CN · en 双语 tab:标题/描述/场地/地址按语言各一份——契约
|
||||
/// translations 的场地/地址是逐语言字段,不合并为共享输入)/ 访问设置
|
||||
/// (默认语言 + 开关组:创建仅 download/original,编辑四开关独立)。
|
||||
/// 保存按钮固定在 bottomNavigationBar——键盘打开时常驻可达(§4.5)。
|
||||
/// 编辑提交经 [AlbumFormDraft.toUpdatePatch] 最小化 PATCH(仅变更字段);
|
||||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情。
|
||||
library;
|
||||
@@ -18,6 +19,8 @@ import '../features/albums/album_format.dart';
|
||||
import '../features/albums/album_form.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 创建([existing] 为 null)/ 编辑相册表单。
|
||||
class AlbumFormPage extends ConsumerStatefulWidget {
|
||||
@@ -220,7 +223,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
// DefaultTabController 仅为 TabBar 提供 controller(内容区非
|
||||
// TabBarView,而是按 _tabIndex 手动切换,避免两 tab 表单失焦重建)。
|
||||
return DefaultTabController(
|
||||
@@ -228,116 +230,129 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isCreate ? l10n.albumCreate : l10n.albumEdit),
|
||||
bottom: TabBar(
|
||||
onTap: (index) => setState(() => _tabIndex = index),
|
||||
tabs: [
|
||||
Tab(text: l10n.localeChinese),
|
||||
Tab(text: l10n.localeEnglish),
|
||||
],
|
||||
),
|
||||
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? l10n.create : l10n.save),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_tabIndex == 0)
|
||||
_translationFields(
|
||||
title: _zhTitle,
|
||||
description: _zhDescription,
|
||||
venue: _zhVenue,
|
||||
address: _zhAddress,
|
||||
isDefault: _defaultLocale == _zh,
|
||||
)
|
||||
else
|
||||
_translationFields(
|
||||
title: _enTitle,
|
||||
description: _enDescription,
|
||||
venue: _enVenue,
|
||||
address: _enAddress,
|
||||
isDefault: _defaultLocale == _en,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Text(
|
||||
l10n.albumDefaultLocale,
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<String>(
|
||||
segments: [
|
||||
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
|
||||
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
|
||||
],
|
||||
selected: {_defaultLocale},
|
||||
onSelectionChanged: (selection) =>
|
||||
setState(() => _defaultLocale = selection.first),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_dateTimeRow(isStart: true),
|
||||
_dateTimeRow(isStart: false),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(
|
||||
controller: _timezone,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumTimezone,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isCreate) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchUpload),
|
||||
value: _uploadEnabled,
|
||||
onChanged: (v) => setState(() => _uploadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchPublic),
|
||||
value: _publicEnabled,
|
||||
onChanged: (v) => setState(() => _publicEnabled = v),
|
||||
),
|
||||
],
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchDownload),
|
||||
value: _downloadEnabled,
|
||||
onChanged: (v) => setState(() => _downloadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchOriginalDownload),
|
||||
value: _originalDownloadEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _originalDownloadEnabled = v),
|
||||
),
|
||||
if (_errors.isNotEmpty) ...[
|
||||
body: Form(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── 分组 1:活动信息 ────────────────────────────────
|
||||
SectionHeader(title: l10n.albumSectionEventInfo),
|
||||
const SizedBox(height: kSpace12),
|
||||
_dateTimeRow(isStart: true),
|
||||
_dateTimeRow(isStart: false),
|
||||
const SizedBox(height: 8),
|
||||
..._errors.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_errorText(l10n, e),
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
TextFormField(
|
||||
controller: _timezone,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(labelText: l10n.albumTimezone),
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 2:多语言内容(tab 切换,另一语言按需填写)──
|
||||
SectionHeader(title: l10n.albumSectionContent),
|
||||
const SizedBox(height: kSpace8),
|
||||
TabBar(
|
||||
onTap: (index) => setState(() => _tabIndex = index),
|
||||
tabs: [
|
||||
Tab(text: l10n.localeChinese),
|
||||
Tab(text: l10n.localeEnglish),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace12),
|
||||
if (_tabIndex == 0)
|
||||
_translationFields(
|
||||
title: _zhTitle,
|
||||
description: _zhDescription,
|
||||
venue: _zhVenue,
|
||||
address: _zhAddress,
|
||||
isDefault: _defaultLocale == _zh,
|
||||
)
|
||||
else
|
||||
_translationFields(
|
||||
title: _enTitle,
|
||||
description: _enDescription,
|
||||
venue: _enVenue,
|
||||
address: _enAddress,
|
||||
isDefault: _defaultLocale == _en,
|
||||
),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 3:访问设置 ────────────────────────────────
|
||||
SectionHeader(title: l10n.albumSectionAccess),
|
||||
const SizedBox(height: kSpace12),
|
||||
Text(l10n.albumDefaultLocale),
|
||||
const SizedBox(height: kSpace8),
|
||||
SegmentedButton<String>(
|
||||
segments: [
|
||||
ButtonSegment(value: _zh, label: Text(l10n.localeChinese)),
|
||||
ButtonSegment(value: _en, label: Text(l10n.localeEnglish)),
|
||||
],
|
||||
selected: {_defaultLocale},
|
||||
onSelectionChanged: (selection) =>
|
||||
setState(() => _defaultLocale = selection.first),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
if (!_isCreate) ...[
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchUpload),
|
||||
value: _uploadEnabled,
|
||||
onChanged: (v) => setState(() => _uploadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchPublic),
|
||||
value: _publicEnabled,
|
||||
onChanged: (v) => setState(() => _publicEnabled = v),
|
||||
),
|
||||
],
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchDownload),
|
||||
value: _downloadEnabled,
|
||||
onChanged: (v) => setState(() => _downloadEnabled = v),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(l10n.switchOriginalDownload),
|
||||
value: _originalDownloadEnabled,
|
||||
onChanged: (v) =>
|
||||
setState(() => _originalDownloadEnabled = v),
|
||||
),
|
||||
if (_errors.isNotEmpty) ...[
|
||||
const SizedBox(height: kSpace8),
|
||||
..._errors.map(
|
||||
(e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
_errorText(l10n, e),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace24),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isCreate ? l10n.create : l10n.save),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -361,7 +376,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: isDefault ? '${l10n.albumTitle} *' : l10n.albumTitle,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -369,28 +383,19 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
controller: description,
|
||||
onChanged: (_) => setState(() {}),
|
||||
maxLines: 2,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumDescription,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumDescription),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: venue,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumVenue,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumVenue),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: address,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.albumAddress,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.albumAddress),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -412,7 +417,6 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage> {
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: isStart ? l10n.albumStartAt : l10n.albumEndAtOptional,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: value == null
|
||||
? const Icon(Icons.edit_calendar_outlined)
|
||||
: Row(
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show HapticFeedback;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/albums/models.dart';
|
||||
@@ -124,6 +125,10 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
|
||||
Future<void> _shotTail = Future<void>.value();
|
||||
|
||||
/// 入队成功短暂反馈(150-220ms 轻量动效档,§3.1)。
|
||||
bool _showEnqueuedFeedback = false;
|
||||
Timer? _feedbackTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -132,6 +137,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_feedbackTimer?.cancel();
|
||||
_source?.dispose().ignore();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -170,18 +176,30 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
_shotTail = _shotTail.then((_) async {
|
||||
final photo = await source.capture();
|
||||
if (!mounted) return;
|
||||
await enqueueForUpload(
|
||||
final enqueued = await enqueueForUpload(
|
||||
ref,
|
||||
accountId: widget.target.accountId,
|
||||
albumId: widget.target.albumId,
|
||||
categoryId: categoryId,
|
||||
sourcePaths: [photo.path],
|
||||
);
|
||||
// 入队成功:短暂反馈(轻触 + 200ms 浮层),不逐张弹确认(§4.5)。
|
||||
if (enqueued > 0 && mounted) _flashEnqueued();
|
||||
}).catchError((Object _) {
|
||||
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
|
||||
});
|
||||
}
|
||||
|
||||
/// 入队成功反馈:触觉轻点 + 顶部短暂浮层(200ms 后自动消退)。
|
||||
void _flashEnqueued() {
|
||||
HapticFeedback.selectionClick();
|
||||
_feedbackTimer?.cancel();
|
||||
setState(() => _showEnqueuedFeedback = true);
|
||||
_feedbackTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
if (mounted) setState(() => _showEnqueuedFeedback = false);
|
||||
});
|
||||
}
|
||||
|
||||
/// 相册多选入队(目标 = 当前相册与分类)。
|
||||
Future<void> _pickFromGallery() async {
|
||||
final paths = await ref.read(multiImagePickerProvider)();
|
||||
@@ -264,7 +282,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_buildCameraArea(l10n),
|
||||
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
|
||||
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -273,15 +291,56 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
categories: categories,
|
||||
categoryId: _categoryId,
|
||||
onCategorySelected: _switchCategory,
|
||||
pending: pending,
|
||||
failed: failed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showEnqueuedFeedback ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
l10n.captureEnqueuedFeedback,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _buildBottomBar(l10n),
|
||||
bottomNavigationBar: _buildBottomBar(l10n, pending),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -312,9 +371,12 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(AppLocalizations l10n) {
|
||||
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
|
||||
return BottomAppBar(
|
||||
color: Colors.black87,
|
||||
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
|
||||
// 内部再占 24),显式加高避免溢出。
|
||||
height: 132,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
@@ -324,7 +386,22 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
color: Colors.white),
|
||||
onPressed: _pickFromGallery,
|
||||
),
|
||||
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
|
||||
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ShutterButton(
|
||||
onPressed: _shoot,
|
||||
enabled: _phase == _CameraPhase.ready,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_Badge(
|
||||
label: l10n.captureQueuedBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
// 占位对齐(快门居中)。
|
||||
const SizedBox(width: 48),
|
||||
],
|
||||
@@ -335,15 +412,14 @@ class _CapturePageState extends ConsumerState<CapturePage> {
|
||||
|
||||
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 待传/失败
|
||||
/// 徽章。
|
||||
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
|
||||
/// (待传计数在快门旁常驻,见底部栏)。
|
||||
class _TargetOverlay extends StatelessWidget {
|
||||
const _TargetOverlay({
|
||||
required this.albumTitle,
|
||||
required this.categories,
|
||||
required this.categoryId,
|
||||
required this.onCategorySelected,
|
||||
required this.pending,
|
||||
required this.failed,
|
||||
});
|
||||
|
||||
@@ -351,7 +427,6 @@ class _TargetOverlay extends StatelessWidget {
|
||||
final List<Category> categories;
|
||||
final String? categoryId;
|
||||
final void Function(String?) onCategorySelected;
|
||||
final int pending;
|
||||
final int failed;
|
||||
|
||||
@override
|
||||
@@ -376,19 +451,12 @@ class _TargetOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Badge(
|
||||
label: l10n.capturePendingBadge(pending),
|
||||
color: Colors.white24,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
if (failed > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
if (failed > 0)
|
||||
_Badge(
|
||||
label: l10n.captureFailedBadge(failed),
|
||||
color: Colors.red,
|
||||
foreground: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,18 @@ import '../core/api_client.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../features/uploads/queue_controller.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
import 'albums_tab.dart';
|
||||
import 'queue_tab.dart';
|
||||
|
||||
/// 应用展示版本(About 分组):与 pubspec version 对齐的缺省值,
|
||||
/// `--dart-define=APP_VERSION=...` 覆盖(未引入 package_info,零新依赖)。
|
||||
const String kAppVersion = String.fromEnvironment(
|
||||
'APP_VERSION',
|
||||
defaultValue: '1.0.0',
|
||||
);
|
||||
|
||||
/// 主壳:底部导航「相册 / 上传 / 设置」(UI-01 §4.1)。
|
||||
///
|
||||
/// IndexedStack 保持三页签状态;「上传」页签角标 = 当前账号未结束任务数
|
||||
@@ -83,7 +92,9 @@ class _UploadTabIcon extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页(P3 Task 3:修改密码入口 + 退出登录;Task 10:语言切换)。
|
||||
/// 设置页(UI-01 §4.5 分组重构):账号 / 语言 / 安全(改密 + 退出)/
|
||||
/// 上传帮助 / 关于(版本)。退出登录确认弹窗明确「本机任务保留,重新
|
||||
/// 登录后继续」——不承诺退出后后台继续上传。
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
const SettingsTab({super.key});
|
||||
|
||||
@@ -130,6 +141,30 @@ class SettingsTab extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出确认:明确本地任务保留、重新登录后继续(不承诺后台继续上传)。
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.logoutConfirmTitle),
|
||||
content: Text(l10n.logoutConfirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.logout),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
await _logout(context, ref);
|
||||
}
|
||||
|
||||
Future<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
// api.logout 尽力而为(服务端撤销失败也照常本地清会话——客户端内已处理)。
|
||||
await ref.read(apiClientProvider).logout();
|
||||
@@ -142,17 +177,48 @@ class SettingsTab extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final choice = ref.watch(localeSettingsProvider);
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(l10n.settings)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: kSpace8)
|
||||
.copyWith(bottom: kSpace16),
|
||||
children: [
|
||||
// ── 账号 ────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionAccount),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: Text(account ?? l10n.notSet),
|
||||
subtitle: Text(l10n.settingsSignedInAccount),
|
||||
),
|
||||
// ── 语言 ────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionLanguage),
|
||||
),
|
||||
// 分组已名「语言」,行标题直接显示当前档位(避免同名重复)。
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language),
|
||||
title: Text(l10n.language),
|
||||
subtitle: Text(_choiceLabel(l10n, choice)),
|
||||
title: Text(_choiceLabel(l10n, choice)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickLocale(context, ref, choice),
|
||||
),
|
||||
// ── 安全:改密 + 退出(确认后清会话) ────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionSecurity),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.password_outlined),
|
||||
title: Text(l10n.passwordChangeTitle),
|
||||
@@ -161,7 +227,48 @@ class SettingsTab extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: Text(l10n.logout),
|
||||
onTap: () => _logout(context, ref),
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
),
|
||||
// ── 上传帮助:系统限制 / 强退 / 网络等待三分态说明 ────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionUploadHelp),
|
||||
),
|
||||
ExpansionTile(
|
||||
leading: const Icon(Icons.help_outline),
|
||||
title: Text(l10n.settingsUploadHelpTitle),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, 0, kSpace16, kSpace16,
|
||||
),
|
||||
child: Text(
|
||||
l10n.settingsUploadHelpBody,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── 关于:版本 ──────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace16, kSpace16, kSpace8,
|
||||
),
|
||||
child: SectionHeader(title: l10n.settingsSectionAbout),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: Text(l10n.settingsVersion),
|
||||
trailing: Text(
|
||||
kAppVersion,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/// 登录页(UI-01 §4.5 主题统一,P3 计划 Task 3 基础)。
|
||||
///
|
||||
/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到
|
||||
/// SessionStore)→ 成功 pushReplacement /home;失败以内联 StateBadge
|
||||
/// (图标 + 文案 + 错误色)展示错误码本地化文案(AUTH_RATE_LIMITED 文案
|
||||
/// 自带「稍后再试」;网络/校验错误各有专属文案)。提交期间按钮进入
|
||||
/// 加载态防重复提交。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -6,13 +15,10 @@ import '../core/app_exception.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../features/uploads/engine_scope.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 登录页(P3 计划 Task 3)。
|
||||
///
|
||||
/// 账号/密码表单 → [ApiClient.login](会话由客户端自动持久化到
|
||||
/// SessionStore)→ 成功 pushReplacement /home;失败按错误码本地化提示
|
||||
/// (AUTH_RATE_LIMITED 文案自带「稍后再试」;网络/校验错误各有专属文案)。
|
||||
/// 提交期间按钮进入加载态防重复提交。
|
||||
/// 登录页。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@@ -27,6 +33,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
|
||||
bool _submitting = false;
|
||||
|
||||
/// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountController.dispose();
|
||||
@@ -36,9 +45,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
await ref.read(apiClientProvider).login(
|
||||
@@ -51,13 +62,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
} on AppException catch (error) {
|
||||
// 429(AUTH_RATE_LIMITED)等文案经错误码映射;登录页的
|
||||
// AUTH_INVALID_CREDENTIALS 即「账号或密码错误」。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.messageForError(error))),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.messageForError(error));
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.errorGeneric);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
@@ -72,7 +79,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: const EdgeInsets.all(kSpace24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
@@ -83,16 +90,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
TextFormField(
|
||||
controller: _accountController,
|
||||
autofillHints: const [AutofillHints.username],
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.username,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.username),
|
||||
validator: (value) =>
|
||||
(value == null || value.trim().isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
@@ -100,15 +104,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
autocorrect: false,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.password,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: l10n.password),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: kSpace16),
|
||||
StateBadge(
|
||||
variant: BadgeVariant.error,
|
||||
label: _errorText!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace24),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
@@ -119,7 +127,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
)
|
||||
: Text(l10n.login),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
Text(
|
||||
l10n.loginHint,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/// 修改密码页(UI-01 §4.5 主题统一,P3 计划 Task 3 基础;入口在设置页)。
|
||||
///
|
||||
/// 当前/新/确认三字段;客户端校验(新密码 ≥8 位、两次一致)先行,
|
||||
/// 服务端 [ApiClient.changePassword] 成功后 204——当前会话被服务端保留,
|
||||
/// 无需重登录:toast 提示后直接 pop 回设置页。失败以内联 StateBadge
|
||||
/// (图标 + 文案 + 错误色)展示;AUTH_INVALID_CREDENTIALS 在本页语义为
|
||||
/// 「当前密码错误」(覆写通用映射)。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -6,13 +15,10 @@ import '../core/app_exception.dart';
|
||||
import '../core/error_codes.dart';
|
||||
import '../core/error_l10n.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 修改密码页(P3 计划 Task 3;入口在设置页)。
|
||||
///
|
||||
/// 当前/新/确认三字段;客户端校验(新密码 ≥8 位、两次一致)先行,
|
||||
/// 服务端 [ApiClient.changePassword] 成功后 204——当前会话被服务端保留,
|
||||
/// 无需重登录:toast 提示后直接 pop 回设置页。
|
||||
/// AUTH_INVALID_CREDENTIALS 在本页语义为「当前密码错误」(覆写通用映射)。
|
||||
/// 修改密码页。
|
||||
class PasswordChangePage extends ConsumerStatefulWidget {
|
||||
const PasswordChangePage({super.key});
|
||||
|
||||
@@ -30,6 +36,9 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
bool _obscureAll = true;
|
||||
bool _submitting = false;
|
||||
|
||||
/// 最近一次提交失败文案(内联 StateBadge 展示;null = 无错误)。
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldController.dispose();
|
||||
@@ -40,7 +49,10 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() => _submitting = true);
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
});
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
@@ -55,19 +67,16 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
);
|
||||
navigator.pop();
|
||||
} on AppException catch (error) {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
error.code == ErrorCodes.authInvalidCredentials
|
||||
? l10n.errorOldPasswordIncorrect
|
||||
: l10n.messageForError(error),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => _errorText =
|
||||
error.code == ErrorCodes.authInvalidCredentials
|
||||
? l10n.errorOldPasswordIncorrect
|
||||
: l10n.messageForError(error),
|
||||
);
|
||||
}
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.errorGeneric)),
|
||||
);
|
||||
if (mounted) setState(() => _errorText = l10n.errorGeneric);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
@@ -81,92 +90,98 @@ class _PasswordChangePageState extends ConsumerState<PasswordChangePage> {
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _oldController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.currentPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(kSpace24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _oldController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.currentPassword,
|
||||
),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
validator: (value) => (value == null || value.isEmpty)
|
||||
? l10n.fieldRequired
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _newController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.newPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _newController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.newPassword,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value.length < 8) return l10n.passwordMinLength;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value.length < 8) return l10n.passwordMinLength;
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.confirmNewPassword,
|
||||
border: const OutlineInputBorder(),
|
||||
const SizedBox(height: kSpace16),
|
||||
TextFormField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureAll,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => _submitting ? null : _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.confirmNewPassword,
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value != _newController.text) {
|
||||
return l10n.passwordMismatch;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.fieldRequired;
|
||||
}
|
||||
if (value != _newController.text) {
|
||||
return l10n.passwordMismatch;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => _obscureAll = !_obscureAll),
|
||||
icon: Icon(
|
||||
_obscureAll
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
const SizedBox(height: kSpace8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: IconButton(
|
||||
onPressed: () =>
|
||||
setState(() => _obscureAll = !_obscureAll),
|
||||
icon: Icon(
|
||||
_obscureAll
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.passwordChangeTitle),
|
||||
),
|
||||
],
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: kSpace8),
|
||||
StateBadge(
|
||||
variant: BadgeVariant.error,
|
||||
label: _errorText!,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace16),
|
||||
FilledButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
child: _submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(l10n.passwordChangeTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
|
||||
/// 上传中心(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||||
///
|
||||
/// 顶部摘要卡(筛选范围标注「相册·时间·本设备」+ 四组任务数:已完成/
|
||||
/// 上传中/等待中/需关注——按任务数统计,单任务百分比才走字节进度)→
|
||||
/// 筛选栏(相册下拉 × 时间快捷 × 状态分组 chips:全部/进行中/需关注/
|
||||
/// 已完成)→ 任务行 = 缩略图 + 文件名 + 状态徽章(StateBadge 图标+文案+
|
||||
/// 颜色三重表达)+ 状态专属文案/操作(§4.4 表格:确认中不显示已公开、
|
||||
/// 准备失败走重新选择而非普通重试、终态走「移除记录」并明确不删云端
|
||||
/// 照片)+ 传输进度(进度总线行级订阅)。存在可重试失败时底部
|
||||
/// 「一键重试」条(仅作用于可重试任务,文案说明范围)。
|
||||
///
|
||||
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
|
||||
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
|
||||
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
|
||||
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
|
||||
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
|
||||
library;
|
||||
|
||||
@@ -23,6 +28,10 @@ import '../features/uploads/queue_l10n.dart';
|
||||
import '../features/uploads/task_state.dart';
|
||||
import '../features/uploads/task_store.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/empty_state.dart';
|
||||
import '../ui/components/error_state.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 队列 Tab(主壳 IndexedStack 内常驻)。
|
||||
class QueueTab extends ConsumerWidget {
|
||||
@@ -32,9 +41,9 @@ class QueueTab extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final autoRetryable =
|
||||
(counts[UploadTaskState.retryableFailed] ?? 0) +
|
||||
(counts[UploadTaskState.waitingNetwork] ?? 0);
|
||||
// 批量重试仅针对可重试失败(等待网络自动恢复,不混入;准备失败需
|
||||
// 重新选图,不能普通重试冒充恢复——§4.4)。
|
||||
final retryable = counts[UploadTaskState.retryableFailed] ?? 0;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.queue),
|
||||
@@ -48,18 +57,137 @@ class QueueTab extends ConsumerWidget {
|
||||
),
|
||||
body: const Column(
|
||||
children: [
|
||||
_SummaryCard(),
|
||||
_FilterBar(),
|
||||
Expanded(child: _TaskListView()),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
|
||||
bottomNavigationBar: retryable > 0 ? const _BatchRetryBar() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 摘要卡 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径)。
|
||||
class _SummaryCard extends ConsumerWidget {
|
||||
const _SummaryCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final filter = ref.watch(queueFilterProvider);
|
||||
final albums = ref.watch(albumListProvider).items;
|
||||
final counts = ref.watch(queueSummaryProvider);
|
||||
|
||||
// 范围标注:[相册名·]时间范围·本设备(本机任务库只含本设备任务,
|
||||
// 跨设备记录在服务端历史页——两者不合并统计)。
|
||||
final album = filter.albumId == null
|
||||
? null
|
||||
: albums.where((a) => a.id == filter.albumId).firstOrNull;
|
||||
final scope = [
|
||||
if (album != null) albumDisplayTitle(album, l10n.localeName),
|
||||
_FilterBar._timeLabel(l10n, filter.time),
|
||||
l10n.thisDevice,
|
||||
].join(' · ');
|
||||
|
||||
final light = theme.brightness == Brightness.light;
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(kSpace16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.summarize_outlined,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
scope,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
Wrap(
|
||||
spacing: kSpace16,
|
||||
runSpacing: kSpace4,
|
||||
children: [
|
||||
_SummaryCount(
|
||||
value: completedSummaryOf(counts),
|
||||
label: l10n.queueSummaryCompleted,
|
||||
color: light ? kColorSuccess : kColorSuccessDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: uploadingSummaryOf(counts),
|
||||
label: l10n.queueSummaryUploading,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: waitingSummaryOf(counts),
|
||||
label: l10n.queueSummaryWaiting,
|
||||
color: light ? kColorWaiting : kColorWaitingDark,
|
||||
),
|
||||
_SummaryCount(
|
||||
value: failedCountOf(counts),
|
||||
label: l10n.queueSummaryNeedAttention,
|
||||
color: light ? kColorError : kColorErrorDark,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色)。
|
||||
class _SummaryCount extends StatelessWidget {
|
||||
const _SummaryCount({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$value',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace4),
|
||||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
|
||||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(§4.4 四组)。
|
||||
class _FilterBar extends ConsumerWidget {
|
||||
const _FilterBar();
|
||||
|
||||
@@ -136,11 +264,8 @@ class _FilterBar extends ConsumerWidget {
|
||||
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
|
||||
switch (state) {
|
||||
QueueStateFilter.all => l10n.queueStateAll,
|
||||
QueueStateFilter.preparing => l10n.queueStatePreparing,
|
||||
QueueStateFilter.queued => l10n.queueStateQueued,
|
||||
QueueStateFilter.uploading => l10n.queueStateUploading,
|
||||
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
|
||||
QueueStateFilter.failed => l10n.queueStateFailed,
|
||||
QueueStateFilter.inProgress => l10n.queueStateInProgress,
|
||||
QueueStateFilter.needsAttention => l10n.queueStateNeedsAttention,
|
||||
QueueStateFilter.completed => l10n.queueStateCompleted,
|
||||
};
|
||||
|
||||
@@ -208,22 +333,16 @@ class _TaskListView extends ConsumerWidget {
|
||||
return tasksAsync.when(
|
||||
skipLoadingOnReload: true,
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.errorGeneric),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => ref.invalidate(queueTasksProvider),
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (error, _) => ErrorState(
|
||||
message: l10n.errorGeneric,
|
||||
onRetry: () => ref.invalidate(queueTasksProvider),
|
||||
),
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return Center(child: Text(l10n.queueEmpty));
|
||||
return EmptyState(
|
||||
icon: Icons.cloud_upload_outlined,
|
||||
title: l10n.queueEmpty,
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
@@ -235,7 +354,7 @@ class _TaskListView extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条任务行。
|
||||
/// 单条任务行(§4.4 状态表逐态落地)。
|
||||
class _TaskTile extends ConsumerWidget {
|
||||
const _TaskTile({required this.task});
|
||||
|
||||
@@ -255,24 +374,32 @@ class _TaskTile extends ConsumerWidget {
|
||||
: task.albumId;
|
||||
final failedLike =
|
||||
task.state == UploadTaskState.preparationFailed ||
|
||||
task.state == UploadTaskState.retryableFailed ||
|
||||
task.state == UploadTaskState.waitingNetwork;
|
||||
task.state == UploadTaskState.retryableFailed;
|
||||
|
||||
return ListTile(
|
||||
leading: _Thumb(
|
||||
path: task.cleanedAt == null ? task.sourceFilePath : null,
|
||||
return Padding(
|
||||
// 行键(task.id)供 widget 测试行级锚定(状态徽章/动作图标定位)。
|
||||
key: ValueKey('queue-task-${task.id}'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16, kSpace8, kSpace16, kSpace8,
|
||||
),
|
||||
title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
_StateChip(state: task.state),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_Thumb(path: task.cleanedAt == null ? task.sourceFilePath : null),
|
||||
const SizedBox(width: kSpace12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
task.filename,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
albumName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -280,34 +407,92 @@ class _TaskTile extends ConsumerWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: kSpace4),
|
||||
Row(
|
||||
children: [
|
||||
// 状态徽章即 §4.4「界面含义」列的唯一文案表达(图标 +
|
||||
// 颜色 + 文字三重编码);尝试次数(>0)为补充说明。
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: _badgeVariant(task.state),
|
||||
label: _badgeLabel(l10n, task.state),
|
||||
),
|
||||
if (task.attempt > 0) ...[
|
||||
const SizedBox(width: kSpace8),
|
||||
Text(
|
||||
l10n.queueAttemptCount(task.attempt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (task.state == UploadTaskState.waitingNetwork) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.queueWaitingNetworkHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.brightness == Brightness.light
|
||||
? kColorWaiting
|
||||
: kColorWaitingDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (failedLike && task.lastError != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.messageForTaskError(task.lastError!),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (task.state == UploadTaskState.uploading ||
|
||||
task.state == UploadTaskState.uploadedConfirming)
|
||||
_ProgressLine(
|
||||
taskId: task.id,
|
||||
confirming: task.state ==
|
||||
UploadTaskState.uploadedConfirming,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (failedLike && task.lastError != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
task.attempt > 0
|
||||
? '${l10n.messageForTaskError(task.lastError!)} · '
|
||||
'${l10n.queueAttemptCount(task.attempt)}'
|
||||
: l10n.messageForTaskError(task.lastError!),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (task.state == UploadTaskState.uploading ||
|
||||
task.state == UploadTaskState.uploadedConfirming)
|
||||
_ProgressLine(
|
||||
taskId: task.id,
|
||||
confirming: task.state == UploadTaskState.uploadedConfirming,
|
||||
),
|
||||
_TaskActions(task: task),
|
||||
],
|
||||
),
|
||||
trailing: _TaskActions(task: task),
|
||||
);
|
||||
}
|
||||
|
||||
/// 徽章语义变体(§4.4:进行=主色、等待=琥珀、失败=红、完成=绿、取消=灰)。
|
||||
static BadgeVariant _badgeVariant(UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing ||
|
||||
UploadTaskState.uploading ||
|
||||
UploadTaskState.uploadedConfirming => BadgeVariant.info,
|
||||
UploadTaskState.queued ||
|
||||
UploadTaskState.waitingNetwork => BadgeVariant.warning,
|
||||
UploadTaskState.preparationFailed ||
|
||||
UploadTaskState.retryableFailed => BadgeVariant.error,
|
||||
UploadTaskState.completed => BadgeVariant.success,
|
||||
UploadTaskState.cancelled => BadgeVariant.neutral,
|
||||
};
|
||||
|
||||
/// 徽章文案 = §4.4 界面含义列(确认中不显示「已公开」等超集语义)。
|
||||
static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) =>
|
||||
switch (state) {
|
||||
UploadTaskState.preparing => l10n.queueRowPreparing,
|
||||
UploadTaskState.preparationFailed => l10n.queueRowFileUnavailable,
|
||||
UploadTaskState.queued => l10n.queueRowWaitingUpload,
|
||||
UploadTaskState.uploading => l10n.taskStateUploading,
|
||||
UploadTaskState.uploadedConfirming => l10n.queueRowConfirming,
|
||||
UploadTaskState.completed => l10n.taskStateCompleted,
|
||||
UploadTaskState.waitingNetwork => l10n.taskStateWaitingNetwork,
|
||||
UploadTaskState.retryableFailed => l10n.taskStateRetryableFailed,
|
||||
UploadTaskState.cancelled => l10n.taskStateCancelled,
|
||||
};
|
||||
}
|
||||
|
||||
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
|
||||
@@ -324,7 +509,7 @@ class _Thumb extends StatelessWidget {
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
@@ -334,7 +519,7 @@ class _Thumb extends StatelessWidget {
|
||||
);
|
||||
if (path == null) return placeholder();
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: BorderRadius.circular(kRadiusBadge),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
@@ -348,62 +533,8 @@ class _Thumb extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
|
||||
class _StateChip extends StatelessWidget {
|
||||
const _StateChip({required this.state});
|
||||
|
||||
final UploadTaskState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final (color, onColor) = switch (state) {
|
||||
UploadTaskState.preparing => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => (
|
||||
theme.colorScheme.errorContainer,
|
||||
theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
UploadTaskState.queued => (
|
||||
theme.colorScheme.secondaryContainer,
|
||||
theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
UploadTaskState.uploading || UploadTaskState.uploadedConfirming => (
|
||||
theme.colorScheme.primaryContainer,
|
||||
theme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
UploadTaskState.completed => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
UploadTaskState.waitingNetwork => (
|
||||
theme.colorScheme.tertiaryContainer,
|
||||
theme.colorScheme.onTertiaryContainer,
|
||||
),
|
||||
UploadTaskState.cancelled => (
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
l10n.labelForTaskState(state),
|
||||
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
|
||||
/// 进度条(等待服务端核验)。
|
||||
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条 + 百分比文案);
|
||||
/// 确认中 = 不确定进度条(等待服务端核验,不伪造百分比)。
|
||||
class _ProgressLine extends ConsumerWidget {
|
||||
const _ProgressLine({required this.taskId, required this.confirming});
|
||||
|
||||
@@ -412,6 +543,7 @@ class _ProgressLine extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 8),
|
||||
child: StreamBuilder<UploadTaskProgress>(
|
||||
@@ -421,9 +553,22 @@ class _ProgressLine extends ConsumerWidget {
|
||||
.where((p) => p.taskId == taskId),
|
||||
builder: (context, snapshot) {
|
||||
final fraction = snapshot.data?.fraction;
|
||||
return LinearProgressIndicator(
|
||||
value: confirming ? null : fraction,
|
||||
minHeight: 4,
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: confirming ? null : fraction,
|
||||
minHeight: 4,
|
||||
),
|
||||
),
|
||||
if (!confirming && fraction != null) ...[
|
||||
const SizedBox(width: kSpace8),
|
||||
Text(
|
||||
l10n.queueRowUploadingPercent((fraction * 100).round()),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -442,16 +587,25 @@ class _TaskActions extends ConsumerWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final account = ref.watch(sessionAccountProvider).value;
|
||||
switch (task.state) {
|
||||
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)——
|
||||
// 不能普通重试冒充恢复(§4.4)。
|
||||
case UploadTaskState.preparationFailed:
|
||||
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
return _RowAction(
|
||||
icon: Icons.image_search_outlined,
|
||||
label: l10n.queueActionSelectAgain,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _retryPreparation(context, ref, account),
|
||||
);
|
||||
case UploadTaskState.retryableFailed:
|
||||
return _RowAction(
|
||||
icon: Icons.refresh,
|
||||
label: l10n.retry,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => ref.read(uploadEngineProvider).retryNow(account, task.id),
|
||||
);
|
||||
// 网络等待自动恢复,保留手动重试入口(§4.4「保留已有重试」)。
|
||||
case UploadTaskState.waitingNetwork:
|
||||
return IconButton(
|
||||
tooltip: l10n.retry,
|
||||
@@ -469,12 +623,16 @@ class _TaskActions extends ConsumerWidget {
|
||||
? null
|
||||
: () => ref.read(uploadEngineProvider).cancel(account, task.id),
|
||||
);
|
||||
// 终态:移除本机记录(不删云端照片,确认弹窗明确区分)。
|
||||
case UploadTaskState.completed:
|
||||
case UploadTaskState.cancelled:
|
||||
return IconButton(
|
||||
tooltip: l10n.delete,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: account == null ? null : () => _delete(ref, account),
|
||||
return _RowAction(
|
||||
icon: Icons.delete_outline,
|
||||
label: l10n.queueActionRemoveRecord,
|
||||
foreground: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
onPressed: account == null
|
||||
? null
|
||||
: () => _confirmRemoveRecord(context, ref, account),
|
||||
);
|
||||
case UploadTaskState.uploading:
|
||||
case UploadTaskState.uploadedConfirming:
|
||||
@@ -501,8 +659,32 @@ class _TaskActions extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
||||
Future<void> _delete(WidgetRef ref, String account) async {
|
||||
/// 移除记录确认:明确仅清本机记录、不删 OSS 照片(§4.4)。
|
||||
Future<void> _confirmRemoveRecord(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String account,
|
||||
) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(l10n.queueRemoveRecordTitle),
|
||||
content: Text(l10n.queueRemoveRecordBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(l10n.queueRemoveRecordConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
// 删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
||||
final store = ref.read(taskStoreProvider);
|
||||
await store.delete(task.id);
|
||||
if (task.cleanedAt == null) {
|
||||
@@ -518,7 +700,38 @@ class _TaskActions extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可自动重试的失败任务时出现)。
|
||||
/// 行内文字动作(重新选择/重试/移除记录):紧凑档,弱化为次级视觉。
|
||||
class _RowAction extends StatelessWidget {
|
||||
const _RowAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.foreground,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final Color? foreground;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: kSpace8),
|
||||
minimumSize: const Size(48, 36),
|
||||
foregroundColor:
|
||||
foreground ?? Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
icon: Icon(icon, size: 16),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed)。
|
||||
class _BatchRetryBar extends ConsumerWidget {
|
||||
const _BatchRetryBar();
|
||||
|
||||
@@ -527,7 +740,9 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final counts = ref.watch(taskCountsProvider);
|
||||
final failed = failedCountOf(counts);
|
||||
// 标题计数与摘要卡「需关注」同口径(准备失败 + 上传失败);按钮仅
|
||||
// 重试其中的可重试子集,范围文案明确区分(出现条件见 QueueTab)。
|
||||
final needsAttention = failedCountOf(counts);
|
||||
return Material(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
child: SafeArea(
|
||||
@@ -541,11 +756,26 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.queueStateFailed} · $failed',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: Column(
|
||||
// Scaffold 对 bottomNavigationBar 给的是宽松高度约束,
|
||||
// 默认 max 会把整条栏撑到剩余全高、把 body 压成 0;min 使
|
||||
// 栏高 = 两行文字 + 内边距。
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n.queueStateNeedsAttention} · $needsAttention',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.queueRetryAllScope,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
@@ -564,21 +794,16 @@ class _BatchRetryBar extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 全部可自动重试任务(retryableFailed + waitingNetwork,跨当前筛选)
|
||||
/// 立即重新入队。
|
||||
/// 全部可重试失败任务(retryableFailed,跨当前筛选)立即重新入队。
|
||||
/// 等待网络任务自动恢复、准备失败需换源重选,均不进入批量重试。
|
||||
Future<void> _retryAll(WidgetRef ref) async {
|
||||
final account = ref.read(sessionAccountProvider).value;
|
||||
if (account == null) return;
|
||||
final engine = ref.read(uploadEngineProvider);
|
||||
final tasks = await ref
|
||||
.read(taskStoreProvider)
|
||||
.listByAccount(
|
||||
account,
|
||||
states: const {
|
||||
UploadTaskState.retryableFailed,
|
||||
UploadTaskState.waitingNetwork,
|
||||
},
|
||||
);
|
||||
final tasks = await ref.read(taskStoreProvider).listByAccount(
|
||||
account,
|
||||
states: const {UploadTaskState.retryableFailed},
|
||||
);
|
||||
for (final task in tasks) {
|
||||
await engine.retryNow(account, task.id);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/// 服务端上传历史页(P3 计划 Task 6,A19 语义)。
|
||||
/// 服务端上传历史页(UI-01 §4.4 主题统一,P3 计划 Task 6 / A19 语义)。
|
||||
///
|
||||
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
|
||||
/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId /
|
||||
/// 创建时间。顶部横幅明确标注「本机待传队列不在此列」——本机队列是
|
||||
/// drift 任务库(TaskStore),两者数据源与生命周期不同(A19 标注要求)。
|
||||
/// 创建时间。页面标题即「服务端历史(跨设备)」,顶部说明条明确「本机
|
||||
/// 待传队列在上传页」——本机队列是 drift 任务库(TaskStore),两者数据源
|
||||
/// 与生命周期不同,不合并统计(A19 标注要求)。
|
||||
///
|
||||
/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示
|
||||
/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时
|
||||
@@ -23,6 +24,10 @@ import '../core/error_l10n.dart';
|
||||
import '../features/photos/models.dart';
|
||||
import '../features/uploads/models.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/empty_state.dart';
|
||||
import '../ui/components/error_state.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 服务端上传历史(从队列页占位入口进入)。
|
||||
class ServerHistoryPage extends ConsumerStatefulWidget {
|
||||
@@ -139,11 +144,11 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// A19 标注横幅:与「队列」页(本机任务库)的数据源区分。
|
||||
// A19 标注说明条:与「上传」页(本机任务库)的数据源区分。
|
||||
Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(kSpace12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -151,7 +156,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.serverHistoryBanner,
|
||||
@@ -167,61 +172,44 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _items.isEmpty && _errorCode != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.cloud_off, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.messageForErrorCode(_errorCode!)),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _reload,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
: _errorCode != null
|
||||
? ErrorState(
|
||||
message: l10n.messageForErrorCode(_errorCode!),
|
||||
onRetry: _reload,
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _items.length +
|
||||
(_items.isEmpty || _hasMore || _loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (_items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
l10n.serverHistoryEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
: _items.isEmpty
|
||||
? EmptyState(
|
||||
icon: Icons.cloud_done_outlined,
|
||||
title: l10n.serverHistoryEmpty,
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: _items.length +
|
||||
(_hasMore || _loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _items.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index >= _items.length - 3 &&
|
||||
_hasMore &&
|
||||
!_loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _loadMore(),
|
||||
);
|
||||
}
|
||||
return _sessionTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (index >= _items.length - 3 &&
|
||||
_hasMore &&
|
||||
!_loadingMore) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _loadMore(),
|
||||
);
|
||||
}
|
||||
return _sessionTile(l10n, theme, _items[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -260,14 +248,26 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_HistoryTag(
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.state) {
|
||||
UploadSessionState.confirmed => BadgeVariant.success,
|
||||
UploadSessionState.pending => BadgeVariant.info,
|
||||
UploadSessionState.cancelled ||
|
||||
UploadSessionState.expired => BadgeVariant.neutral,
|
||||
},
|
||||
label: _sessionStateLabel(l10n, record.state),
|
||||
highlight: record.state == UploadSessionState.confirmed,
|
||||
),
|
||||
_HistoryTag(
|
||||
StateBadge(
|
||||
compact: true,
|
||||
variant: switch (record.photo.processingState) {
|
||||
PhotoProcessingState.ready => BadgeVariant.success,
|
||||
PhotoProcessingState.processing ||
|
||||
PhotoProcessingState.uploaded => BadgeVariant.info,
|
||||
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
|
||||
PhotoProcessingState.processingFailed => BadgeVariant.error,
|
||||
},
|
||||
label: _processingLabel(l10n, record.photo.processingState),
|
||||
highlight: record.photo.processingState ==
|
||||
PhotoProcessingState.ready,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -308,33 +308,3 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 会话记录标签(会话状态/处理状态)。
|
||||
class _HistoryTag extends StatelessWidget {
|
||||
const _HistoryTag({required this.label, required this.highlight});
|
||||
|
||||
final String label;
|
||||
final bool highlight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: highlight
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: highlight
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
/// 分享二维码对话框(P3 计划 Task 4)。
|
||||
/// 分享二维码对话框(UI-01 §4.5 分层重构,P3 计划 Task 4 基础)。
|
||||
///
|
||||
/// viewer 链接构造:`{VIEWER_BASE}/a/{publicToken}`([ApiConfig.
|
||||
/// viewerAlbumUrl],VIEWER_BASE 经 --dart-define 覆盖,dev=模拟器宿主
|
||||
/// 回环 5175)。QrImageView 渲染链接 + 链接文本可长按选中 + 一键复制。
|
||||
/// 分层:相册标题 → 二维码(白底突出,暗色主题保持扫码对比度)→
|
||||
/// 链接文本(可选中)→ 操作行(复制链接为主操作,「在浏览器中打开」为
|
||||
/// 次操作,经 url_launcher 外链打开)。viewer 链接构造:
|
||||
/// `{VIEWER_BASE}/a/{publicToken}`([ApiConfig.viewerAlbumUrl])。
|
||||
///
|
||||
/// 用 [Dialog] 而非 [AlertDialog]:AlertDialog 内容经 IntrinsicWidth
|
||||
/// 测量,而 QrImageView 内部使用 LayoutBuilder(不支持 intrinsic 计算)
|
||||
/// 会抛布局断言,普通 Dialog 无此路径。
|
||||
/// 会抛布局断言,普通 Dialog 无此路径。外链打开经 Provider 注入
|
||||
/// ([urlLauncherProvider]),widget 测试 override 替身免平台通道。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../core/config.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/tokens.dart';
|
||||
|
||||
/// 观众分享二维码对话框([publicToken] 来自相册详情)。
|
||||
class ShareQrDialog extends StatelessWidget {
|
||||
const ShareQrDialog({required this.publicToken, super.key});
|
||||
/// 外链打开注入点(生产=url_launcher;测试 override)。
|
||||
typedef UrlLauncher = Future<bool> Function(String url);
|
||||
|
||||
/// Provider:外部浏览器打开 URL(LaunchMode.externalApplication——浏览器
|
||||
/// 才能承载观众页的全部交互,内置 WebView 不适用)。
|
||||
final urlLauncherProvider = Provider<UrlLauncher>(
|
||||
(ref) => (url) =>
|
||||
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
|
||||
);
|
||||
|
||||
/// 观众分享二维码对话框([publicToken] 来自相册详情;[albumTitle] 可选
|
||||
/// 分层标题,缺省回退通用标题)。
|
||||
class ShareQrDialog extends ConsumerWidget {
|
||||
const ShareQrDialog({required this.publicToken, super.key, this.albumTitle});
|
||||
|
||||
final String publicToken;
|
||||
|
||||
/// 相册展示名(当前界面语言回退解析后的标题)。
|
||||
final String? albumTitle;
|
||||
|
||||
Future<void> _copy(BuildContext context, String url) async {
|
||||
await Clipboard.setData(ClipboardData(text: url));
|
||||
if (!context.mounted) return;
|
||||
@@ -32,8 +51,18 @@ class ShareQrDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openInBrowser(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String url,
|
||||
) async {
|
||||
final launched = await ref.read(urlLauncherProvider)(url);
|
||||
if (!launched || !context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final url = ApiConfig.viewerAlbumUrl(publicToken);
|
||||
@@ -44,18 +73,26 @@ class ShareQrDialog extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 分层 1:相册标题(回退通用标题)。
|
||||
Text(
|
||||
l10n.shareQrTitle,
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
albumTitle ?? l10n.shareQrTitle,
|
||||
style: theme.textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
l10n.shareQrTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
// 分层 2:二维码(白底突出)。
|
||||
Center(
|
||||
// 白底保证暗色主题下的扫码对比度。
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(kSpace8),
|
||||
child: QrImageView(
|
||||
data: url,
|
||||
version: QrVersions.auto,
|
||||
@@ -64,13 +101,14 @@ class ShareQrDialog extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
// 分层 3:链接文本(可长按选中)。
|
||||
SelectableText(
|
||||
url,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.qrHint,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -78,18 +116,21 @@ class ShareQrDialog extends StatelessWidget {
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: kSpace12),
|
||||
// 分层 4:操作行——复制为主操作,浏览器打开为次操作。
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => _copy(context, url),
|
||||
icon: const Icon(Icons.copy_outlined),
|
||||
label: Text(l10n.copyLink),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _copy(context, url),
|
||||
icon: const Icon(Icons.copy_outlined, size: 18),
|
||||
label: Text(l10n.copyLink),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.ok),
|
||||
onPressed: () => _openInBrowser(context, ref, url),
|
||||
child: Text(l10n.openInBrowser),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user