681 lines
20 KiB
Dart
681 lines
20 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../api/errors/error_mapper.dart';
|
|
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart';
|
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview.dart';
|
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart';
|
|
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_response.dart';
|
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
|
import '../../../../extensions/date_time.dart';
|
|
import '../../../../model/account_data.dart';
|
|
import '../../../../model/endpoint_data.dart';
|
|
import '../../../../share_intent/remote_file_ref.dart';
|
|
import '../../../../utils/downloads/download_job.dart';
|
|
import '../../../../widget/app_progress_indicator.dart';
|
|
import '../../../../widget/demo_restricted.dart';
|
|
import '../../../../widget/downloads/download_trigger.dart';
|
|
|
|
const int _sharedItemsPageSize = 20;
|
|
|
|
/// The non-media shared-item categories Talk exposes, in tab order. `media` is
|
|
/// handled separately (split into Bilder/Videos). `location`/`deckcard` are rich
|
|
/// objects without a downloadable file, so they have no place in this view.
|
|
const List<_SharedCategory> _sharedCategories = [
|
|
_SharedCategory('file', 'Dokumente', _SharedItemsLayout.list),
|
|
_SharedCategory('voice', 'Sprachnachrichten', _SharedItemsLayout.list),
|
|
_SharedCategory('audio', 'Audio', _SharedItemsLayout.list),
|
|
_SharedCategory('recording', 'Aufnahmen', _SharedItemsLayout.list),
|
|
_SharedCategory('other', 'Sonstiges', _SharedItemsLayout.list),
|
|
];
|
|
|
|
class _SharedCategory {
|
|
final String objectType;
|
|
final String label;
|
|
final _SharedItemsLayout layout;
|
|
|
|
const _SharedCategory(this.objectType, this.label, this.layout);
|
|
}
|
|
|
|
bool _isVideoItem(GetChatResponseObject item) {
|
|
final file = item.messageParameters?['file'];
|
|
return file != null && _isVideoFile(file.name);
|
|
}
|
|
|
|
/// One page of shared items plus the pagination cursor for the next request.
|
|
/// Produced by [buildSharedItemsPage] so the in-view loader and the overview
|
|
/// seed share the exact same paging semantics.
|
|
class SharedItemsPage {
|
|
final List<GetChatResponseObject> items;
|
|
final int? lastKnownMessageId;
|
|
final bool hasMore;
|
|
|
|
const SharedItemsPage(this.items, this.lastKnownMessageId, this.hasMore);
|
|
}
|
|
|
|
List<GetChatResponseObject> _fileItems(List<GetChatResponseObject> items) => items
|
|
.where((item) => item.messageParameters?['file']?.path != null)
|
|
.toList();
|
|
|
|
SharedItemsPage buildSharedItemsPage(
|
|
GetSharedItemsResponse response,
|
|
int? previousMessageId,
|
|
) {
|
|
final lastGiven = response.lastGivenMessageId;
|
|
final hasMore =
|
|
response.items.length >= _sharedItemsPageSize &&
|
|
lastGiven != null &&
|
|
lastGiven != previousMessageId;
|
|
return SharedItemsPage(
|
|
_fileItems(response.items),
|
|
hasMore ? lastGiven : previousMessageId,
|
|
hasMore,
|
|
);
|
|
}
|
|
|
|
/// Seeds a tab from the overview payload. The pagination cursor is the oldest
|
|
/// message id already shown; the per-type endpoint takes over from there via
|
|
/// its `X-Chat-Last-Given` header. [hasMore] is a heuristic — a full page from
|
|
/// the overview means there are probably older items to fetch on scroll.
|
|
SharedItemsPage _seedFromOverview(List<GetChatResponseObject> rawItems) {
|
|
final hasMore = rawItems.length >= _sharedItemsPageSize;
|
|
final oldestId = rawItems.isEmpty
|
|
? null
|
|
: rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
|
return SharedItemsPage(
|
|
_fileItems(rawItems),
|
|
hasMore ? oldestId : null,
|
|
hasMore,
|
|
);
|
|
}
|
|
|
|
/// WhatsApp-style overview of everything shared in a chat. One tab per Talk
|
|
/// share category, but only categories that actually contain a downloadable
|
|
/// file are shown. Each tab paginates on its own via [GetSharedItems].
|
|
class SharedItemsView extends StatefulWidget {
|
|
final GetRoomResponseObject room;
|
|
|
|
/// Overview fetched ahead of time (by ChatInfo) so tabs render instantly.
|
|
/// Null => this view fetches it itself on open.
|
|
final GetSharedItemsOverviewResponse? overview;
|
|
|
|
const SharedItemsView(this.room, {this.overview, super.key});
|
|
|
|
/// Best-effort preload used by ChatInfo while the user is on the details
|
|
/// screen, so opening this view needs no round-trip.
|
|
static Future<GetSharedItemsOverviewResponse> prefetchOverview(
|
|
String token,
|
|
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
|
|
|
|
@override
|
|
State<SharedItemsView> createState() => _SharedItemsViewState();
|
|
}
|
|
|
|
class _SharedItemsViewState extends State<SharedItemsView>
|
|
with SingleTickerProviderStateMixin {
|
|
GetSharedItemsOverviewResponse? _overview;
|
|
Object? _error;
|
|
|
|
List<(String, Widget)>? _tabs;
|
|
TabController? _tabController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_overview = widget.overview;
|
|
if (_overview == null) {
|
|
_load();
|
|
} else {
|
|
_prepareTabs();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController?.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
setState(() => _error = null);
|
|
try {
|
|
final overview = await SharedItemsView.prefetchOverview(widget.room.token);
|
|
if (!mounted) return;
|
|
_overview = overview;
|
|
_prepareTabs();
|
|
setState(() {});
|
|
} catch (e) {
|
|
if (mounted) setState(() => _error = e);
|
|
}
|
|
}
|
|
|
|
/// Builds the tabs and their controller exactly once. A scrollable [TabBar]
|
|
/// crashes with "setState during build" when an ancestor rebuild swaps the
|
|
/// [TabController] out mid-fling; owning a stable controller here avoids that.
|
|
void _prepareTabs() {
|
|
final overview = _overview;
|
|
if (overview == null) return;
|
|
|
|
final tabs = <(String, Widget)>[];
|
|
|
|
// Media is one server category but two tabs: split the shared stream into
|
|
// Bilder/Videos client-side. Both tabs page the same `media` endpoint.
|
|
// Videos have no server preview here, so they render as a name list.
|
|
final mediaSeed = _seedFromOverview(
|
|
overview.itemsByType['media'] ?? const [],
|
|
);
|
|
if (mediaSeed.items.any((item) => !_isVideoItem(item))) {
|
|
tabs.add((
|
|
'Bilder',
|
|
_SharedItemsTab(
|
|
token: widget.room.token,
|
|
objectType: 'media',
|
|
layout: _SharedItemsLayout.grid,
|
|
emptyLabel: 'Keine Bilder',
|
|
initialPage: mediaSeed,
|
|
itemFilter: (item) => !_isVideoItem(item),
|
|
),
|
|
));
|
|
}
|
|
if (mediaSeed.items.any(_isVideoItem)) {
|
|
tabs.add((
|
|
'Videos',
|
|
_SharedItemsTab(
|
|
token: widget.room.token,
|
|
objectType: 'media',
|
|
layout: _SharedItemsLayout.list,
|
|
emptyLabel: 'Keine Videos',
|
|
initialPage: mediaSeed,
|
|
itemFilter: _isVideoItem,
|
|
),
|
|
));
|
|
}
|
|
|
|
for (final category in _sharedCategories) {
|
|
final raw = overview.itemsByType[category.objectType] ?? const [];
|
|
if (_fileItems(raw).isEmpty) continue;
|
|
tabs.add((
|
|
category.label,
|
|
_SharedItemsTab(
|
|
token: widget.room.token,
|
|
objectType: category.objectType,
|
|
layout: category.layout,
|
|
emptyLabel: 'Keine Einträge',
|
|
initialPage: _seedFromOverview(raw),
|
|
),
|
|
));
|
|
}
|
|
|
|
_tabs = tabs;
|
|
_tabController = tabs.isEmpty
|
|
? null
|
|
: TabController(length: tabs.length, vsync: this);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_overview == null) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Medien und Dokumente')),
|
|
body: _error != null
|
|
? _ErrorState(message: errorToUserMessage(_error), onRetry: _load)
|
|
: const Center(child: AppProgressIndicator.medium()),
|
|
);
|
|
}
|
|
|
|
final tabs = _tabs ?? const [];
|
|
if (tabs.isEmpty) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Medien und Dokumente')),
|
|
body: Center(
|
|
child: Text(
|
|
'Noch nichts geteilt',
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final scrollable = tabs.length > 3;
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Medien und Dokumente'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
isScrollable: scrollable,
|
|
tabAlignment: scrollable ? TabAlignment.start : TabAlignment.fill,
|
|
tabs: [for (final tab in tabs) Tab(text: tab.$1)],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [for (final tab in tabs) tab.$2],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _SharedItemsLayout { grid, list }
|
|
|
|
class _SharedItemsTab extends StatefulWidget {
|
|
final String token;
|
|
final String objectType;
|
|
final _SharedItemsLayout layout;
|
|
final String emptyLabel;
|
|
final SharedItemsPage? initialPage;
|
|
|
|
/// Optional client-side filter. Used to carve the combined `media` stream
|
|
/// into separate "Bilder" and "Videos" tabs; the pagination cursor still
|
|
/// tracks the full (unfiltered) stream.
|
|
final bool Function(GetChatResponseObject)? itemFilter;
|
|
|
|
const _SharedItemsTab({
|
|
required this.token,
|
|
required this.objectType,
|
|
required this.layout,
|
|
required this.emptyLabel,
|
|
this.initialPage,
|
|
this.itemFilter,
|
|
});
|
|
|
|
@override
|
|
State<_SharedItemsTab> createState() => _SharedItemsTabState();
|
|
}
|
|
|
|
class _SharedItemsTabState extends State<_SharedItemsTab>
|
|
with AutomaticKeepAliveClientMixin {
|
|
// When a filtered tab keeps pulling pages that contain none of its kind, stop
|
|
// auto-filling after this many empty pages so a video-sparse chat does not
|
|
// page endlessly; the user can still scroll to fetch more by hand.
|
|
static const int _maxEmptyAutoFills = 5;
|
|
|
|
final ScrollController _scrollController = ScrollController();
|
|
final List<GetChatResponseObject> _items = [];
|
|
|
|
bool _loading = false;
|
|
bool _initialLoaded = false;
|
|
bool _hasMore = true;
|
|
int? _lastKnownMessageId;
|
|
int _emptyAutoFills = 0;
|
|
Object? _error;
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
|
|
List<GetChatResponseObject> _applyFilter(List<GetChatResponseObject> items) =>
|
|
widget.itemFilter == null
|
|
? items
|
|
: items.where(widget.itemFilter!).toList();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(_onScroll);
|
|
final prefetch = widget.initialPage;
|
|
if (prefetch != null) {
|
|
_items.addAll(_applyFilter(prefetch.items));
|
|
_lastKnownMessageId = prefetch.lastKnownMessageId;
|
|
_hasMore = prefetch.hasMore;
|
|
_initialLoaded = true;
|
|
_scheduleFillCheck();
|
|
} else {
|
|
_loadMore();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_scrollController.hasClients) return;
|
|
final position = _scrollController.position;
|
|
if (position.pixels >= position.maxScrollExtent - 400) {
|
|
_loadMore();
|
|
}
|
|
}
|
|
|
|
/// After layout, keep loading while the (possibly filtered) content is too
|
|
/// short to scroll — otherwise scroll-based paging could never kick in.
|
|
void _scheduleFillCheck() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _loading || !_hasMore) return;
|
|
if (_emptyAutoFills >= _maxEmptyAutoFills) return;
|
|
if (!_scrollController.hasClients) return;
|
|
if (_scrollController.position.maxScrollExtent > 0) return;
|
|
_loadMore();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadMore() async {
|
|
if (_loading || !_hasMore) return;
|
|
setState(() {
|
|
_loading = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final response = await GetSharedItems(
|
|
widget.token,
|
|
objectType: widget.objectType,
|
|
limit: _sharedItemsPageSize,
|
|
lastKnownMessageId: _lastKnownMessageId,
|
|
).run();
|
|
if (!mounted) return;
|
|
final page = buildSharedItemsPage(response, _lastKnownMessageId);
|
|
final filtered = _applyFilter(page.items);
|
|
setState(() {
|
|
_items.addAll(filtered);
|
|
_lastKnownMessageId = page.lastKnownMessageId;
|
|
_hasMore = page.hasMore;
|
|
_initialLoaded = true;
|
|
_loading = false;
|
|
_emptyAutoFills = filtered.isEmpty ? _emptyAutoFills + 1 : 0;
|
|
});
|
|
_scheduleFillCheck();
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_error = e;
|
|
_loading = false;
|
|
_initialLoaded = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _retry() {
|
|
setState(() {
|
|
_hasMore = true;
|
|
_error = null;
|
|
});
|
|
return _loadMore();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
|
|
if (!_initialLoaded && _loading) {
|
|
return const Center(child: AppProgressIndicator.medium());
|
|
}
|
|
|
|
if (_items.isEmpty && _error != null) {
|
|
return _ErrorState(message: errorToUserMessage(_error), onRetry: _retry);
|
|
}
|
|
|
|
if (_items.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
widget.emptyLabel,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
);
|
|
}
|
|
|
|
final trailingLoader = _loading && _hasMore
|
|
? const SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: AppProgressIndicator.small()),
|
|
),
|
|
)
|
|
: const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
|
|
// Stop this list's scroll notifications from bubbling into the enclosing
|
|
// TabBarView, which otherwise mis-syncs its indicator and can crash with
|
|
// "setState during build" on a fling. Our own paging uses the controller
|
|
// listener, not notifications, so nothing here depends on them bubbling.
|
|
return NotificationListener<ScrollNotification>(
|
|
onNotification: (_) => true,
|
|
child: CustomScrollView(
|
|
controller: _scrollController,
|
|
slivers: [
|
|
if (widget.layout == _SharedItemsLayout.grid)
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(2),
|
|
sliver: SliverGrid(
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 3,
|
|
mainAxisSpacing: 2,
|
|
crossAxisSpacing: 2,
|
|
),
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) => _SharedItemTile(
|
|
item: _items[index],
|
|
layout: _SharedItemsLayout.grid,
|
|
),
|
|
childCount: _items.length,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) => _SharedItemTile(
|
|
item: _items[index],
|
|
layout: _SharedItemsLayout.list,
|
|
),
|
|
childCount: _items.length,
|
|
),
|
|
),
|
|
trailingLoader,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SharedItemTile extends StatefulWidget {
|
|
final GetChatResponseObject item;
|
|
final _SharedItemsLayout layout;
|
|
|
|
const _SharedItemTile({required this.item, required this.layout});
|
|
|
|
@override
|
|
State<_SharedItemTile> createState() => _SharedItemTileState();
|
|
}
|
|
|
|
class _SharedItemTileState extends State<_SharedItemTile>
|
|
with DownloadTrigger<_SharedItemTile> {
|
|
RichObjectString get _file => widget.item.messageParameters!['file']!;
|
|
|
|
@override
|
|
String? get downloadRemotePath => _file.path;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initDownloadTrigger();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
disposeDownloadTrigger();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onTap() {
|
|
if (guardDemoAction(context)) return;
|
|
if (isDownloading) {
|
|
confirmCancelDownload();
|
|
} else {
|
|
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
|
}
|
|
}
|
|
|
|
String get _previewUrl =>
|
|
'https://${EndpointData().nextcloud().full()}'
|
|
'/index.php/core/preview?fileId=${_file.id}&x=300&y=300&a=1';
|
|
|
|
bool get _isVideo => _isVideoFile(_file.name);
|
|
|
|
bool get _isDownloading => downloadJob?.status.value is DownloadInProgress;
|
|
|
|
double? get _downloadProgress {
|
|
final status = downloadJob?.status.value;
|
|
if (status is! DownloadInProgress) return null;
|
|
return status.percent <= 0 ? null : status.percent / 100;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.layout == _SharedItemsLayout.grid
|
|
? _buildGrid(context)
|
|
: _buildList(context);
|
|
|
|
Widget _buildGrid(BuildContext context) => GestureDetector(
|
|
onTap: _onTap,
|
|
child: ColoredBox(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
CachedNetworkImage(
|
|
imageUrl: _previewUrl,
|
|
httpHeaders: AccountData().authHeaders(),
|
|
fit: BoxFit.cover,
|
|
fadeInDuration: Duration.zero,
|
|
fadeOutDuration: Duration.zero,
|
|
errorListener: (_) {},
|
|
placeholder: (context, url) =>
|
|
const Center(child: AppProgressIndicator.small()),
|
|
// Video thumbnails only exist when the server's preview provider
|
|
// (ffmpeg) generated one; fall back to a film icon rather than a
|
|
// broken-image glyph when it did not.
|
|
errorWidget: (context, url, error) => Center(
|
|
child: Icon(
|
|
_isVideo
|
|
? Icons.movie_outlined
|
|
: Icons.image_not_supported_outlined,
|
|
),
|
|
),
|
|
),
|
|
if (_isVideo)
|
|
const Center(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: Colors.black45,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(6),
|
|
child: Icon(Icons.play_arrow, color: Colors.white, size: 22),
|
|
),
|
|
),
|
|
),
|
|
if (_isDownloading)
|
|
const ColoredBox(
|
|
color: Colors.black38,
|
|
child: Center(
|
|
child: AppProgressIndicator.small(color: Colors.white),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
Widget _buildList(BuildContext context) => ListTile(
|
|
leading: Icon(_iconForFile(_file.name), size: 36),
|
|
title: Text(_file.name, maxLines: 2, overflow: TextOverflow.ellipsis),
|
|
subtitle: Text(
|
|
'${widget.item.actorDisplayName} · '
|
|
'${DateTime.fromMillisecondsSinceEpoch(widget.item.timestamp * 1000).formatDateShort()}',
|
|
),
|
|
trailing: _isDownloading
|
|
? SizedBox(
|
|
width: 24,
|
|
height: 24,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
value: _downloadProgress,
|
|
),
|
|
)
|
|
: null,
|
|
onTap: _onTap,
|
|
);
|
|
}
|
|
|
|
bool _isVideoFile(String name) {
|
|
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
|
const videoExtensions = {
|
|
'mp4',
|
|
'mov',
|
|
'mkv',
|
|
'webm',
|
|
'avi',
|
|
'm4v',
|
|
'3gp',
|
|
'mpeg',
|
|
'mpg',
|
|
'wmv',
|
|
'flv',
|
|
};
|
|
return videoExtensions.contains(ext);
|
|
}
|
|
|
|
IconData _iconForFile(String name) {
|
|
if (_isVideoFile(name)) return Icons.movie_outlined;
|
|
final ext = name.contains('.') ? name.split('.').last.toLowerCase() : '';
|
|
switch (ext) {
|
|
case 'pdf':
|
|
return Icons.picture_as_pdf_outlined;
|
|
case 'doc':
|
|
case 'docx':
|
|
case 'odt':
|
|
case 'rtf':
|
|
case 'txt':
|
|
return Icons.description_outlined;
|
|
case 'xls':
|
|
case 'xlsx':
|
|
case 'ods':
|
|
case 'csv':
|
|
return Icons.table_chart_outlined;
|
|
case 'ppt':
|
|
case 'pptx':
|
|
case 'odp':
|
|
return Icons.slideshow_outlined;
|
|
case 'zip':
|
|
case 'rar':
|
|
case '7z':
|
|
case 'tar':
|
|
case 'gz':
|
|
return Icons.folder_zip_outlined;
|
|
case 'mp3':
|
|
case 'wav':
|
|
case 'm4a':
|
|
case 'ogg':
|
|
return Icons.audiotrack_outlined;
|
|
default:
|
|
return Icons.insert_drive_file_outlined;
|
|
}
|
|
}
|
|
|
|
class _ErrorState extends StatelessWidget {
|
|
final String message;
|
|
final Future<void> Function() onRetry;
|
|
|
|
const _ErrorState({required this.message, required this.onRetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 40),
|
|
const SizedBox(height: 12),
|
|
Text(message, textAlign: TextAlign.center),
|
|
const SizedBox(height: 12),
|
|
FilledButton.tonal(
|
|
onPressed: onRetry,
|
|
child: const Text('Erneut versuchen'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|