1312 lines
43 KiB
Dart
1312 lines
43 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:gap/gap.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:island/widgets/alert.dart';
|
|
import 'package:island/widgets/content/sheet.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:island/screens/posts/compose.dart';
|
|
import 'package:island/models/file.dart';
|
|
import 'package:island/pods/link_preview.dart';
|
|
import 'package:island/pods/network.dart';
|
|
import 'package:island/pods/config.dart';
|
|
import 'package:island/services/file.dart';
|
|
import 'package:mime/mime.dart';
|
|
|
|
import 'dart:io';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:island/models/chat.dart';
|
|
import 'package:island/screens/chat/chat.dart';
|
|
import 'package:island/widgets/content/cloud_files.dart';
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:styled_widget/styled_widget.dart';
|
|
|
|
enum ShareContentType { text, link, file }
|
|
|
|
class ShareContent {
|
|
final ShareContentType type;
|
|
final String? text;
|
|
final String? link;
|
|
final List<XFile>? files;
|
|
|
|
const ShareContent({required this.type, this.text, this.link, this.files});
|
|
|
|
ShareContent.text(String this.text)
|
|
: type = ShareContentType.text,
|
|
link = null,
|
|
files = null;
|
|
|
|
ShareContent.link(String this.link)
|
|
: type = ShareContentType.link,
|
|
text = null,
|
|
files = null;
|
|
|
|
ShareContent.files(List<XFile> this.files)
|
|
: type = ShareContentType.file,
|
|
text = null,
|
|
link = null;
|
|
|
|
String get displayText {
|
|
switch (type) {
|
|
case ShareContentType.text:
|
|
return text ?? '';
|
|
case ShareContentType.link:
|
|
return link ?? '';
|
|
case ShareContentType.file:
|
|
return files?.map((f) => f.name).join(', ') ?? '';
|
|
}
|
|
}
|
|
}
|
|
|
|
class ShareSheet extends ConsumerStatefulWidget {
|
|
final ShareContent content;
|
|
final String? title;
|
|
final bool toSystem;
|
|
final VoidCallback? onClose;
|
|
|
|
const ShareSheet({
|
|
super.key,
|
|
required this.content,
|
|
this.title,
|
|
this.toSystem = false,
|
|
this.onClose,
|
|
});
|
|
|
|
// Convenience constructors
|
|
ShareSheet.text({
|
|
Key? key,
|
|
required String text,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) : this(
|
|
key: key,
|
|
content: ShareContent.text(text),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
|
|
ShareSheet.link({
|
|
Key? key,
|
|
required String link,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) : this(
|
|
key: key,
|
|
content: ShareContent.link(link),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
|
|
ShareSheet.files({
|
|
Key? key,
|
|
required List<XFile> files,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) : this(
|
|
key: key,
|
|
content: ShareContent.files(files),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
|
|
@override
|
|
ConsumerState<ShareSheet> createState() => _ShareSheetState();
|
|
}
|
|
|
|
class _ShareSheetState extends ConsumerState<ShareSheet> {
|
|
bool _isLoading = false;
|
|
final TextEditingController _messageController = TextEditingController();
|
|
final Map<String, List<double>> _fileUploadProgress = {};
|
|
|
|
@override
|
|
void dispose() {
|
|
_messageController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _shareToPost() async {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
// Convert ShareContent to PostComposeInitialState
|
|
String content = '';
|
|
List<UniversalFile> attachments = [];
|
|
|
|
switch (widget.content.type) {
|
|
case ShareContentType.text:
|
|
content = widget.content.text ?? '';
|
|
break;
|
|
case ShareContentType.link:
|
|
content = widget.content.link ?? '';
|
|
break;
|
|
case ShareContentType.file:
|
|
if (widget.content.files != null) {
|
|
// Convert XFiles to UniversalFiles
|
|
for (final file in widget.content.files!) {
|
|
var mimeType = file.mimeType;
|
|
mimeType ??= lookupMimeType(file.path);
|
|
|
|
UniversalFileType fileType;
|
|
if (mimeType?.startsWith('image/') == true) {
|
|
fileType = UniversalFileType.image;
|
|
} else if (mimeType?.startsWith('video/') == true) {
|
|
fileType = UniversalFileType.video;
|
|
} else if (mimeType?.startsWith('audio/') == true) {
|
|
fileType = UniversalFileType.audio;
|
|
} else {
|
|
fileType = UniversalFileType.file;
|
|
}
|
|
|
|
attachments.add(UniversalFile(data: file, type: fileType));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
final initialState = PostComposeInitialState(
|
|
title: widget.title,
|
|
content: content,
|
|
attachments: attachments,
|
|
);
|
|
|
|
// Navigate to compose screen
|
|
if (mounted) {
|
|
context.push('/posts/compose', extra: initialState);
|
|
Navigator.of(context).pop(); // Close the share sheet
|
|
}
|
|
} catch (e) {
|
|
showErrorAlert(e);
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _shareToSpecificChat(SnChatRoom chatRoom) async {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
final apiClient = ref.read(apiClientProvider);
|
|
final serverUrl = ref.read(serverUrlProvider);
|
|
|
|
String content = _messageController.text.trim();
|
|
List<String> attachmentIds = [];
|
|
|
|
// Handle different content types
|
|
switch (widget.content.type) {
|
|
case ShareContentType.text:
|
|
if (content.isEmpty) {
|
|
content = widget.content.text ?? '';
|
|
} else if (widget.content.text?.isNotEmpty == true) {
|
|
content = '$content\n\n${widget.content.text}';
|
|
}
|
|
break;
|
|
case ShareContentType.link:
|
|
if (content.isEmpty) {
|
|
content = widget.content.link ?? '';
|
|
} else if (widget.content.link?.isNotEmpty == true) {
|
|
content = '$content\n\n${widget.content.link}';
|
|
}
|
|
break;
|
|
case ShareContentType.file:
|
|
// Upload files to cloud storage
|
|
if (widget.content.files?.isNotEmpty == true) {
|
|
final token = ref.watch(tokenProvider)?.token;
|
|
if (token == null) {
|
|
throw Exception('Authentication required');
|
|
}
|
|
|
|
final universalFiles =
|
|
widget.content.files!.map((file) {
|
|
UniversalFileType fileType;
|
|
if (file.mimeType?.startsWith('image/') == true) {
|
|
fileType = UniversalFileType.image;
|
|
} else if (file.mimeType?.startsWith('video/') == true) {
|
|
fileType = UniversalFileType.video;
|
|
} else if (file.mimeType?.startsWith('audio/') == true) {
|
|
fileType = UniversalFileType.audio;
|
|
} else {
|
|
fileType = UniversalFileType.file;
|
|
}
|
|
return UniversalFile(data: file, type: fileType);
|
|
}).toList();
|
|
|
|
// Initialize progress tracking
|
|
final messageId = DateTime.now().millisecondsSinceEpoch.toString();
|
|
_fileUploadProgress[messageId] = List.filled(
|
|
universalFiles.length,
|
|
0.0,
|
|
);
|
|
|
|
// Upload each file
|
|
for (var idx = 0; idx < universalFiles.length; idx++) {
|
|
final file = universalFiles[idx];
|
|
final cloudFile =
|
|
await putMediaToCloud(
|
|
fileData: file,
|
|
atk: token,
|
|
baseUrl: serverUrl,
|
|
filename: file.data.name ?? 'Shared file',
|
|
mimetype:
|
|
file.data.mimeType ??
|
|
switch (file.type) {
|
|
UniversalFileType.image => 'image/unknown',
|
|
UniversalFileType.video => 'video/unknown',
|
|
UniversalFileType.audio => 'audio/unknown',
|
|
UniversalFileType.file => 'application/octet-stream',
|
|
},
|
|
onProgress: (progress, _) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_fileUploadProgress[messageId]?[idx] = progress;
|
|
});
|
|
}
|
|
},
|
|
).future;
|
|
|
|
if (cloudFile == null) {
|
|
throw Exception('Failed to upload file: ${file.data.name}');
|
|
}
|
|
attachmentIds.add(cloudFile.id);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (content.isEmpty && attachmentIds.isEmpty) {
|
|
throw Exception('No content to share');
|
|
}
|
|
|
|
// Send message to chat room
|
|
await apiClient.post(
|
|
'/chat/${chatRoom.id}/messages',
|
|
data: {'content': content, 'attachments_id': attachmentIds, 'meta': {}},
|
|
);
|
|
|
|
if (mounted) {
|
|
// Show success message
|
|
showSnackBar(
|
|
'shareToSpecificChatSuccess'.tr(
|
|
args: [chatRoom.name ?? 'directChat'.tr()],
|
|
),
|
|
);
|
|
|
|
// Show navigation prompt
|
|
final shouldNavigate = await showDialog<bool>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
title: Text('shareSuccess'.tr()),
|
|
content: Text('wouldYouLikeToGoToChat'.tr()),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text('no'.tr()),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text('yes'.tr()),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// Close the share sheet
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
// Navigate to chat if requested
|
|
if (shouldNavigate == true && mounted) {
|
|
context.push('/chat/${chatRoom.id}');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Failed to share to chat: $e'),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _shareToSystem() async {
|
|
if (!widget.toSystem) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
switch (widget.content.type) {
|
|
case ShareContentType.text:
|
|
if (widget.content.text?.isNotEmpty == true) {
|
|
await Share.share(widget.content.text!);
|
|
}
|
|
break;
|
|
case ShareContentType.link:
|
|
if (widget.content.link?.isNotEmpty == true) {
|
|
await Share.share(widget.content.link!);
|
|
}
|
|
break;
|
|
case ShareContentType.file:
|
|
if (widget.content.files?.isNotEmpty == true) {
|
|
await Share.shareXFiles(widget.content.files!);
|
|
}
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
showErrorAlert(e);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _copyToClipboard() async {
|
|
try {
|
|
String textToCopy = '';
|
|
switch (widget.content.type) {
|
|
case ShareContentType.text:
|
|
textToCopy = widget.content.text ?? '';
|
|
break;
|
|
case ShareContentType.link:
|
|
textToCopy = widget.content.link ?? '';
|
|
break;
|
|
case ShareContentType.file:
|
|
textToCopy =
|
|
widget.content.files?.map((f) => f.name).join('\n') ?? '';
|
|
break;
|
|
}
|
|
|
|
await Clipboard.setData(ClipboardData(text: textToCopy));
|
|
if (mounted) showSnackBar('copyToClipboard'.tr());
|
|
} catch (e) {
|
|
showErrorAlert(e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SheetScaffold(
|
|
titleText: widget.title ?? 'share'.tr(),
|
|
heightFactor: 0.75,
|
|
child: Column(
|
|
children: [
|
|
// Share options with keyboard avoidance
|
|
Expanded(
|
|
child: AnimatedPadding(
|
|
duration: const Duration(milliseconds: 300),
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Content preview
|
|
Container(
|
|
margin: const EdgeInsets.all(16),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'contentToShare'.tr(),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_ContentPreview(content: widget.content),
|
|
],
|
|
),
|
|
),
|
|
// Quick actions row (horizontally scrollable)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'quickActions'.tr(),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
height: 80,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
children: [
|
|
_CompactShareOption(
|
|
icon: Symbols.post_add,
|
|
title: 'post'.tr(),
|
|
onTap: _isLoading ? null : _shareToPost,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_CompactShareOption(
|
|
icon: Symbols.content_copy,
|
|
title: 'copy'.tr(),
|
|
onTap: _isLoading ? null : _copyToClipboard,
|
|
),
|
|
if (widget.toSystem) ...<Widget>[
|
|
const SizedBox(width: 12),
|
|
_CompactShareOption(
|
|
icon: Symbols.share,
|
|
title: 'share'.tr(),
|
|
onTap: _isLoading ? null : _shareToSystem,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Chat section
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'sendToChat'.tr(),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleSmall?.copyWith(
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Additional message input
|
|
Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
child: TextField(
|
|
controller: _messageController,
|
|
decoration: InputDecoration(
|
|
hintText: 'addAdditionalMessage'.tr(),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
onTapOutside:
|
|
(_) =>
|
|
FocusManager.instance.primaryFocus
|
|
?.unfocus(),
|
|
maxLines: 3,
|
|
minLines: 1,
|
|
enabled: !_isLoading,
|
|
),
|
|
),
|
|
|
|
_ChatRoomsList(
|
|
onChatSelected:
|
|
_isLoading ? null : _shareToSpecificChat,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Loading indicator and file upload progress
|
|
if (_isLoading)
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 8),
|
|
if (_fileUploadProgress.isNotEmpty)
|
|
..._fileUploadProgress.entries.map((entry) {
|
|
final progress = entry.value;
|
|
final averageProgress =
|
|
progress.isEmpty
|
|
? 0.0
|
|
: progress.reduce((a, b) => a + b) /
|
|
progress.length;
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
'uploadingFiles'.tr(),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 4),
|
|
LinearProgressIndicator(value: averageProgress),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${(averageProgress * 100).toInt()}%',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatRoomsList extends ConsumerWidget {
|
|
final Function(SnChatRoom)? onChatSelected;
|
|
|
|
const _ChatRoomsList({this.onChatSelected});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final chatRooms = ref.watch(chatroomsJoinedProvider);
|
|
|
|
return chatRooms.when(
|
|
data: (rooms) {
|
|
if (rooms.isEmpty) {
|
|
return Container(
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'noChatRoomsAvailable'.tr(),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
height: 80,
|
|
child: ListView.separated(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: rooms.length,
|
|
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
|
itemBuilder: (context, index) {
|
|
final room = rooms[index];
|
|
return _ChatRoomOption(
|
|
room: room,
|
|
onTap:
|
|
onChatSelected != null ? () => onChatSelected!(room) : null,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
loading:
|
|
() => SizedBox(
|
|
height: 80,
|
|
child: Center(
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
error:
|
|
(error, stack) => Container(
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.errorContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'failedToLoadChats'.tr(),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChatRoomOption extends StatelessWidget {
|
|
final SnChatRoom room;
|
|
final VoidCallback? onTap;
|
|
|
|
const _ChatRoomOption({required this.room, this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDirect = room.type == 1; // Assuming type 1 is direct chat
|
|
final displayName =
|
|
room.name ??
|
|
(isDirect && room.members != null
|
|
? room.members!.map((m) => m.account.nick).join(', ')
|
|
: 'unknownChat'.tr());
|
|
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 72,
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
onTap != null
|
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
|
: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
// Chat room avatar
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child:
|
|
room.picture != null
|
|
? ClipRRect(
|
|
borderRadius: BorderRadius.circular(16),
|
|
child: CloudFileWidget(
|
|
item: room.picture!,
|
|
fit: BoxFit.cover,
|
|
),
|
|
)
|
|
: Icon(
|
|
isDirect ? Symbols.person : Symbols.group,
|
|
size: 20,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
// Chat room name
|
|
Text(
|
|
displayName,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color:
|
|
onTap != null
|
|
? Theme.of(context).colorScheme.onSurface
|
|
: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CompactShareOption extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final VoidCallback? onTap;
|
|
|
|
const _CompactShareOption({
|
|
required this.icon,
|
|
required this.title,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
width: 72,
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color:
|
|
onTap != null
|
|
? Theme.of(context).colorScheme.surfaceContainerHighest
|
|
: Theme.of(
|
|
context,
|
|
).colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 24,
|
|
color:
|
|
onTap != null
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color:
|
|
onTap != null
|
|
? Theme.of(context).colorScheme.onSurface
|
|
: Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant.withOpacity(0.5),
|
|
),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ContentPreview extends StatelessWidget {
|
|
final ShareContent content;
|
|
|
|
const _ContentPreview({required this.content});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
switch (content.type) {
|
|
case ShareContentType.text:
|
|
return _TextPreview(text: content.text ?? '');
|
|
case ShareContentType.link:
|
|
return _LinkPreview(link: content.link ?? '');
|
|
case ShareContentType.file:
|
|
return _FilePreview(files: content.files ?? []);
|
|
}
|
|
}
|
|
}
|
|
|
|
const double kPreviewMaxHeight = 80;
|
|
|
|
class _TextPreview extends StatelessWidget {
|
|
final String text;
|
|
|
|
const _TextPreview({required this.text});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight),
|
|
child: Text(text, style: Theme.of(context).textTheme.bodyMedium),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LinkPreview extends ConsumerWidget {
|
|
final String link;
|
|
|
|
const _LinkPreview({required this.link});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final linkPreviewAsync = ref.watch(linkPreviewProvider(link));
|
|
|
|
return linkPreviewAsync.when(
|
|
loading:
|
|
() => Container(
|
|
constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight),
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Loading link preview...',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
error: (error, stackTrace) => _buildFallbackPreview(context),
|
|
data: (embed) {
|
|
if (embed == null) {
|
|
return _buildFallbackPreview(context);
|
|
}
|
|
|
|
return Container(
|
|
constraints: const BoxConstraints(
|
|
maxHeight: 120,
|
|
), // Increased height for rich preview
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Favicon and image
|
|
if (embed.imageUrl != null || embed.faviconUrl.isNotEmpty)
|
|
Container(
|
|
width: 60,
|
|
height: 60,
|
|
margin: const EdgeInsets.only(right: 12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(8),
|
|
color:
|
|
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child:
|
|
embed.imageUrl != null
|
|
? Image.network(
|
|
embed.imageUrl!,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return _buildFaviconFallback(
|
|
context,
|
|
embed.faviconUrl,
|
|
);
|
|
},
|
|
)
|
|
: _buildFaviconFallback(context, embed.faviconUrl),
|
|
),
|
|
),
|
|
// Content
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Site name
|
|
if (embed.siteName.isNotEmpty)
|
|
Text(
|
|
embed.siteName,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
// Title
|
|
Text(
|
|
embed.title,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
// Description
|
|
if (embed.description != null &&
|
|
embed.description!.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
embed.description!,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
// URL
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
embed.url,
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildFallbackPreview(BuildContext context) {
|
|
return Container(
|
|
constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Symbols.link,
|
|
size: 16,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Link',
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
const Gap(6),
|
|
Text(
|
|
'Link embed was not loaded.',
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
).opacity(0.75),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: SelectableText(
|
|
link,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFaviconFallback(BuildContext context, String faviconUrl) {
|
|
if (faviconUrl.isNotEmpty) {
|
|
return Image.network(
|
|
faviconUrl,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Icon(
|
|
Symbols.link,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 24,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
return Icon(
|
|
Symbols.link,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
size: 24,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FilePreview extends StatelessWidget {
|
|
final List<XFile> files;
|
|
|
|
const _FilePreview({required this.files});
|
|
|
|
bool _isImageFile(String fileName) {
|
|
final ext = path.extension(fileName).toLowerCase();
|
|
return ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].contains(ext);
|
|
}
|
|
|
|
bool _isVideoFile(String fileName) {
|
|
final ext = path.extension(fileName).toLowerCase();
|
|
return ['.mp4', '.mov', '.avi', '.mkv', '.webm'].contains(ext);
|
|
}
|
|
|
|
IconData _getFileIcon(String fileName) {
|
|
final ext = path.extension(fileName).toLowerCase();
|
|
|
|
if (_isImageFile(fileName)) return Symbols.image;
|
|
if (_isVideoFile(fileName)) return Symbols.video_file;
|
|
|
|
switch (ext) {
|
|
case '.pdf':
|
|
return Symbols.picture_as_pdf;
|
|
case '.doc':
|
|
case '.docx':
|
|
return Symbols.description;
|
|
case '.xls':
|
|
case '.xlsx':
|
|
return Symbols.table_chart;
|
|
case '.ppt':
|
|
case '.pptx':
|
|
return Symbols.slideshow;
|
|
case '.zip':
|
|
case '.rar':
|
|
case '.7z':
|
|
return Symbols.folder_zip;
|
|
case '.txt':
|
|
return Symbols.text_snippet;
|
|
default:
|
|
return Symbols.attach_file;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
constraints: const BoxConstraints(maxHeight: kPreviewMaxHeight),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Symbols.attach_file,
|
|
size: 16,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${files.length} file${files.length > 1 ? 's' : ''}',
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: files.length,
|
|
itemBuilder: (context, index) {
|
|
final file = files[index];
|
|
final isImage = _isImageFile(file.name);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.outline.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
if (isImage)
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Image.file(
|
|
File(file.path),
|
|
width: 40,
|
|
height: 40,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (context, error, stackTrace) {
|
|
return Container(
|
|
width: 40,
|
|
height: 40,
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.surfaceVariant,
|
|
child: Icon(
|
|
Symbols.broken_image,
|
|
size: 20,
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
)
|
|
else
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color:
|
|
Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Icon(
|
|
_getFileIcon(file.name),
|
|
size: 20,
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
file.name,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
FutureBuilder<int>(
|
|
future: file.length(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasData) {
|
|
final size = snapshot.data!;
|
|
final sizeStr =
|
|
size < 1024
|
|
? '${size}B'
|
|
: size < 1024 * 1024
|
|
? '${(size / 1024).toStringAsFixed(1)}KB'
|
|
: '${(size / (1024 * 1024)).toStringAsFixed(1)}MB';
|
|
return Text(
|
|
sizeStr,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(
|
|
color:
|
|
Theme.of(
|
|
context,
|
|
).colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Helper functions to show the share sheet
|
|
void showShareSheet({
|
|
required BuildContext context,
|
|
required ShareContent content,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useRootNavigator: true,
|
|
builder:
|
|
(context) => ShareSheet(
|
|
content: content,
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
),
|
|
);
|
|
}
|
|
|
|
void showShareSheetText({
|
|
required BuildContext context,
|
|
required String text,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) {
|
|
showShareSheet(
|
|
context: context,
|
|
content: ShareContent.text(text),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
}
|
|
|
|
void showShareSheetLink({
|
|
required BuildContext context,
|
|
required String link,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) {
|
|
showShareSheet(
|
|
context: context,
|
|
content: ShareContent.link(link),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
}
|
|
|
|
void showShareSheetFiles({
|
|
required BuildContext context,
|
|
required List<XFile> files,
|
|
String? title,
|
|
bool toSystem = false,
|
|
VoidCallback? onClose,
|
|
}) {
|
|
showShareSheet(
|
|
context: context,
|
|
content: ShareContent.files(files),
|
|
title: title,
|
|
toSystem: toSystem,
|
|
onClose: onClose,
|
|
);
|
|
}
|