Enhanced material design 3 implementation
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
/// A centered error state with an icon, title, human-readable message and an
|
||||
/// optional retry button.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// if (async.hasError && !async.hasValue) {
|
||||
/// return AppErrorView(
|
||||
/// error: async.error!,
|
||||
/// onRetry: () => ref.invalidate(myProvider),
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class AppErrorView extends StatelessWidget {
|
||||
const AppErrorView({
|
||||
super.key,
|
||||
required this.error,
|
||||
this.title = 'Something went wrong',
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
final Object error;
|
||||
|
||||
/// Short title shown above the error message.
|
||||
final String title;
|
||||
|
||||
/// When provided, a "Try again" button is rendered below the message.
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final message = _humanise(error);
|
||||
|
||||
return Center(
|
||||
child: M3FadeSlideIn(
|
||||
duration: M3Motion.standard,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
M3BounceIcon(
|
||||
icon: Icons.error_outline_rounded,
|
||||
iconColor: cs.onErrorContainer,
|
||||
backgroundColor: cs.errorContainer,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
title,
|
||||
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('Try again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips common Dart exception prefixes so the user sees a clean message.
|
||||
static String _humanise(Object error) {
|
||||
var text = error.toString();
|
||||
const prefixes = ['Exception: ', 'Error: ', 'FormatException: '];
|
||||
for (final p in prefixes) {
|
||||
if (text.startsWith(p)) {
|
||||
text = text.substring(p.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return text.isEmpty ? 'An unexpected error occurred.' : text;
|
||||
}
|
||||
}
|
||||
|
||||
/// A centered empty-state view with an icon, title and optional subtitle.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// if (items.isEmpty && !loading) {
|
||||
/// return const AppEmptyView(
|
||||
/// icon: Icons.task_outlined,
|
||||
/// title: 'No tasks yet',
|
||||
/// subtitle: 'Tasks assigned to you will appear here.',
|
||||
/// );
|
||||
/// }
|
||||
/// ```
|
||||
class AppEmptyView extends StatelessWidget {
|
||||
const AppEmptyView({
|
||||
super.key,
|
||||
this.icon = Icons.inbox_outlined,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.action,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
/// Optional call-to-action placed below the subtitle.
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
|
||||
return Center(
|
||||
child: M3FadeSlideIn(
|
||||
duration: M3Motion.standard,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
M3BounceIcon(
|
||||
icon: icon,
|
||||
iconColor: cs.onSurfaceVariant,
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
title,
|
||||
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user