175 lines
4.9 KiB
Dart
175 lines
4.9 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../theme/app_colors.dart';
|
|
import '../theme/app_dimens.dart';
|
|
|
|
const double _regularIconSize = 48;
|
|
const double _compactIconSize = 32;
|
|
const double _maxContentWidth = 260;
|
|
const double _regularHorizontal = 24;
|
|
const double _regularVertical = 28;
|
|
const double _compactPad = 16;
|
|
const double _regularGap = 20;
|
|
const double _compactGap = 12;
|
|
const double _actionMinHeight = 44;
|
|
|
|
/// 统一空列表占位:一枚图标、标题、可选说明与操作。
|
|
class EmptyState extends StatelessWidget {
|
|
const EmptyState({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.actionLabel,
|
|
this.actionKey,
|
|
this.onAction,
|
|
this.footer,
|
|
this.compact = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String? subtitle;
|
|
final String? actionLabel;
|
|
final Key? actionKey;
|
|
final VoidCallback? onAction;
|
|
final Widget? footer;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasAction = actionLabel != null && onAction != null;
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
key: const Key('empty-state-content'),
|
|
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
|
child: Padding(
|
|
key: const Key('empty-state-padding'),
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: compact ? _compactPad : _regularHorizontal,
|
|
vertical: compact ? _compactPad : _regularVertical,
|
|
),
|
|
child: Semantics(
|
|
container: true,
|
|
label: title,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ExcludeSemantics(
|
|
child: _EmptyMark(icon: icon, compact: compact),
|
|
),
|
|
SizedBox(height: compact ? _compactGap : _regularGap),
|
|
_EmptyCopy(
|
|
title: title,
|
|
subtitle: subtitle,
|
|
actionLabel: hasAction ? actionLabel : null,
|
|
actionKey: actionKey,
|
|
onAction: hasAction ? onAction : null,
|
|
footer: footer,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 空状态只留一枚图标,不加水印底和描边。
|
|
class _EmptyMark extends StatelessWidget {
|
|
const _EmptyMark({required this.icon, required this.compact});
|
|
|
|
final IconData icon;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dark = Theme.of(context).brightness == Brightness.dark;
|
|
return Icon(
|
|
icon,
|
|
key: const Key('empty-state-icon'),
|
|
size: compact ? _compactIconSize : _regularIconSize,
|
|
color: dark ? AppColors.gold : AppColors.blue,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyCopy extends StatelessWidget {
|
|
const _EmptyCopy({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.actionLabel,
|
|
required this.actionKey,
|
|
required this.onAction,
|
|
required this.footer,
|
|
});
|
|
|
|
final String title;
|
|
final String? subtitle;
|
|
final String? actionLabel;
|
|
final Key? actionKey;
|
|
final VoidCallback? onAction;
|
|
final Widget? footer;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ExcludeSemantics(
|
|
child: Text(
|
|
title,
|
|
key: const Key('empty-state-title'),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
color: scheme.onSurface,
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 6),
|
|
ExcludeSemantics(
|
|
child: Text(
|
|
subtitle!,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: AppColors.muted,
|
|
fontSize: AppDimens.emptyStateSubtitle,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
if (actionLabel != null && onAction != null) ...[
|
|
const SizedBox(height: 8),
|
|
TextButton(
|
|
key: actionKey ?? const Key('empty-state-action'),
|
|
onPressed: onAction,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.blueSoft,
|
|
minimumSize: const Size(44, _actionMinHeight),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
),
|
|
child: Text(
|
|
actionLabel!,
|
|
style: const TextStyle(fontSize: AppDimens.emptyStateAction),
|
|
),
|
|
),
|
|
],
|
|
if (footer != null) ...[const SizedBox(height: 12), footer!],
|
|
],
|
|
);
|
|
}
|
|
}
|