| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- 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<AttachmentFile> _files = [];
- List<AttachmentFile> get files => List.unmodifiable(_files);
- int get count => _files.length;
- bool get isFull => _files.length >= maxCount;
- AttachmentPickerController({
- this.maxCount = 9,
- List<AttachmentFile>? 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<AttachmentFile> 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<void> restoreFromPaths(List<String> paths) async {
- _files.clear();
- for (final path in paths.take(maxCount)) {
- _files.add(await AttachmentFile.fromPath(path));
- }
- notifyListeners();
- }
- /// 导出为路径列表(草稿持久化)
- List<String> 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<String>? 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<AttachmentPicker> createState() => _AttachmentPickerState();
- }
- class _AttachmentPickerState extends State<AttachmentPicker> {
- List<AttachmentFile> 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<void> _showPicker() async {
- _unfocus();
- final l10n = AppLocalizations.of(context);
- final colors = Theme.of(context).extension<AppColorsExtension>()!;
- final choice = await showModalBottomSheet<String>(
- context: context,
- backgroundColor: colors.bgCard,
- shape: const RoundedRectangleBorder(
- borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
- ),
- builder: (ctx) => SafeArea(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- ListTile(
- leading: const Icon(Icons.image_outlined),
- title: Text(l10n.get('pickImage')),
- onTap: () => Navigator.pop(ctx, 'image'),
- ),
- ListTile(
- leading: const Icon(Icons.description_outlined),
- title: Text(l10n.get('pickFile')),
- onTap: () => Navigator.pop(ctx, 'file'),
- ),
- ],
- ),
- ),
- );
- if (!mounted || choice == null) return;
- if (choice == 'image') {
- await _pickImages();
- } else {
- await _pickDocuments();
- }
- }
- Future<void> _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<void> _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<AppColorsExtension>()!;
- 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.bgPage,
- 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<AppColorsExtension>()!;
- 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),
- );
- }
- }
|