tasq/lib/widgets/app_state_view.dart

166 lines
4.5 KiB
Dart

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!,
],
],
),
),
),
);
}
}