import 'dart:io'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../models/attachment_file.dart'; import '../../core/i18n/app_localizations.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors_extension.dart'; // ═══════════════════════════════════════════════════════════════ // Controller // ═══════════════════════════════════════════════════════════════ class AttachmentPickerController extends ChangeNotifier { final int maxCount; final List _files = []; List get files => List.unmodifiable(_files); int get count => _files.length; bool get isFull => _files.length >= maxCount; AttachmentPickerController({ this.maxCount = 9, List? initialFiles, }) { if (initialFiles != null) { _files.addAll(initialFiles.take(maxCount)); } } void addFile(AttachmentFile file) { if (_files.length >= maxCount) return; _files.add(file); notifyListeners(); } void addFiles(List files) { for (final f in files) { if (_files.length >= maxCount) break; _files.add(f); } notifyListeners(); } void removeFile(int index) { if (index < 0 || index >= _files.length) return; _files.removeAt(index); notifyListeners(); } void clear() { if (_files.isEmpty) return; _files.clear(); notifyListeners(); } /// 从路径列表恢复(草稿兼容) Future restoreFromPaths(List paths) async { _files.clear(); for (final path in paths.take(maxCount)) { _files.add(await AttachmentFile.fromPath(path)); } notifyListeners(); } /// 导出为路径列表(草稿持久化) List toPathList() => _files.map((f) => f.path).toList(); } // ═══════════════════════════════════════════════════════════════ // Widget // ═══════════════════════════════════════════════════════════════ class AttachmentPicker extends StatefulWidget { final AttachmentPickerController controller; /// 图片大小上限(MB),null 不限制 final double? maxImageSizeMB; /// 文件大小上限(MB),null 不限制 final double? maxFileSizeMB; /// 允许的文件扩展名,null 使用默认 pdf/doc/docx/xls/xlsx/ppt/pptx/txt final List? allowedExtensions; /// 文件被拒时回调 final void Function(AttachmentFile file, String reason)? onFileRejected; /// 缩略图尺寸 final double thumbnailSize; const AttachmentPicker({ super.key, required this.controller, this.maxImageSizeMB, this.maxFileSizeMB, this.allowedExtensions, this.onFileRejected, this.thumbnailSize = 80, }); @override State createState() => _AttachmentPickerState(); } class _AttachmentPickerState extends State { List get _files => widget.controller.files; @override void initState() { super.initState(); widget.controller.addListener(_onChanged); } @override void dispose() { widget.controller.removeListener(_onChanged); super.dispose(); } void _onChanged() { if (mounted) setState(() {}); } // ── 选择入口 ── Future _showPicker() async { _unfocus(); final l10n = AppLocalizations.of(context); final colors = Theme.of(context).extension()!; final choice = await showModalBottomSheet( context: context, backgroundColor: colors.bgCard, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(0, 8, 0, 20), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 拖拽手柄 Center( child: Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: colors.border, borderRadius: BorderRadius.circular(2), ), ), ), // 选择图片 InkWell( onTap: () => Navigator.pop(ctx, 'image'), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(12), ), child: Icon(Icons.image_outlined, color: colors.primary, size: 24), ), const SizedBox(width: 16), Text( l10n.get('pickImage'), style: TextStyle(fontSize: 16, color: colors.textPrimary), ), ], ), ), ), const Divider(height: 1, indent: 76), // 选择文件 InkWell( onTap: () => Navigator.pop(ctx, 'file'), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(12), ), child: Icon(Icons.description_outlined, color: colors.primary, size: 24), ), const SizedBox(width: 16), Text( l10n.get('pickFile'), style: TextStyle(fontSize: 16, color: colors.textPrimary), ), ], ), ), ), ], ), ), ), ); if (!mounted || choice == null) return; if (choice == 'image') { await _pickImages(); } else { await _pickDocuments(); } } Future _pickImages() async { final available = widget.controller.maxCount - widget.controller.count; if (available <= 0) return; final picker = ImagePicker(); final images = await picker.pickMultiImage(limit: available); if (!mounted || images.isEmpty) return; for (final img in images) { if (widget.controller.isFull) break; final file = await AttachmentFile.fromXFile(img); if (_checkOversized(file)) continue; widget.controller.addFile(file); } } Future _pickDocuments() async { final available = widget.controller.maxCount - widget.controller.count; if (available <= 0) return; final result = await FilePicker.pickFiles( type: FileType.custom, allowedExtensions: widget.allowedExtensions ?? const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'], allowMultiple: true, ); if (!mounted || result == null || result.files.isEmpty) return; for (final pf in result.files) { if (widget.controller.isFull) break; if (pf.path == null) continue; final file = AttachmentFile.fromPlatformFile(pf); if (_checkOversized(file)) continue; widget.controller.addFile(file); } } /// 返回 true 表示文件超过大小限制 bool _checkOversized(AttachmentFile file) { final l10n = AppLocalizations.of(context); final sizeMB = file.sizeMB; if (file.isImage && widget.maxImageSizeMB != null && sizeMB > widget.maxImageSizeMB!) { final reason = l10n.getString('imageSizeLimit', args: {'max': widget.maxImageSizeMB!.toStringAsFixed(0)}); widget.onFileRejected?.call(file, reason); if (mounted) TDToast.showText(reason, context: context); return true; } if (!file.isImage && widget.maxFileSizeMB != null && sizeMB > widget.maxFileSizeMB!) { final reason = l10n.getString('fileSizeLimit', args: {'max': widget.maxFileSizeMB!.toStringAsFixed(0)}); widget.onFileRejected?.call(file, reason); if (mounted) TDToast.showText(reason, context: context); return true; } return false; } void _unfocus() => FocusScope.of(context).unfocus(); // ── UI ── @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, children: [ ..._files.asMap().entries.map( (e) => Stack( clipBehavior: Clip.none, children: [ _buildThumbnail(e.value), Positioned( right: -4, top: -4, child: GestureDetector( onTap: () => widget.controller.removeFile(e.key), child: Container( width: 20, height: 20, decoration: BoxDecoration( color: colors.danger, shape: BoxShape.circle, ), child: const Icon( Icons.close, size: 12, color: Colors.white, ), ), ), ), ], ), ), if (!widget.controller.isFull) GestureDetector( onTap: () => _showPicker(), child: Container( width: widget.thumbnailSize, height: widget.thumbnailSize, decoration: BoxDecoration( color: colors.bgCard, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border, width: 1), ), child: Center( child: Icon( Icons.add, size: 24, color: colors.textPlaceholder, ), ), ), ), ], ), ], ); } Widget _buildThumbnail(AttachmentFile file) { final colors = Theme.of(context).extension()!; final size = widget.thumbnailSize; if (file.isImage) { return Container( width: size, height: size, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border, width: 0.5), ), child: ClipRRect( borderRadius: BorderRadius.circular(4), child: Image.file( File(file.path), width: size, height: size, fit: BoxFit.cover, errorBuilder: (_, _, _) => _buildDocTile(file, colors, size), ), ), ); } return _buildDocTile(file, colors, size); } Widget _buildDocTile(AttachmentFile file, AppColorsExtension colors, double size) { return SizedBox( width: size, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: size, height: size, decoration: BoxDecoration( color: colors.primaryLight, borderRadius: BorderRadius.circular(4), ), child: Center( child: Icon( _fileTypeIcon(file.extension), color: colors.primary, size: size * 0.4, ), ), ), const SizedBox(height: 4), SizedBox( width: size, height: 16, child: _MarqueeText( text: file.name, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), ), ], ), ); } IconData _fileTypeIcon(String ext) { switch (ext) { case 'pdf': return Icons.picture_as_pdf; case 'doc': case 'docx': return Icons.description; case 'xls': case 'xlsx': return Icons.table_chart; case 'ppt': case 'pptx': return Icons.slideshow; default: return Icons.insert_drive_file; } } } // ═══════════════════════════════════════════════════════════════ // Marquee Text // ═══════════════════════════════════════════════════════════════ class _MarqueeText extends StatefulWidget { final String text; final TextStyle style; const _MarqueeText({required this.text, required this.style}); @override State<_MarqueeText> createState() => _MarqueeTextState(); } class _MarqueeTextState extends State<_MarqueeText> with SingleTickerProviderStateMixin { late final AnimationController _controller; final ScrollController _scrollController = ScrollController(); bool _needsScroll = false; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 3000), ); WidgetsBinding.instance.addPostFrameCallback(_measure); } void _measure(_) { if (!mounted || !_scrollController.hasClients) return; if (_scrollController.position.maxScrollExtent > 0) { setState(() => _needsScroll = true); _controller.addListener(_onScroll); _controller.repeat(reverse: true); } } void _onScroll() { if (!_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; if (max > 0) { _scrollController.jumpTo(_controller.value * max); } } @override void dispose() { _controller.dispose(); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleChildScrollView( controller: _scrollController, scrollDirection: Axis.horizontal, physics: _needsScroll ? const NeverScrollableScrollPhysics() : const ClampingScrollPhysics(), child: Text(widget.text, style: widget.style, maxLines: 1), ); } }