Enhanced material design 3 implementation
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/m3_motion.dart';
|
||||
|
||||
/// Standardized M3 Expressive page header with animated entrance.
|
||||
///
|
||||
/// Replaces the ad-hoc `Padding(Align(Text(...)))` pattern found across
|
||||
/// screens. Provides:
|
||||
/// * `headlineSmall` typography — correct M3 size for a top-level page title.
|
||||
/// * [M3FadeSlideIn] entrance animation — 400 ms with emphasized easing.
|
||||
/// * Optional [subtitle] in `bodyMedium` / `onSurfaceVariant`.
|
||||
/// * Optional [actions] row rendered at the trailing end.
|
||||
/// * Responsive alignment: left-aligned on desktop (≥600 dp), centered on
|
||||
/// mobile to match the existing navigation-bar-centric layout.
|
||||
class AppPageHeader extends StatelessWidget {
|
||||
const AppPageHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.actions,
|
||||
this.padding = const EdgeInsets.only(top: 20, bottom: 12),
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
|
||||
/// Optional trailing action widgets (e.g. filter chip, icon button).
|
||||
final List<Widget>? actions;
|
||||
|
||||
/// Vertical padding around the header. Horizontal padding is handled by
|
||||
/// the parent [ResponsiveBody] so only top/bottom should be set here.
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final titleWidget = Text(
|
||||
title,
|
||||
style: tt.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.3,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
final subtitleWidget = subtitle == null
|
||||
? null
|
||||
: Text(
|
||||
subtitle!,
|
||||
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
final hasActions = actions != null && actions!.isNotEmpty;
|
||||
|
||||
Widget content;
|
||||
if (!hasActions) {
|
||||
content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
titleWidget,
|
||||
if (subtitleWidget != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
subtitleWidget,
|
||||
],
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// With actions — centered title/subtitle, actions sit to the right.
|
||||
content = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
titleWidget,
|
||||
if (subtitleWidget != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
subtitleWidget,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(mainAxisSize: MainAxisSize.min, children: actions!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return M3FadeSlideIn(
|
||||
duration: M3Motion.standard,
|
||||
child: Padding(padding: padding, child: content),
|
||||
);
|
||||
}
|
||||
}
|
||||
+35
-25
@@ -84,6 +84,7 @@ class AppScaffold extends ConsumerWidget {
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName),
|
||||
@@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
|
||||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadCount = ref.watch(unreadNotificationsCountProvider);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () => context.go('/notifications'),
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications),
|
||||
if (unreadCount > 0)
|
||||
const Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Icon(Icons.circle, size: 10, color: Colors.red),
|
||||
const Icon(Icons.notifications_outlined),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: unreadCount > 0
|
||||
? Positioned(
|
||||
key: const ValueKey('badge'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -422,24 +447,12 @@ List<NavSection> _buildSections(String role) {
|
||||
icon: Icons.apartment_outlined,
|
||||
selectedIcon: Icons.apartment,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Geofence test',
|
||||
route: '/settings/geofence-test',
|
||||
icon: Icons.map_outlined,
|
||||
selectedIcon: Icons.map,
|
||||
),
|
||||
NavItem(
|
||||
label: 'IT Staff Teams',
|
||||
route: '/settings/teams',
|
||||
icon: Icons.groups_2_outlined,
|
||||
selectedIcon: Icons.groups_2,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Permissions',
|
||||
route: '/settings/permissions',
|
||||
icon: Icons.lock_open,
|
||||
selectedIcon: Icons.lock,
|
||||
),
|
||||
if (kIsWeb) ...[
|
||||
NavItem(
|
||||
label: 'App Update',
|
||||
@@ -459,19 +472,16 @@ List<NavSection> _buildSections(String role) {
|
||||
];
|
||||
}
|
||||
|
||||
// non-admin users still get a simple Settings section containing only
|
||||
// permissions. this keeps the screen accessible without exposing the
|
||||
// administrative management screens.
|
||||
return [
|
||||
NavSection(label: 'Operations', items: mainItems),
|
||||
NavSection(
|
||||
label: 'Settings',
|
||||
items: [
|
||||
NavItem(
|
||||
label: 'Permissions',
|
||||
route: '/settings/permissions',
|
||||
icon: Icons.lock_open,
|
||||
selectedIcon: Icons.lock,
|
||||
label: 'Logout',
|
||||
route: '',
|
||||
icon: Icons.logout,
|
||||
isLogout: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,25 @@ import 'package:flutter/material.dart';
|
||||
/// Native Flutter profile avatar that displays either:
|
||||
/// 1. User's avatar image URL (if provided)
|
||||
/// 2. Initials derived from full name (fallback)
|
||||
///
|
||||
/// Pass [heroTag] to participate in a Hero transition to/from a destination
|
||||
/// that uses the same tag (e.g., the profile screen's large avatar).
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
const ProfileAvatar({
|
||||
super.key,
|
||||
required this.fullName,
|
||||
this.avatarUrl,
|
||||
this.radius = 18,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final String fullName;
|
||||
final String? avatarUrl;
|
||||
final double radius;
|
||||
|
||||
/// When non-null, wraps the avatar in a [Hero] with this tag.
|
||||
final Object? heroTag;
|
||||
|
||||
String _getInitials() {
|
||||
final trimmed = fullName.trim();
|
||||
if (trimmed.isEmpty) return 'U';
|
||||
@@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
|
||||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||
}
|
||||
|
||||
Color _getInitialsColor(String initials) {
|
||||
// Generate a deterministic color based on initials
|
||||
/// Returns a (background, foreground) pair from the M3 tonal palette.
|
||||
///
|
||||
/// Uses a deterministic hash of the initials to cycle through the scheme's
|
||||
/// semantic container colors so every avatar is theme-aware and accessible.
|
||||
(Color, Color) _getTonalColors(String initials, ColorScheme cs) {
|
||||
final hash =
|
||||
initials.codeUnitAt(0) +
|
||||
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
|
||||
final colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
|
||||
// Six M3-compliant container pairs (background / on-color text).
|
||||
final pairs = [
|
||||
(cs.primaryContainer, cs.onPrimaryContainer),
|
||||
(cs.secondaryContainer, cs.onSecondaryContainer),
|
||||
(cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
(cs.errorContainer, cs.onErrorContainer),
|
||||
(cs.primary, cs.onPrimary),
|
||||
(cs.secondary, cs.onSecondary),
|
||||
];
|
||||
return colors[hash % colors.length];
|
||||
|
||||
return pairs[hash % pairs.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials();
|
||||
|
||||
Widget avatar;
|
||||
|
||||
// If avatar URL is provided, attempt to load the image
|
||||
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
avatar = CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: NetworkImage(avatarUrl!),
|
||||
onBackgroundImageError: (_, _) {
|
||||
@@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
|
||||
},
|
||||
child: null, // Image will display if loaded successfully
|
||||
);
|
||||
} else {
|
||||
final (bg, fg) = _getTonalColors(
|
||||
initials,
|
||||
Theme.of(context).colorScheme,
|
||||
);
|
||||
|
||||
// Fallback to initials
|
||||
avatar = CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: bg,
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: radius * 0.8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to initials
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: _getInitialsColor(initials),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: radius * 0.8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (heroTag != null) {
|
||||
return Hero(tag: heroTag!, child: avatar);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// isEmphasized uses primaryContainer (higher tonal weight) to visually
|
||||
// distinguish high-priority statuses from routine ones.
|
||||
final background = isEmphasized
|
||||
? scheme.tertiaryContainer
|
||||
? scheme.primaryContainer
|
||||
: scheme.tertiaryContainer;
|
||||
final foreground = scheme.onTertiaryContainer;
|
||||
final foreground = isEmphasized
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onTertiaryContainer;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_typography.dart';
|
||||
import '../theme/app_surfaces.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'mono_text.dart';
|
||||
|
||||
/// A column configuration for the [TasQAdaptiveList] desktop table view.
|
||||
@@ -205,11 +206,18 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
}
|
||||
final item = items[index];
|
||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||
return _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
|
||||
final staggerDelay = Duration(
|
||||
milliseconds: math.min(index, 8) * 50,
|
||||
);
|
||||
return M3FadeSlideIn(
|
||||
delay: staggerDelay,
|
||||
child: _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -225,11 +233,17 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
}
|
||||
final item = items[index];
|
||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||
return _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
final staggerDelay = Duration(
|
||||
milliseconds: math.min(index, 8) * 50,
|
||||
);
|
||||
return M3FadeSlideIn(
|
||||
delay: staggerDelay,
|
||||
child: _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
shrinkWrap: true,
|
||||
@@ -294,35 +308,29 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||
Widget _loadingTile(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
height: 72,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
M3ShimmerBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
M3ShimmerBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
M3ShimmerBox(width: 150, height: 10),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(height: 12, color: Colors.white),
|
||||
const SizedBox(height: 8),
|
||||
Container(height: 10, width: 150, color: Colors.white),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -151,11 +151,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
),
|
||||
],
|
||||
if (_failed)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'An error occurred while downloading. Please try again.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user