added a swipeable media gallery for chat and folder images and videos with server previews, on-demand originals and zoom, and marked document previews in chat so they no longer pass for photos
This commit is contained in:
1 parent
952f916231
commit
aa176c90e3
22 files changed
+1658
-103
No files matched your search
@@ -140,8 +140,12 @@ class _FilesViewState extends State<_FilesView> {
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: files.length,
|
||||
itemBuilder: (context, index) =>
|
||||
FileElement(files[index], widget.path, bloc.refresh),
|
||||
itemBuilder: (context, index) => FileElement(
|
||||
files[index],
|
||||
widget.path,
|
||||
bloc.refresh,
|
||||
siblings: files,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -17,6 +17,8 @@ import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/demo_restricted.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/downloads/download_trigger.dart';
|
||||
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||
import '../../../../widget/media_gallery/gallery_source.dart';
|
||||
import '../../../../widget/prompt_dialog.dart';
|
||||
import '../../talk/widgets/highlighted_linkify.dart';
|
||||
import '../data/webdav_path.dart';
|
||||
@@ -33,11 +35,16 @@ class FileElement extends StatefulWidget {
|
||||
/// highlighted in the tile title. Used by the Files search delegate.
|
||||
final String? highlight;
|
||||
|
||||
/// The listing [file] belongs to, in display order; the media gallery pages
|
||||
/// through its images and videos. Null opens the gallery on [file] alone.
|
||||
final List<CacheableFile>? siblings;
|
||||
|
||||
const FileElement(
|
||||
this.file,
|
||||
this.path,
|
||||
this.refetch, {
|
||||
this.highlight,
|
||||
this.siblings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -100,10 +107,7 @@ class _FileElementState extends State<FileElement>
|
||||
|
||||
void _onTap() {
|
||||
if (widget.file.isDirectory) {
|
||||
AppRoutes.openFolder(
|
||||
context,
|
||||
[...widget.path, widget.file.name],
|
||||
);
|
||||
AppRoutes.openFolder(context, [...widget.path, widget.file.name]);
|
||||
return;
|
||||
}
|
||||
if (guardDemoAction(context)) return;
|
||||
@@ -111,12 +115,45 @@ class _FileElementState extends State<FileElement>
|
||||
confirmCancelDownload();
|
||||
return;
|
||||
}
|
||||
if (galleryMediaKindOf(widget.file.name, mimeType: widget.file.mimeType) !=
|
||||
null) {
|
||||
_openGallery();
|
||||
return;
|
||||
}
|
||||
startDownload(
|
||||
name: widget.file.name,
|
||||
remoteFile: RemoteFileRef.fromCacheable(widget.file),
|
||||
);
|
||||
}
|
||||
|
||||
void _openGallery() {
|
||||
final items = [
|
||||
for (final file in widget.siblings ?? [widget.file])
|
||||
if (!file.isDirectory) ?_galleryItem(file),
|
||||
];
|
||||
AppRoutes.openMediaGallery(
|
||||
context,
|
||||
source: StaticGallerySource(items),
|
||||
initialKey: widget.file.path,
|
||||
);
|
||||
}
|
||||
|
||||
static GalleryItem? _galleryItem(CacheableFile file) {
|
||||
final kind = galleryMediaKindOf(file.name, mimeType: file.mimeType);
|
||||
if (kind == null) return null;
|
||||
final size = file.size;
|
||||
final modified = file.modifiedAt;
|
||||
return GalleryItem(
|
||||
file: RemoteFileRef.fromCacheable(file),
|
||||
kind: kind,
|
||||
eTag: file.eTag,
|
||||
subtitle: [
|
||||
if (size != null) filesize(size),
|
||||
if (modified != null) modified.formatDateTime(),
|
||||
].join(' · '),
|
||||
);
|
||||
}
|
||||
|
||||
void _rename() {
|
||||
if (guardDemoAction(context)) return;
|
||||
showPromptDialog(
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../utils/emoji_detection.dart';
|
||||
import '../../../../utils/url_opener.dart';
|
||||
import '../widgets/chat_file_attachment.dart';
|
||||
import '../widgets/highlighted_linkify.dart';
|
||||
import '../widgets/message_markdown.dart';
|
||||
|
||||
@@ -78,36 +76,7 @@ class ChatMessage {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
errorWidget: (context, url, error) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.file_open_outlined, size: 35),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
file.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
placeholder: (context, url) => const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: SizedBox(width: 50, child: LinearProgressIndicator()),
|
||||
),
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorListener: (value) {},
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
imageUrl:
|
||||
'${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
||||
),
|
||||
ChatFileAttachment(file: file),
|
||||
if (originalMessage != '{file}') ...[
|
||||
SizedBox(height: 5),
|
||||
contentWidget,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||
import '../../../../widget/media_gallery/gallery_source.dart';
|
||||
import '../details/shared_items_view.dart';
|
||||
|
||||
GalleryItem? galleryItemFromTalk(GetChatResponseObject message) {
|
||||
final file = message.messageParameters?['file'];
|
||||
if (file == null || file.path == null) return null;
|
||||
final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype);
|
||||
if (kind == null) return null;
|
||||
return GalleryItem(
|
||||
file: RemoteFileRef.fromTalk(file),
|
||||
kind: kind,
|
||||
messageId: message.id,
|
||||
subtitle:
|
||||
'${message.actorDisplayName} · ${message.sentAt.formatDateTime()}',
|
||||
);
|
||||
}
|
||||
|
||||
/// All images and videos of a chat, via Talk's shared-items `media` stream.
|
||||
/// Opens on the tapped message and fills in around it in the background.
|
||||
class TalkGallerySource extends GallerySource {
|
||||
/// The share endpoint only pages backwards from the newest item, so reaching
|
||||
/// an old tapped image means walking down to it; give up beyond this.
|
||||
static const _maxCatchUpPages = 25;
|
||||
|
||||
final String token;
|
||||
final GalleryItem initial;
|
||||
final int _epoch = SessionManager().sessionEpoch;
|
||||
|
||||
List<GalleryItem> _items;
|
||||
int? _cursor;
|
||||
bool _hasOlder = false;
|
||||
bool _loading = false;
|
||||
bool _disposed = false;
|
||||
|
||||
TalkGallerySource({required this.token, required this.initial})
|
||||
: _items = [initial] {
|
||||
_catchUp();
|
||||
}
|
||||
|
||||
@override
|
||||
List<GalleryItem> get items => _items;
|
||||
|
||||
@override
|
||||
bool get hasOlder => _hasOlder && !_loading;
|
||||
|
||||
bool get _stale => _disposed || !SessionManager().isCurrentSession(_epoch);
|
||||
|
||||
Future<SharedItemsPage> _fetch(int? cursor) async => buildSharedItemsPage(
|
||||
await GetSharedItems(
|
||||
token,
|
||||
objectType: 'media',
|
||||
limit: sharedItemsPageSize,
|
||||
lastKnownMessageId: cursor,
|
||||
).run(),
|
||||
cursor,
|
||||
);
|
||||
|
||||
Iterable<GalleryItem> _toItems(SharedItemsPage page) =>
|
||||
page.items.map(galleryItemFromTalk).nonNulls;
|
||||
|
||||
Future<void> _catchUp() async {
|
||||
final initialId = initial.messageId!;
|
||||
_loading = true;
|
||||
try {
|
||||
final collected = <GalleryItem>[];
|
||||
int? cursor;
|
||||
var hasMore = true;
|
||||
var reached = false;
|
||||
for (var pages = 0; hasMore && pages < _maxCatchUpPages; pages++) {
|
||||
final page = await _fetch(cursor);
|
||||
if (_stale) return;
|
||||
collected.addAll(_toItems(page));
|
||||
cursor = page.lastKnownMessageId;
|
||||
hasMore = page.hasMore;
|
||||
if (page.items.any((item) => item.id <= initialId)) {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (reached || !hasMore) {
|
||||
_items = mergeGalleryItems(_items, collected);
|
||||
_cursor = cursor;
|
||||
_hasOlder = hasMore;
|
||||
} else {
|
||||
// Too far down: skip the newer media and only page older ones.
|
||||
_cursor = initialId;
|
||||
_hasOlder = true;
|
||||
}
|
||||
} on Object {
|
||||
if (_stale) return;
|
||||
_cursor = initialId;
|
||||
_hasOlder = true;
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadOlder() async {
|
||||
if (!hasOlder) return;
|
||||
_loading = true;
|
||||
try {
|
||||
final page = await _fetch(_cursor);
|
||||
if (_stale) return;
|
||||
_items = mergeGalleryItems(_items, _toItems(page));
|
||||
_cursor = page.lastKnownMessageId;
|
||||
_hasOlder = page.hasMore;
|
||||
} on Object {
|
||||
// Retried on the next page change.
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
if (!_stale) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,16 @@ import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_res
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../session/session_manager.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';
|
||||
import '../data/talk_gallery_source.dart';
|
||||
|
||||
const int _sharedItemsPageSize = 20;
|
||||
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
|
||||
@@ -64,7 +66,7 @@ SharedItemsPage buildSharedItemsPage(
|
||||
) {
|
||||
final lastGiven = response.lastGivenMessageId;
|
||||
final hasMore =
|
||||
response.items.length >= _sharedItemsPageSize &&
|
||||
response.items.length >= sharedItemsPageSize &&
|
||||
lastGiven != null &&
|
||||
lastGiven != previousMessageId;
|
||||
return SharedItemsPage(
|
||||
@@ -79,7 +81,7 @@ SharedItemsPage buildSharedItemsPage(
|
||||
/// 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 hasMore = rawItems.length >= sharedItemsPageSize;
|
||||
final oldestId = rawItems.isEmpty
|
||||
? null
|
||||
: rawItems.map((item) => item.id).reduce((a, b) => a < b ? a : b);
|
||||
@@ -106,7 +108,7 @@ class SharedItemsView extends StatefulWidget {
|
||||
/// screen, so opening this view needs no round-trip.
|
||||
static Future<GetSharedItemsOverviewResponse> prefetchOverview(
|
||||
String token,
|
||||
) => GetSharedItemsOverview(token, limit: _sharedItemsPageSize).run();
|
||||
) => GetSharedItemsOverview(token, limit: sharedItemsPageSize).run();
|
||||
|
||||
@override
|
||||
State<SharedItemsView> createState() => _SharedItemsViewState();
|
||||
@@ -362,7 +364,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
||||
final response = await GetSharedItems(
|
||||
widget.token,
|
||||
objectType: widget.objectType,
|
||||
limit: _sharedItemsPageSize,
|
||||
limit: sharedItemsPageSize,
|
||||
lastKnownMessageId: _lastKnownMessageId,
|
||||
).run();
|
||||
if (!mounted) return;
|
||||
@@ -445,6 +447,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _SharedItemTile(
|
||||
token: widget.token,
|
||||
item: _items[index],
|
||||
layout: _SharedItemsLayout.grid,
|
||||
),
|
||||
@@ -456,6 +459,7 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _SharedItemTile(
|
||||
token: widget.token,
|
||||
item: _items[index],
|
||||
layout: _SharedItemsLayout.list,
|
||||
),
|
||||
@@ -470,10 +474,15 @@ class _SharedItemsTabState extends State<_SharedItemsTab>
|
||||
}
|
||||
|
||||
class _SharedItemTile extends StatefulWidget {
|
||||
final String token;
|
||||
final GetChatResponseObject item;
|
||||
final _SharedItemsLayout layout;
|
||||
|
||||
const _SharedItemTile({required this.item, required this.layout});
|
||||
const _SharedItemTile({
|
||||
required this.token,
|
||||
required this.item,
|
||||
required this.layout,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_SharedItemTile> createState() => _SharedItemTileState();
|
||||
@@ -502,12 +511,18 @@ class _SharedItemTileState extends State<_SharedItemTile>
|
||||
if (guardDemoAction(context)) return;
|
||||
if (isDownloading) {
|
||||
confirmCancelDownload();
|
||||
} else {
|
||||
startDownload(
|
||||
name: _file.name,
|
||||
remoteFile: RemoteFileRef.fromTalk(_file),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final galleryItem = galleryItemFromTalk(widget.item);
|
||||
if (galleryItem != null) {
|
||||
AppRoutes.openMediaGallery(
|
||||
context,
|
||||
source: TalkGallerySource(token: widget.token, initial: galleryItem),
|
||||
initialKey: galleryItem.key,
|
||||
);
|
||||
return;
|
||||
}
|
||||
startDownload(name: _file.name, remoteFile: RemoteFileRef.fromTalk(_file));
|
||||
}
|
||||
|
||||
String get _previewUrl =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../share_intent/remote_file_ref.dart';
|
||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../utils/downloads/download_job.dart';
|
||||
@@ -14,6 +15,7 @@ import '../../../../widget/demo_restricted.dart';
|
||||
import '../../../../widget/downloads/download_trigger.dart';
|
||||
import '../data/chat_bubble_styles.dart';
|
||||
import '../data/chat_message.dart';
|
||||
import '../data/talk_gallery_source.dart';
|
||||
import 'answer_reference.dart';
|
||||
import 'bubble.dart';
|
||||
import 'chat_bubble_poll.dart';
|
||||
@@ -192,9 +194,21 @@ class _ChatBubbleState extends State<ChatBubble>
|
||||
if (guardDemoAction(context)) return;
|
||||
if (isDownloading) {
|
||||
confirmCancelDownload();
|
||||
} else {
|
||||
_startFileDownload();
|
||||
return;
|
||||
}
|
||||
final galleryItem = galleryItemFromTalk(widget.bubbleData);
|
||||
if (galleryItem != null) {
|
||||
AppRoutes.openMediaGallery(
|
||||
context,
|
||||
source: TalkGallerySource(
|
||||
token: widget.chatData.token,
|
||||
initial: galleryItem,
|
||||
),
|
||||
initialKey: galleryItem.key,
|
||||
);
|
||||
return;
|
||||
}
|
||||
_startFileDownload();
|
||||
}
|
||||
|
||||
void _updateMessage(BuildContext context) {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../model/endpoint_data.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
import '../../../../widget/media_gallery/gallery_item.dart';
|
||||
import '../../files/data/file_type_icon.dart';
|
||||
|
||||
/// File attachment in a chat bubble. Only what the media gallery pages through
|
||||
/// renders as a plain picture; other previews (documents, audio covers) carry
|
||||
/// a name strip so they don't pass for photos.
|
||||
class ChatFileAttachment extends StatelessWidget {
|
||||
final RichObjectString file;
|
||||
|
||||
const ChatFileAttachment({required this.file, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final kind = galleryMediaKindOf(file.name, mimeType: file.mimetype);
|
||||
return CachedNetworkImage(
|
||||
imageUrl:
|
||||
'${EndpointData().nextcloud().origin()}/index.php/core/preview?fileId=${file.id}&x=130&y=-1&a=1',
|
||||
httpHeaders: SessionManager().requireNextcloud().authHeaders,
|
||||
alignment: Alignment.center,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
errorListener: (_) {},
|
||||
placeholder: (context, url) => const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: SizedBox(width: 50, child: LinearProgressIndicator()),
|
||||
),
|
||||
imageBuilder: switch (kind) {
|
||||
GalleryMediaKind.image => null,
|
||||
GalleryMediaKind.video => (context, image) => Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Image(image: image),
|
||||
const _PlayBadge(),
|
||||
],
|
||||
),
|
||||
null => (context, image) => Stack(
|
||||
children: [
|
||||
Image(image: image),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _NameStrip(name: file.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
errorWidget: (context, url, error) => kind == GalleryMediaKind.video
|
||||
? const _VideoPlaceholder()
|
||||
: _FileRow(name: file.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayBadge extends StatelessWidget {
|
||||
const _PlayBadge();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.black54, shape: BoxShape.circle),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(Icons.play_arrow, color: Colors.white, size: 28),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Same footprint as a typical preview, so videos without a server thumbnail
|
||||
/// still read as media.
|
||||
class _VideoPlaceholder extends StatelessWidget {
|
||||
const _VideoPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 130,
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black87,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Icon(Icons.movie_outlined, color: Colors.white38, size: 56),
|
||||
_PlayBadge(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _NameStrip extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
const _NameStrip({required this.name});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconForFileName(name), size: 14, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _FileRow extends StatelessWidget {
|
||||
final String name;
|
||||
|
||||
const _FileRow({required this.name});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.file_open_outlined, size: 35),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user