546 lines
17 KiB
Dart
546 lines
17 KiB
Dart
|
|
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
|
|||
|
|
///
|
|||
|
|
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
|
|||
|
|
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
|
|||
|
|
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
|
|||
|
|
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
|
|||
|
|
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
|
|||
|
|
/// 承担,见 native_transport.dart)。
|
|||
|
|
///
|
|||
|
|
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
|
|||
|
|
/// 隔离平台通道,widget 测试注入替身驱动。
|
|||
|
|
library;
|
|||
|
|
|
|||
|
|
import 'dart:async';
|
|||
|
|
|
|||
|
|
import 'package:camera/camera.dart';
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
|
|
|
|||
|
|
import '../features/albums/models.dart';
|
|||
|
|
import '../features/uploads/enqueue.dart';
|
|||
|
|
import '../features/uploads/queue_controller.dart';
|
|||
|
|
import '../l10n/app_localizations.dart';
|
|||
|
|
|
|||
|
|
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
|
|||
|
|
|
|||
|
|
/// 拍摄源:初始化/拍摄/预览/释放。
|
|||
|
|
abstract class CaptureSource {
|
|||
|
|
/// 初始化(权限缺失/失败抛异常;权限拒绝抛 [CameraException])。
|
|||
|
|
Future<void> initialize();
|
|||
|
|
|
|||
|
|
/// 拍摄一张(返回源文件)。
|
|||
|
|
Future<XFile> capture();
|
|||
|
|
|
|||
|
|
/// 取景预览。
|
|||
|
|
Widget buildPreview();
|
|||
|
|
|
|||
|
|
/// 释放底层资源。
|
|||
|
|
Future<void> dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
|
|||
|
|
class CameraPluginSource implements CaptureSource {
|
|||
|
|
CameraPluginSource._(this._controller);
|
|||
|
|
|
|||
|
|
/// 创建并初始化(后摄优先)。
|
|||
|
|
static Future<CameraPluginSource> create() async {
|
|||
|
|
final cameras = await availableCameras();
|
|||
|
|
if (cameras.isEmpty) {
|
|||
|
|
throw CameraException('noCamera', 'no camera available');
|
|||
|
|
}
|
|||
|
|
final back = cameras.firstWhere(
|
|||
|
|
(c) => c.lensDirection == CameraLensDirection.back,
|
|||
|
|
orElse: () => cameras.first,
|
|||
|
|
);
|
|||
|
|
final controller = CameraController(back, ResolutionPreset.max,
|
|||
|
|
enableAudio: false);
|
|||
|
|
await controller.initialize();
|
|||
|
|
return CameraPluginSource._(controller);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final CameraController _controller;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Future<void> initialize() async {}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Future<XFile> capture() => _controller.takePicture();
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget buildPreview() => CameraPreview(_controller);
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Future<void> dispose() => _controller.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 拍摄源工厂 Provider(测试 override 为替身)。
|
|||
|
|
final captureSourceFactoryProvider =
|
|||
|
|
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
|
|||
|
|
|
|||
|
|
// ── 连拍页 ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/// 连拍页参数:目标相册(相册详情 FAB 进入时固定)与分类候选。
|
|||
|
|
class CaptureTarget {
|
|||
|
|
const CaptureTarget({
|
|||
|
|
required this.accountId,
|
|||
|
|
required this.albumId,
|
|||
|
|
required this.albumTitle,
|
|||
|
|
this.categories = const <Category>[],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
/// 入队账号(TaskStore 隔离键)。
|
|||
|
|
final String accountId;
|
|||
|
|
|
|||
|
|
/// 目标相册 ID。
|
|||
|
|
final String albumId;
|
|||
|
|
|
|||
|
|
/// 目标相册展示名(信息层常驻显示)。
|
|||
|
|
final String albumTitle;
|
|||
|
|
|
|||
|
|
/// 相册分类(空 = 无可切换项,仅默认分类)。
|
|||
|
|
final List<Category> categories;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 连拍页。
|
|||
|
|
class CapturePage extends ConsumerStatefulWidget {
|
|||
|
|
const CapturePage({required this.target, super.key});
|
|||
|
|
|
|||
|
|
final CaptureTarget target;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ConsumerState<CapturePage> createState() => _CapturePageState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
enum _CameraPhase { loading, ready, denied, failed }
|
|||
|
|
|
|||
|
|
class _CapturePageState extends ConsumerState<CapturePage> {
|
|||
|
|
CaptureSource? _source;
|
|||
|
|
_CameraPhase _phase = _CameraPhase.loading;
|
|||
|
|
|
|||
|
|
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
|
|||
|
|
String? _categoryId;
|
|||
|
|
|
|||
|
|
/// 连拍串行链:快门连点逐张落地(相机硬件本就串行)。
|
|||
|
|
Future<void> _shotTail = Future<void>.value();
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_initCamera();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
_source?.dispose().ignore();
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _initCamera() async {
|
|||
|
|
setState(() => _phase = _CameraPhase.loading);
|
|||
|
|
try {
|
|||
|
|
final source = await ref.read(captureSourceFactoryProvider)();
|
|||
|
|
await source.initialize();
|
|||
|
|
if (!mounted) {
|
|||
|
|
await source.dispose();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
_source = source;
|
|||
|
|
setState(() => _phase = _CameraPhase.ready);
|
|||
|
|
} on Object catch (error) {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() => _phase = _isPermissionDenied(error)
|
|||
|
|
? _CameraPhase.denied
|
|||
|
|
: _CameraPhase.failed);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
|
|||
|
|
/// CameraAccessDeniedWithoutD restriction 等)。
|
|||
|
|
static bool _isPermissionDenied(Object error) =>
|
|||
|
|
error is CameraException &&
|
|||
|
|
(error.code.toLowerCase().contains('denied') ||
|
|||
|
|
error.code.toLowerCase().contains('permission'));
|
|||
|
|
|
|||
|
|
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
|
|||
|
|
void _shoot() {
|
|||
|
|
final source = _source;
|
|||
|
|
if (source == null || _phase != _CameraPhase.ready) return;
|
|||
|
|
final categoryId = _categoryId;
|
|||
|
|
_shotTail = _shotTail.then((_) async {
|
|||
|
|
final photo = await source.capture();
|
|||
|
|
if (!mounted) return;
|
|||
|
|
await enqueueForUpload(
|
|||
|
|
ref,
|
|||
|
|
accountId: widget.target.accountId,
|
|||
|
|
albumId: widget.target.albumId,
|
|||
|
|
categoryId: categoryId,
|
|||
|
|
sourcePaths: [photo.path],
|
|||
|
|
);
|
|||
|
|
}).catchError((Object _) {
|
|||
|
|
// 单张失败吞掉(连拍不中断;失败任务/错误在徽章与队列页可见)。
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 相册多选入队(目标 = 当前相册与分类)。
|
|||
|
|
Future<void> _pickFromGallery() async {
|
|||
|
|
final paths = await ref.read(multiImagePickerProvider)();
|
|||
|
|
if (paths.isEmpty || !mounted) return;
|
|||
|
|
final l10n = AppLocalizations.of(context);
|
|||
|
|
final messenger = ScaffoldMessenger.of(context);
|
|||
|
|
final count = await enqueueForUpload(
|
|||
|
|
ref,
|
|||
|
|
accountId: widget.target.accountId,
|
|||
|
|
albumId: widget.target.albumId,
|
|||
|
|
categoryId: _categoryId,
|
|||
|
|
sourcePaths: paths,
|
|||
|
|
);
|
|||
|
|
if (!mounted) return;
|
|||
|
|
messenger.showSnackBar(
|
|||
|
|
SnackBar(content: Text(l10n.uploadEnqueuedCount(count))),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _switchCategory(String? categoryId) {
|
|||
|
|
if (categoryId == _categoryId) return;
|
|||
|
|
setState(() => _categoryId = categoryId);
|
|||
|
|
// 归属不变提示(A15):仅影响之后拍摄。
|
|||
|
|
ScaffoldMessenger.of(context)
|
|||
|
|
..hideCurrentSnackBar()
|
|||
|
|
..showSnackBar(
|
|||
|
|
SnackBar(content: Text(AppLocalizations.of(context).captureSwitchHint)),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _confirmExit() async {
|
|||
|
|
final l10n = AppLocalizations.of(context);
|
|||
|
|
final leave = await showDialog<bool>(
|
|||
|
|
context: context,
|
|||
|
|
builder: (dialogContext) => AlertDialog(
|
|||
|
|
title: Text(l10n.captureExitTitle),
|
|||
|
|
content: Text(
|
|||
|
|
l10n.captureExitBody(pendingCountOf(ref.read(taskCountsProvider))),
|
|||
|
|
),
|
|||
|
|
actions: [
|
|||
|
|
TextButton(
|
|||
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|||
|
|
child: Text(l10n.captureKeepShooting),
|
|||
|
|
),
|
|||
|
|
FilledButton(
|
|||
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|||
|
|
child: Text(l10n.captureExitConfirm),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
if (leave == true && mounted) {
|
|||
|
|
Navigator.of(context).pop();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
final l10n = AppLocalizations.of(context);
|
|||
|
|
final counts = ref.watch(taskCountsProvider);
|
|||
|
|
final pending = pendingCountOf(counts);
|
|||
|
|
final failed = failedCountOf(counts);
|
|||
|
|
final categories = widget.target.categories;
|
|||
|
|
|
|||
|
|
return PopScope(
|
|||
|
|
// 有待传任务 → 拦截退出弹确认(后台继续文案);无任务直接退出。
|
|||
|
|
canPop: pending == 0,
|
|||
|
|
onPopInvokedWithResult: (didPop, _) {
|
|||
|
|
if (didPop) return;
|
|||
|
|
_confirmExit();
|
|||
|
|
},
|
|||
|
|
child: Scaffold(
|
|||
|
|
backgroundColor: Colors.black,
|
|||
|
|
appBar: AppBar(
|
|||
|
|
backgroundColor: Colors.black87,
|
|||
|
|
foregroundColor: Colors.white,
|
|||
|
|
title: Text(l10n.captureTitle),
|
|||
|
|
),
|
|||
|
|
body: Stack(
|
|||
|
|
fit: StackFit.expand,
|
|||
|
|
children: [
|
|||
|
|
_buildCameraArea(l10n),
|
|||
|
|
// 常驻信息层:相册 + 分类 + 待传/失败徽章(R17/O01)。
|
|||
|
|
SafeArea(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
_TargetOverlay(
|
|||
|
|
albumTitle: widget.target.albumTitle,
|
|||
|
|
categories: categories,
|
|||
|
|
categoryId: _categoryId,
|
|||
|
|
onCategorySelected: _switchCategory,
|
|||
|
|
pending: pending,
|
|||
|
|
failed: failed,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
bottomNavigationBar: _buildBottomBar(l10n),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildCameraArea(AppLocalizations l10n) {
|
|||
|
|
final source = _source;
|
|||
|
|
switch (_phase) {
|
|||
|
|
case _CameraPhase.ready:
|
|||
|
|
return source!.buildPreview();
|
|||
|
|
case _CameraPhase.loading:
|
|||
|
|
return const Center(
|
|||
|
|
child: CircularProgressIndicator(color: Colors.white),
|
|||
|
|
);
|
|||
|
|
case _CameraPhase.denied:
|
|||
|
|
return _CameraMessageView(
|
|||
|
|
icon: Icons.no_photography_outlined,
|
|||
|
|
message: l10n.cameraPermissionDenied,
|
|||
|
|
actionLabel: l10n.retry,
|
|||
|
|
onAction: _initCamera,
|
|||
|
|
);
|
|||
|
|
case _CameraPhase.failed:
|
|||
|
|
return _CameraMessageView(
|
|||
|
|
icon: Icons.error_outline,
|
|||
|
|
message: l10n.cameraInitFailed,
|
|||
|
|
actionLabel: l10n.retry,
|
|||
|
|
onAction: _initCamera,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildBottomBar(AppLocalizations l10n) {
|
|||
|
|
return BottomAppBar(
|
|||
|
|
color: Colors.black87,
|
|||
|
|
child: Row(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|||
|
|
children: [
|
|||
|
|
IconButton(
|
|||
|
|
tooltip: l10n.capturePickFromGallery,
|
|||
|
|
icon: const Icon(Icons.photo_library_outlined,
|
|||
|
|
color: Colors.white),
|
|||
|
|
onPressed: _pickFromGallery,
|
|||
|
|
),
|
|||
|
|
_ShutterButton(onPressed: _shoot, enabled: _phase == _CameraPhase.ready),
|
|||
|
|
// 占位对齐(快门居中)。
|
|||
|
|
const SizedBox(width: 48),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 信息层组件 ────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/// 顶部常驻信息层:相册名 + 分类 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,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final String albumTitle;
|
|||
|
|
final List<Category> categories;
|
|||
|
|
final String? categoryId;
|
|||
|
|
final void Function(String?) onCategorySelected;
|
|||
|
|
final int pending;
|
|||
|
|
final int failed;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
final l10n = AppLocalizations.of(context);
|
|||
|
|
return Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|||
|
|
children: [
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
const Icon(Icons.photo_album_outlined,
|
|||
|
|
size: 16, color: Colors.white70),
|
|||
|
|
const SizedBox(width: 6),
|
|||
|
|
Expanded(
|
|||
|
|
child: Text(
|
|||
|
|
albumTitle,
|
|||
|
|
maxLines: 1,
|
|||
|
|
overflow: TextOverflow.ellipsis,
|
|||
|
|
style: const TextStyle(color: Colors.white),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: 8),
|
|||
|
|
_Badge(
|
|||
|
|
label: l10n.capturePendingBadge(pending),
|
|||
|
|
color: Colors.white24,
|
|||
|
|
foreground: Colors.white,
|
|||
|
|
),
|
|||
|
|
if (failed > 0) ...[
|
|||
|
|
const SizedBox(width: 6),
|
|||
|
|
_Badge(
|
|||
|
|
label: l10n.captureFailedBadge(failed),
|
|||
|
|
color: Colors.red,
|
|||
|
|
foreground: Colors.white,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
if (categories.isNotEmpty)
|
|||
|
|
SizedBox(
|
|||
|
|
height: 44,
|
|||
|
|
child: ListView(
|
|||
|
|
scrollDirection: Axis.horizontal,
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
|||
|
|
children: [
|
|||
|
|
ChoiceChip(
|
|||
|
|
label: Text(l10n.captureCategoryDefault),
|
|||
|
|
selected: categoryId == null,
|
|||
|
|
onSelected: (_) => onCategorySelected(null),
|
|||
|
|
labelStyle: const TextStyle(color: Colors.white),
|
|||
|
|
selectedColor: Colors.white24,
|
|||
|
|
backgroundColor: Colors.black38,
|
|||
|
|
),
|
|||
|
|
for (final category in categories) ...[
|
|||
|
|
const SizedBox(width: 8),
|
|||
|
|
ChoiceChip(
|
|||
|
|
label: Text(_categoryLabel(category, l10n)),
|
|||
|
|
selected: categoryId == category.id,
|
|||
|
|
onSelected: (_) => onCategorySelected(category.id),
|
|||
|
|
labelStyle: const TextStyle(color: Colors.white),
|
|||
|
|
selectedColor: Colors.white24,
|
|||
|
|
backgroundColor: Colors.black38,
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static String _categoryLabel(Category category, AppLocalizations l10n) {
|
|||
|
|
final lang = l10n.localeName.split('-').first.toLowerCase();
|
|||
|
|
CategoryTranslation? match;
|
|||
|
|
for (final t in category.translations) {
|
|||
|
|
if (t.locale.toLowerCase() == l10n.localeName.toLowerCase() ||
|
|||
|
|
t.locale.toLowerCase().split('-').first == lang) {
|
|||
|
|
match = t;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (match != null) return match.name;
|
|||
|
|
return category.translations.isEmpty
|
|||
|
|
? category.id
|
|||
|
|
: category.translations.first.name;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 徽章(待传/失败计数)。
|
|||
|
|
class _Badge extends StatelessWidget {
|
|||
|
|
const _Badge({
|
|||
|
|
required this.label,
|
|||
|
|
required this.color,
|
|||
|
|
required this.foreground,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final String label;
|
|||
|
|
final Color color;
|
|||
|
|
final Color foreground;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: color,
|
|||
|
|
borderRadius: BorderRadius.circular(999),
|
|||
|
|
),
|
|||
|
|
child: Text(
|
|||
|
|
label,
|
|||
|
|
style: TextStyle(color: foreground, fontSize: 12),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
|
|||
|
|
class _ShutterButton extends StatelessWidget {
|
|||
|
|
const _ShutterButton({required this.onPressed, required this.enabled});
|
|||
|
|
|
|||
|
|
final VoidCallback onPressed;
|
|||
|
|
final bool enabled;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Semantics(
|
|||
|
|
button: true,
|
|||
|
|
label: AppLocalizations.of(context).captureShutter,
|
|||
|
|
child: IconButton(
|
|||
|
|
onPressed: enabled ? onPressed : null,
|
|||
|
|
iconSize: 64,
|
|||
|
|
icon: Container(
|
|||
|
|
width: 64,
|
|||
|
|
height: 64,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
shape: BoxShape.circle,
|
|||
|
|
border: Border.all(color: Colors.white, width: 4),
|
|||
|
|
),
|
|||
|
|
child: const Icon(Icons.camera, color: Colors.white),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
|
|||
|
|
class _CameraMessageView extends StatelessWidget {
|
|||
|
|
const _CameraMessageView({
|
|||
|
|
required this.icon,
|
|||
|
|
required this.message,
|
|||
|
|
required this.actionLabel,
|
|||
|
|
required this.onAction,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
final IconData icon;
|
|||
|
|
final String message;
|
|||
|
|
final String actionLabel;
|
|||
|
|
final VoidCallback onAction;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
return Container(
|
|||
|
|
color: Colors.black87,
|
|||
|
|
child: Center(
|
|||
|
|
child: Column(
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
children: [
|
|||
|
|
Icon(icon, size: 48, color: Colors.white70),
|
|||
|
|
const SizedBox(height: 12),
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
|||
|
|
child: Text(
|
|||
|
|
message,
|
|||
|
|
textAlign: TextAlign.center,
|
|||
|
|
style: const TextStyle(color: Colors.white),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
FilledButton.icon(
|
|||
|
|
onPressed: onAction,
|
|||
|
|
icon: const Icon(Icons.refresh),
|
|||
|
|
label: Text(actionLabel),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|