281 lines
8.7 KiB
Dart
281 lines
8.7 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:flutter/services.dart';
|
|||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
|
|
import '../../../shared/theme/theme_extensions.dart';
|
|||
|
|
import '../../../core/providers/app_providers.dart';
|
|||
|
|
import '../../../core/services/api_service.dart';
|
|||
|
|
import 'enhanced_image_attachment.dart';
|
|||
|
|
import 'package:share_plus/share_plus.dart';
|
|||
|
|
import 'package:path_provider/path_provider.dart';
|
|||
|
|
import 'dart:io';
|
|||
|
|
import 'dart:convert';
|
|||
|
|
|
|||
|
|
class EnhancedAttachment extends ConsumerStatefulWidget {
|
|||
|
|
final String attachmentId;
|
|||
|
|
final bool isMarkdownFormat;
|
|||
|
|
final BoxConstraints? constraints;
|
|||
|
|
final bool isUserMessage;
|
|||
|
|
final bool disableAnimation;
|
|||
|
|
|
|||
|
|
const EnhancedAttachment({
|
|||
|
|
super.key,
|
|||
|
|
required this.attachmentId,
|
|||
|
|
this.isMarkdownFormat = false,
|
|||
|
|
this.constraints,
|
|||
|
|
this.isUserMessage = false,
|
|||
|
|
this.disableAnimation = false,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ConsumerState<EnhancedAttachment> createState() => _EnhancedAttachmentState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
|
|||
|
|
Map<String, dynamic>? _fileInfo;
|
|||
|
|
bool _isLoading = true;
|
|||
|
|
String? _error;
|
|||
|
|
String? _localFilePath;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_resolveType();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _resolveType() async {
|
|||
|
|
try {
|
|||
|
|
// Data URL for images – short-circuit to image widget
|
|||
|
|
if (widget.attachmentId.startsWith('data:image/')) {
|
|||
|
|
setState(() {
|
|||
|
|
_isLoading = false;
|
|||
|
|
_fileInfo = {'mime': 'image/inline'};
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final api = ref.read(apiServiceProvider);
|
|||
|
|
if (api is! ApiService) {
|
|||
|
|
setState(() {
|
|||
|
|
_isLoading = false;
|
|||
|
|
_error = 'Service unavailable';
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final info = await api.getFileInfo(widget.attachmentId);
|
|||
|
|
setState(() {
|
|||
|
|
_fileInfo = info;
|
|||
|
|
_isLoading = false;
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
setState(() {
|
|||
|
|
_error = 'Failed to load attachment';
|
|||
|
|
_isLoading = false;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool _isImageFile(Map<String, dynamic>? info) {
|
|||
|
|
if (info == null) return false;
|
|||
|
|
final mime = (info['content_type'] ?? info['mime'] ?? '')
|
|||
|
|
.toString()
|
|||
|
|
.toLowerCase();
|
|||
|
|
if (mime.startsWith('image/')) return true;
|
|||
|
|
final name = (info['filename'] ?? info['name'] ?? '')
|
|||
|
|
.toString()
|
|||
|
|
.toLowerCase();
|
|||
|
|
final ext = name.split('.').length > 1 ? name.split('.').last : '';
|
|||
|
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<String?> _ensureLocalFile() async {
|
|||
|
|
if (_localFilePath != null && await File(_localFilePath!).exists()) {
|
|||
|
|
return _localFilePath;
|
|||
|
|
}
|
|||
|
|
try {
|
|||
|
|
final api = ref.read(apiServiceProvider);
|
|||
|
|
if (api is! ApiService) return null;
|
|||
|
|
|
|||
|
|
final content = await api.getFileContent(widget.attachmentId);
|
|||
|
|
final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file')
|
|||
|
|
.toString();
|
|||
|
|
final dir = await getTemporaryDirectory();
|
|||
|
|
final filePath = '${dir.path}/$filename';
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
if (content.length > 128 &&
|
|||
|
|
RegExp(
|
|||
|
|
r'^[A-Za-z0-9+/=\r\n]+$',
|
|||
|
|
).hasMatch(content.replaceAll('\n', ''))) {
|
|||
|
|
final bytes = base64Decode(content.replaceAll('\n', ''));
|
|||
|
|
await File(filePath).writeAsBytes(bytes, flush: true);
|
|||
|
|
} else {
|
|||
|
|
await File(filePath).writeAsString(content, flush: true);
|
|||
|
|
}
|
|||
|
|
} catch (_) {
|
|||
|
|
await File(filePath).writeAsString(content, flush: true);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_localFilePath = filePath;
|
|||
|
|
return _localFilePath;
|
|||
|
|
} catch (e) {
|
|||
|
|
setState(() {
|
|||
|
|
_error = 'Failed to prepare file';
|
|||
|
|
});
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _shareFile() async {
|
|||
|
|
final path = await _ensureLocalFile();
|
|||
|
|
if (path == null) return;
|
|||
|
|
final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'file')
|
|||
|
|
.toString();
|
|||
|
|
await Share.shareXFiles([XFile(path, name: filename)]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
String _fileIconFor(String filename) {
|
|||
|
|
final lower = filename.toLowerCase();
|
|||
|
|
String ext = '';
|
|||
|
|
final parts = lower.split('.');
|
|||
|
|
if (parts.length > 1) ext = parts.last;
|
|||
|
|
if (['pdf', 'doc', 'docx'].contains(ext)) return '📄';
|
|||
|
|
if (['xls', 'xlsx'].contains(ext)) return '📊';
|
|||
|
|
if (['ppt', 'pptx'].contains(ext)) return '📊';
|
|||
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext)) return '🖼️';
|
|||
|
|
if (['js', 'ts', 'py', 'dart', 'java', 'cpp'].contains(ext)) return '💻';
|
|||
|
|
if (['html', 'css', 'json', 'xml'].contains(ext)) return '🌐';
|
|||
|
|
if (['zip', 'rar', '7z', 'tar', 'gz'].contains(ext)) return '📦';
|
|||
|
|
if (['mp3', 'wav', 'flac', 'm4a'].contains(ext)) return '🎵';
|
|||
|
|
if (['mp4', 'avi', 'mov', 'mkv'].contains(ext)) return '🎬';
|
|||
|
|
return '📎';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
if (_isLoading) {
|
|||
|
|
return Container(
|
|||
|
|
width: widget.constraints?.maxWidth ?? 160,
|
|||
|
|
height: 84,
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: context.conduitTheme.cardBackground,
|
|||
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|||
|
|
border: Border.all(
|
|||
|
|
color: context.conduitTheme.textPrimary.withValues(alpha: 0.1),
|
|||
|
|
width: BorderWidth.regular,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (_error != null) {
|
|||
|
|
return Container(
|
|||
|
|
padding: const EdgeInsets.all(Spacing.sm),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: context.conduitTheme.surfaceContainer,
|
|||
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|||
|
|
border: Border.all(
|
|||
|
|
color: context.conduitTheme.error.withValues(alpha: 0.3),
|
|||
|
|
width: BorderWidth.regular,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
child: Text(
|
|||
|
|
_error!,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: context.conduitTheme.error,
|
|||
|
|
fontSize: AppTypography.labelMedium,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Image: delegate to existing image widget for consistency
|
|||
|
|
if (_isImageFile(_fileInfo)) {
|
|||
|
|
return EnhancedImageAttachment(
|
|||
|
|
attachmentId: widget.attachmentId,
|
|||
|
|
isMarkdownFormat: widget.isMarkdownFormat,
|
|||
|
|
constraints: widget.constraints,
|
|||
|
|
isUserMessage: widget.isUserMessage,
|
|||
|
|
disableAnimation: widget.disableAnimation,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
final filename = (_fileInfo?['filename'] ?? _fileInfo?['name'] ?? 'File')
|
|||
|
|
.toString();
|
|||
|
|
final size = _fileInfo?['size'];
|
|||
|
|
final sizeLabel = size is num ? _formatSize(size.toInt()) : null;
|
|||
|
|
|
|||
|
|
final card = Container(
|
|||
|
|
constraints: widget.constraints,
|
|||
|
|
padding: const EdgeInsets.all(Spacing.md),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: context.conduitTheme.cardBackground,
|
|||
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|||
|
|
border: Border.all(
|
|||
|
|
color: context.conduitTheme.textPrimary.withValues(alpha: 0.12),
|
|||
|
|
width: BorderWidth.regular,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
child: Row(
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
_fileIconFor(filename),
|
|||
|
|
style: const TextStyle(fontSize: AppTypography.headlineLarge),
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: Spacing.sm),
|
|||
|
|
ConstrainedBox(
|
|||
|
|
constraints: const BoxConstraints(maxWidth: 220),
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
filename,
|
|||
|
|
maxLines: 1,
|
|||
|
|
overflow: TextOverflow.ellipsis,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: context.conduitTheme.textPrimary,
|
|||
|
|
fontSize: AppTypography.labelLarge,
|
|||
|
|
fontWeight: FontWeight.w600,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
if (sizeLabel != null)
|
|||
|
|
Text(
|
|||
|
|
sizeLabel,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: context.conduitTheme.textSecondary.withValues(
|
|||
|
|
alpha: 0.7,
|
|||
|
|
),
|
|||
|
|
fontSize: AppTypography.labelMedium,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return InkWell(
|
|||
|
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
|||
|
|
onTap: () async {
|
|||
|
|
await HapticFeedback.mediumImpact();
|
|||
|
|
await _shareFile();
|
|||
|
|
},
|
|||
|
|
child: card,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
String _formatSize(int bytes) {
|
|||
|
|
if (bytes < 1024) return '$bytes B';
|
|||
|
|
if (bytes < 1024 * 1024) {
|
|||
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|||
|
|
}
|
|||
|
|
if (bytes < 1024 * 1024 * 1024) {
|
|||
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|||
|
|
}
|
|||
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|||
|
|
}
|
|||
|
|
}
|