import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; void main() { runApp(const LubanImagerApp()); } class LubanImagerApp extends StatefulWidget { const LubanImagerApp({super.key}); @override State createState() => _LubanImagerAppState(); } class _LubanImagerAppState extends State with WidgetsBindingObserver { late String _appName; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _appName = AppName.forLocales( WidgetsBinding.instance.platformDispatcher.locales, ); } @override void didChangeLocales(List? locales) { setState(() { _appName = AppName.forLocales( locales ?? WidgetsBinding.instance.platformDispatcher.locales, ); }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: _appName, theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, home: ImagerHomePage(appName: _appName), ); } } class AppTheme { static const _seedColor = Color(0xFF2F6F73); static const _lightBackground = Color(0xFFF7F8F6); static const _darkBackground = Color(0xFF101817); static ThemeData light() { final colorScheme = ColorScheme.fromSeed( seedColor: _seedColor, brightness: Brightness.light, ); return _build( colorScheme: colorScheme, scaffoldBackgroundColor: _lightBackground, systemOverlayStyle: SystemUiOverlayStyle.dark, ); } static ThemeData dark() { final colorScheme = ColorScheme.fromSeed( seedColor: _seedColor, brightness: Brightness.dark, ); return _build( colorScheme: colorScheme, scaffoldBackgroundColor: _darkBackground, systemOverlayStyle: SystemUiOverlayStyle.light, ); } static ThemeData _build({ required ColorScheme colorScheme, required Color scaffoldBackgroundColor, required SystemUiOverlayStyle systemOverlayStyle, }) { return ThemeData( useMaterial3: true, colorScheme: colorScheme, scaffoldBackgroundColor: scaffoldBackgroundColor, canvasColor: scaffoldBackgroundColor, appBarTheme: AppBarTheme( centerTitle: false, backgroundColor: scaffoldBackgroundColor, foregroundColor: colorScheme.onSurface, surfaceTintColor: Colors.transparent, elevation: 0, systemOverlayStyle: systemOverlayStyle, ), ); } } class AppName { static const english = 'Luban Imager'; static const chinese = '鲁班压图'; static String forLocales(List locales) { final locale = locales.isNotEmpty ? locales.first : WidgetsBinding.instance.platformDispatcher.locale; return forLocale(locale); } static String forLocale(Locale locale) { return locale.languageCode.toLowerCase() == 'zh' ? chinese : english; } } class ImagerHomePage extends StatefulWidget { const ImagerHomePage({required this.appName, super.key}); final String appName; @override State createState() => _ImagerHomePageState(); } class _ImagerHomePageState extends State { static const MethodChannel _channel = MethodChannel( 'luban_imager/native_images', ); ImageJob? _job; ImagePickSource? _currentPickSource; var _previewMode = PreviewMode.original; var _compareFraction = 0.5; var _isPicking = false; OverlayEntry? _toastEntry; Timer? _toastTimer; @override void initState() { super.initState(); _channel.setMethodCallHandler(_handleNativeCall); WidgetsBinding.instance.addPostFrameCallback((_) { _takeSharedImages(); }); } @override void dispose() { _channel.setMethodCallHandler(null); _removeToast(); super.dispose(); } Future _handleNativeCall(MethodCall call) async { switch (call.method) { case 'sharedImages': await _handleNativeImages(call.arguments); return null; default: throw MissingPluginException('No handler for ${call.method}'); } } Future _takeSharedImages() async { try { final response = await _channel.invokeMethod>( 'takeSharedImages', ); await _handleNativeImages(response); } on MissingPluginException { // Widget tests and unsupported platforms do not provide the native bridge. } on PlatformException catch (error) { _showMessage(error.message ?? '读取分享图片失败'); } } Future _handleNativeImages(Object? response) async { final images = response is List ? response : const []; if (!mounted || images.isEmpty) { return; } final first = images.first; if (first is! Map) { return; } await _startImageJob( NativeImage.fromMap(Map.from(first)), message: '已接收分享图片,正在压缩', ); } Future _startImageJob( NativeImage image, { String? message, ImagePickSource? source, }) async { if (!mounted) { return; } setState(() { _job = ImageJob(image); _currentPickSource = source; _previewMode = PreviewMode.original; _compareFraction = 0.5; }); if (message != null) { _showMessage(message); } await _compressSelected(); } Future _pickFiles() async { await _pickNativeImage( method: 'pickImages', errorMessage: '选择文件失败', source: ImagePickSource.file, ); } Future _pickAlbumImage() async { await _pickNativeImage( method: 'pickAlbumImage', errorMessage: '选择相册图片失败', source: ImagePickSource.album, ); } Future _repickCurrentSource() async { switch (_currentPickSource) { case ImagePickSource.album: await _pickAlbumImage(); case ImagePickSource.file: case null: await _pickFiles(); } } Future _pickNativeImage({ required String method, required String errorMessage, required ImagePickSource source, }) async { if (_isPicking) { return; } NativeImage? picked; setState(() { _isPicking = true; }); try { final response = await _channel.invokeMethod>(method); final images = response ?? []; if (!mounted || images.isEmpty) { return; } picked = NativeImage.fromMap( Map.from(images.first as Map), ); } on PlatformException catch (error) { _showMessage(error.message ?? errorMessage); } finally { if (mounted) { setState(() { _isPicking = false; }); } } if (picked != null && mounted) { await _startImageJob(picked, source: source); } } Future _compressSelected() async { final job = _job; if (job == null) { return; } if (job.isCompressing) { return; } setState(() { job ..isCompressing = true ..error = null; }); try { final response = await _channel .invokeMethod>('compressImage', { 'sourceHandle': job.original.sourceHandle, 'displayName': job.original.displayName, 'previewPath': job.original.previewPath, 'originalSize': job.original.originalSize, 'originalWidth': job.original.width, 'originalHeight': job.original.height, }); if (response == null) { throw const FormatException('native compressor returned no data'); } final compressed = CompressedImage.fromMap(response); if (!mounted) { return; } setState(() { job ..compressed = compressed ..isCompressing = false ..overwritten = false; _previewMode = PreviewMode.compare; }); _showMessage( compressed.passthrough ? '压缩后比原图更大,已使用原图' : '压缩完成,节省 ${_savingLabel(job)}', ); } on Object catch (error) { if (!mounted) { return; } setState(() { job ..isCompressing = false ..error = _errorText(error); }); _showMessage(job.error ?? '压缩失败'); } } Future _saveSelected() async { final job = _job; final compressed = job?.compressed; if (job == null || compressed == null) { return; } try { await _channel.invokeMethod>('saveToGallery', { 'compressedPath': compressed.path, 'suggestedName': _suggestedName(job.original.displayName), }); _showMessage('已保存到系统相册'); } on PlatformException catch (error) { _showMessage(error.message ?? '保存失败'); } } Future _shareSelected() async { await _openShareSheet(); } Future _openShareSheet() async { final job = _job; final compressed = job?.compressed; if (job == null || compressed == null) { return; } try { await _channel.invokeMethod>('shareImage', { 'compressedPath': compressed.path, 'suggestedName': _suggestedName(job.original.displayName), }); } on PlatformException catch (error) { _showMessage(error.message ?? '分享失败'); } } Future _overwriteSelected() async { final job = _job; final compressed = job?.compressed; if (job == null || compressed == null) { return; } if (!job.original.canOverwrite) { _showMessage('当前来源不支持原位覆盖,请使用保存'); return; } final confirmed = await _confirmOverwrite(job.original.displayName); if (!confirmed || !mounted) { return; } try { await _channel.invokeMethod>('overwriteOriginal', { 'sourceHandle': job.original.sourceHandle, 'compressedPath': compressed.path, }); if (!mounted) { return; } setState(() { job.overwritten = true; }); _showMessage('已覆盖'); } on PlatformException catch (error) { _showMessage(error.message ?? '覆盖失败'); } } Future _confirmOverwrite(String name) async { final first = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('覆盖'), content: Text('将用压缩后的图片替换「$name」,此操作无法从 App 内撤销。'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('取消'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('继续'), ), ], ), ); if (first != true || !mounted) { return false; } final second = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('再次确认'), content: const Text('确认覆盖?建议先保存备份。'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('取消'), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: () => Navigator.of(context).pop(true), child: const Text('覆盖'), ), ], ), ); return second == true; } void _clearImage() { setState(() { _job = null; _currentPickSource = null; _previewMode = PreviewMode.original; _compareFraction = 0.5; }); } void _showMessage(String message) { if (!mounted) { return; } _removeToast(); final overlay = Overlay.maybeOf(context, rootOverlay: true); if (overlay == null) { return; } final colorScheme = Theme.of(context).colorScheme; final textStyle = Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onInverseSurface, fontWeight: FontWeight.w700, ); _toastEntry = OverlayEntry( builder: (context) { final top = MediaQuery.of(context).padding.top + kToolbarHeight + 8; return Positioned( left: 16, right: 16, top: top, child: IgnorePointer( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Material( color: colorScheme.inverseSurface, elevation: 8, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), child: Text( message, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: textStyle, ), ), ), ), ), ), ); }, ); overlay.insert(_toastEntry!); _toastTimer = Timer(const Duration(milliseconds: 1500), () { _toastEntry?.remove(); _toastEntry = null; _toastTimer = null; }); } void _removeToast() { _toastTimer?.cancel(); _toastTimer = null; _toastEntry?.remove(); _toastEntry = null; } @override Widget build(BuildContext context) { final job = _job; return Scaffold( appBar: AppBar( title: Text(widget.appName), actions: job == null ? null : [ Padding( padding: const EdgeInsets.only(right: 8), child: TextButton.icon( onPressed: _isPicking ? null : _repickCurrentSource, icon: const Icon(Icons.add_photo_alternate_outlined), label: const Text('重新选择'), ), ), ], ), body: SafeArea( child: job == null ? _buildEmptyState() : _buildPreviewPanel(job), ), ); } Widget _buildEmptyState() { final colorScheme = Theme.of(context).colorScheme; return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.image_search_outlined, size: 72, color: colorScheme.outline, ), const SizedBox(height: 20), Text( '选择图片开始压缩', style: Theme.of( context, ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), ), const SizedBox(height: 16), Wrap( spacing: 10, runSpacing: 10, alignment: WrapAlignment.center, children: [ FilledButton.icon( onPressed: _isPicking ? null : _pickFiles, icon: const Icon(Icons.insert_drive_file_outlined), label: const Text('选择文件'), ), FilledButton.tonalIcon( onPressed: _isPicking ? null : _pickAlbumImage, icon: const Icon(Icons.photo_library_outlined), label: const Text('相册选择'), ), ], ), ], ), ), ); } Widget _buildPreviewPanel(ImageJob job) { final compressed = job.compressed; final canCompare = compressed != null; final mode = canCompare ? _previewMode : PreviewMode.original; final colorScheme = Theme.of(context).colorScheme; return LayoutBuilder( builder: (context, constraints) { final showCompareSlider = mode == PreviewMode.compare && canCompare; final showWarning = compressed?.passthrough == true; const minPreviewHeight = 200.0; const baseChromeHeight = 360.0; const compareSliderHeight = 52.0; const warningHeight = 58.0; // Keep the panel fixed-height while the preview can stay at least 200px. final chromeHeight = baseChromeHeight + (showWarning ? warningHeight : 0.0) - (showCompareSlider ? 0.0 : compareSliderHeight); final availablePreviewHeight = constraints.maxHeight - chromeHeight; final previewHeight = math.max( minPreviewHeight, availablePreviewHeight, ); List buildContent() { return [ SizedBox( height: 40, child: Row( children: [ Expanded( child: Text( job.original.displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, ), ), ), IconButton( tooltip: '移除', visualDensity: VisualDensity.compact, onPressed: _clearImage, icon: const Icon(Icons.close), ), ], ), ), const SizedBox(height: 10), Row( children: [ Expanded( child: _MetricBlock( label: '原图', value: _formatSize(job.original.originalSize), detail: '${job.original.width}×${job.original.height}', ), ), const SizedBox(width: 10), Expanded( child: _MetricBlock( label: '压缩后', value: compressed == null ? '--' : _formatSize(compressed.outputSize), detail: compressed == null ? '--' : '${compressed.width}×${compressed.height}', ), ), const SizedBox(width: 10), Expanded( child: _MetricBlock( label: '节省', value: compressed == null ? '--' : _savingLabel(job), detail: compressed?.passthrough == true ? '压缩后更大' : '', ), ), ], ), if (showWarning) ...[ const SizedBox(height: 10), const _WarningStrip(message: '压缩结果比原图更大,已自动使用原图。'), ], const SizedBox(height: 14), SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( value: PreviewMode.original, icon: Icon(Icons.image_outlined), label: Text('原图'), ), ButtonSegment( value: PreviewMode.compressed, icon: Icon(Icons.photo_size_select_large_outlined), label: Text('压缩'), ), ButtonSegment( value: PreviewMode.compare, icon: Icon(Icons.compare_outlined), label: Text('对比'), ), ], selected: {mode}, onSelectionChanged: canCompare ? (selection) { setState(() { _previewMode = selection.first; }); } : null, ), const SizedBox(height: 14), SizedBox( height: previewHeight, child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), foregroundDecoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: colorScheme.outlineVariant), ), child: _buildImagePreview(job, mode), ), ), if (showCompareSlider) ...[ const SizedBox(height: 4), _CompareSlider( value: _compareFraction, min: 0.0, max: 1.0, onChanged: (value) { setState(() { _compareFraction = value; }); }, ), ], const SizedBox(height: 10), Wrap( spacing: 10, runSpacing: 10, crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton.icon( onPressed: compressed == null || job.isCompressing ? null : _saveSelected, icon: const Icon(Icons.save_alt_outlined), label: const Text('保存'), ), FilledButton.tonalIcon( onPressed: compressed == null || job.isCompressing ? null : _shareSelected, icon: const Icon(Icons.ios_share_outlined), label: const Text('分享'), ), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: compressed == null ? null : _overwriteSelected, icon: const Icon(Icons.warning_amber_rounded), label: const Text('覆盖'), ), ], ), ]; } final child = Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: buildContent(), ); if (availablePreviewHeight >= minPreviewHeight) { return Padding(padding: const EdgeInsets.all(16), child: child); } return Scrollbar( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: child, ), ); }, ); } Widget _buildImagePreview(ImageJob job, PreviewMode mode) { final compressedPath = job.compressed?.path; if (mode == PreviewMode.compressed && compressedPath != null) { return _ZoomablePreview( width: job.compressed?.width ?? job.original.width, height: job.compressed?.height ?? job.original.height, child: _PreviewImage(path: compressedPath), ); } if (mode == PreviewMode.compare && compressedPath != null) { return _ZoomablePreview( width: job.original.width, height: job.original.height, overlay: _CompareOverlay(fraction: _compareFraction), childBuilder: (context, metrics) { final clipX = metrics.canvasXForViewportFraction(_compareFraction); return Stack( fit: StackFit.expand, children: [ _PreviewImage(path: compressedPath), ClipRect( clipper: _CompareClipper(clipX), child: _PreviewImage(path: job.original.previewPath), ), ], ); }, ); } return _ZoomablePreview( width: job.original.width, height: job.original.height, child: _PreviewImage(path: job.original.previewPath), ); } String _savingLabel(ImageJob job) { final compressed = job.compressed; if (compressed == null || job.original.originalSize <= 0) { return '--'; } if (compressed.passthrough) { return '使用原图'; } final saved = job.original.originalSize - compressed.outputSize; if (saved <= 0) { return '0%'; } return '${(saved / job.original.originalSize * 100).toStringAsFixed(1)}%'; } String _suggestedName(String originalName) { final dot = originalName.lastIndexOf('.'); if (dot <= 0) { return '${originalName}_luban.jpg'; } return '${originalName.substring(0, dot)}_luban${originalName.substring(dot)}'; } String _formatSize(int bytes) { if (bytes <= 0) { return '0 B'; } const units = ['B', 'KB', 'MB', 'GB']; var value = bytes.toDouble(); var unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } final digits = unit == 0 ? 0 : 1; return '${value.toStringAsFixed(digits)} ${units[unit]}'; } String _errorText(Object error) { if (error is PlatformException) { return error.message ?? error.code; } return error.toString(); } } enum PreviewMode { original, compressed, compare } enum ImagePickSource { file, album } class NativeImage { NativeImage({ required this.sourceHandle, required this.displayName, required this.previewPath, required this.originalSize, required this.width, required this.height, required this.canOverwrite, }); factory NativeImage.fromMap(Map map) { return NativeImage( sourceHandle: map['sourceHandle'] as String, displayName: map['displayName'] as String? ?? 'image', previewPath: map['previewPath'] as String, originalSize: (map['originalSize'] as num?)?.toInt() ?? 0, width: (map['width'] as num?)?.toInt() ?? 0, height: (map['height'] as num?)?.toInt() ?? 0, canOverwrite: map['canOverwrite'] as bool? ?? false, ); } final String sourceHandle; final String displayName; final String previewPath; final int originalSize; final int width; final int height; final bool canOverwrite; } class CompressedImage { CompressedImage({ required this.path, required this.outputSize, required this.width, required this.height, required this.passthrough, }); factory CompressedImage.fromMap(Map map) { return CompressedImage( path: map['path'] as String, outputSize: (map['outputSize'] as num?)?.toInt() ?? 0, width: (map['width'] as num?)?.toInt() ?? 0, height: (map['height'] as num?)?.toInt() ?? 0, passthrough: map['passthrough'] as bool? ?? false, ); } final String path; final int outputSize; final int width; final int height; final bool passthrough; } class ImageJob { ImageJob(this.original); final NativeImage original; CompressedImage? compressed; bool isCompressing = false; bool overwritten = false; String? error; } class _WarningStrip extends StatelessWidget { const _WarningStrip({required this.message}); final String message; @override Widget build(BuildContext context) { final color = Theme.of(context).colorScheme.error; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withValues(alpha: 0.28)), ), child: Row( children: [ Icon(Icons.warning_amber_rounded, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Text( message, style: TextStyle(color: color, fontWeight: FontWeight.w700), ), ), ], ), ); } } class _MetricBlock extends StatelessWidget { const _MetricBlock({ required this.label, required this.value, required this.detail, }); final String label; final String value; final String detail; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(8), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 7), Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), ), const SizedBox(height: 2), Text( detail, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall, ), ], ), ); } } class _CompareSlider extends StatefulWidget { const _CompareSlider({ required this.value, required this.min, required this.max, required this.onChanged, }); final double value; final double min; final double max; final ValueChanged onChanged; @override State<_CompareSlider> createState() => _CompareSliderState(); } class _CompareSliderState extends State<_CompareSlider> { static const _height = 48.0; static const _trackHeight = 4.0; static const _thumbSize = 22.0; double? _dragValue; double _clampValue(double value) { return value.clamp(widget.min, widget.max).toDouble(); } double _valueFromLocalX(double localX, double trackWidth) { final normal = (localX / trackWidth).clamp(0.0, 1.0).toDouble(); return widget.min + normal * (widget.max - widget.min); } void _jumpTo(double localX, double trackWidth) { final value = _clampValue(_valueFromLocalX(localX, trackWidth)); _dragValue = value; widget.onChanged(value); } void _handleDragStart(DragStartDetails details) { _dragValue ??= widget.value; } void _handleDragUpdate(DragUpdateDetails details, double trackWidth) { if (trackWidth <= 0) { return; } final range = widget.max - widget.min; final delta = (details.primaryDelta ?? details.delta.dx) / trackWidth; final nextValue = _clampValue((_dragValue ?? widget.value) + delta * range); _dragValue = nextValue; widget.onChanged(nextValue); } void _handleDragEnd(DragEndDetails details) { _dragValue = null; } void _handleDragCancel() { _dragValue = null; } void _step(double delta) { widget.onChanged(_clampValue(widget.value + delta)); } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Semantics( label: '对比位置', value: '${(widget.value * 100).round()}%', onIncrease: () => _step(0.02), onDecrease: () => _step(-0.02), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final trackWidth = math.max(1.0, width); final normal = ((widget.value - widget.min) / (widget.max - widget.min)) .clamp(0.0, 1.0) .toDouble(); final thumbCenter = trackWidth * normal; return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) { _jumpTo(details.localPosition.dx, trackWidth); }, onTapUp: (details) { _dragValue = null; }, onHorizontalDragStart: _handleDragStart, onHorizontalDragUpdate: (details) { _handleDragUpdate(details, trackWidth); }, onHorizontalDragEnd: _handleDragEnd, onHorizontalDragCancel: _handleDragCancel, child: SizedBox( height: _height, child: Stack( alignment: Alignment.centerLeft, children: [ Positioned( left: 0, right: 0, top: (_height - _trackHeight) / 2, child: DecoratedBox( decoration: BoxDecoration( color: colorScheme.outlineVariant, borderRadius: BorderRadius.circular(99), ), child: const SizedBox(height: _trackHeight), ), ), Positioned( left: 0, width: trackWidth * normal, top: (_height - _trackHeight) / 2, child: DecoratedBox( decoration: BoxDecoration( color: colorScheme.primary, borderRadius: BorderRadius.circular(99), ), child: const SizedBox(height: _trackHeight), ), ), Positioned( left: thumbCenter - _thumbSize / 2, top: (_height - _thumbSize) / 2, child: DecoratedBox( decoration: BoxDecoration( color: colorScheme.primary, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.18), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: const SizedBox.square(dimension: _thumbSize), ), ), ], ), ), ); }, ), ); } } typedef _ZoomablePreviewChildBuilder = Widget Function(BuildContext context, _ZoomablePreviewMetrics metrics); class _ZoomablePreviewMetrics { const _ZoomablePreviewMetrics({ required this.viewportSize, required this.canvasSize, required this.scale, required this.offset, }); final Size viewportSize; final Size canvasSize; final double scale; final Offset offset; double canvasXForViewportFraction(double fraction) { final viewportX = viewportSize.width * fraction.clamp(0.0, 1.0).toDouble(); return canvasSize.width / 2 + (viewportX - viewportSize.width / 2 - offset.dx) / scale; } } class _CompareOverlay extends StatelessWidget { const _CompareOverlay({required this.fraction}); final double fraction; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final dividerX = constraints.maxWidth * fraction.clamp(0.0, 1.0).toDouble(); return Stack( children: [ Positioned( left: dividerX - 1, top: 0, bottom: 0, child: Container(width: 2, color: Colors.white), ), const Positioned( left: 12, top: 12, child: _PreviewBadge(label: '原图'), ), const Positioned( right: 12, top: 12, child: _PreviewBadge(label: '压缩'), ), ], ); }, ); } } class _ZoomablePreview extends StatefulWidget { const _ZoomablePreview({ required this.width, required this.height, this.child, this.childBuilder, this.overlay, }) : assert(child != null || childBuilder != null); final int width; final int height; final Widget? child; final _ZoomablePreviewChildBuilder? childBuilder; final Widget? overlay; @override State<_ZoomablePreview> createState() => _ZoomablePreviewState(); } class _ZoomablePreviewState extends State<_ZoomablePreview> { static const _minScale = 1.0; static const _maxScale = 8.0; Size _viewportSize = Size.zero; Size _canvasSize = Size.zero; var _scale = _minScale; var _offset = Offset.zero; var _gestureStartScale = _minScale; var _gestureStartOffset = Offset.zero; var _gestureStartScenePoint = Offset.zero; @override void didUpdateWidget(covariant _ZoomablePreview oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.width != widget.width || oldWidget.height != widget.height) { _resetTransform(); } } void _resetTransform() { _scale = _minScale; _offset = Offset.zero; } double _clampScale(double scale) { return scale.clamp(_minScale, _maxScale).toDouble(); } Offset _clampOffset(Offset offset, double scale) { final maxX = math.max( 0.0, (_canvasSize.width * scale - _viewportSize.width) / 2, ); final maxY = math.max( 0.0, (_canvasSize.height * scale - _viewportSize.height) / 2, ); return Offset( maxX == 0 ? 0 : offset.dx.clamp(-maxX, maxX).toDouble(), maxY == 0 ? 0 : offset.dy.clamp(-maxY, maxY).toDouble(), ); } void _startGesture(ScaleStartDetails details) { _gestureStartScale = _scale; _gestureStartOffset = _offset; final center = _viewportSize.center(Offset.zero); _gestureStartScenePoint = (details.localFocalPoint - center - _gestureStartOffset) / _scale; } void _updateGesture(ScaleUpdateDetails details) { final nextScale = _clampScale(_gestureStartScale * details.scale); final center = _viewportSize.center(Offset.zero); final nextOffset = details.localFocalPoint - center - _gestureStartScenePoint * nextScale; setState(() { _scale = nextScale; _offset = _clampOffset(nextOffset, nextScale); }); } void _endGesture(ScaleEndDetails details) { setState(() { _scale = _clampScale(_scale); _offset = _clampOffset(_offset, _scale); }); } @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { _viewportSize = Size(constraints.maxWidth, constraints.maxHeight); final imageWidth = math.max(1, widget.width).toDouble(); final imageHeight = math.max(1, widget.height).toDouble(); final aspectRatio = imageWidth / imageHeight; var canvasWidth = constraints.maxWidth; var canvasHeight = canvasWidth / aspectRatio; if (canvasHeight > constraints.maxHeight) { canvasHeight = constraints.maxHeight; canvasWidth = canvasHeight * aspectRatio; } _canvasSize = Size(canvasWidth, canvasHeight); _scale = _clampScale(_scale); _offset = _clampOffset(_offset, _scale); final metrics = _ZoomablePreviewMetrics( viewportSize: _viewportSize, canvasSize: _canvasSize, scale: _scale, offset: _offset, ); final previewChild = widget.childBuilder?.call(context, metrics) ?? widget.child!; return ClipRect( child: Stack( fit: StackFit.expand, children: [ GestureDetector( behavior: HitTestBehavior.opaque, onScaleStart: _startGesture, onScaleUpdate: _updateGesture, onScaleEnd: _endGesture, child: SizedBox.expand( child: Center( child: Transform.translate( offset: _offset, child: Transform.scale( scale: _scale, child: SizedBox( width: canvasWidth, height: canvasHeight, child: previewChild, ), ), ), ), ), ), if (widget.overlay != null) Positioned.fill(child: IgnorePointer(child: widget.overlay)), ], ), ); }, ); } } class _PreviewImage extends StatelessWidget { const _PreviewImage({required this.path}); final String path; @override Widget build(BuildContext context) { return Image.file( File(path), fit: BoxFit.contain, gaplessPlayback: true, errorBuilder: (context, error, stackTrace) { return const Center(child: Icon(Icons.broken_image_outlined, size: 48)); }, ); } } class _PreviewBadge extends StatelessWidget { const _PreviewBadge({required this.label}); final String label; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.62), borderRadius: BorderRadius.circular(99), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), child: Text( label, style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ); } } class _CompareClipper extends CustomClipper { const _CompareClipper(this.clipX); final double clipX; @override Rect getClip(Size size) { final width = clipX.clamp(0.0, size.width).toDouble(); return Rect.fromLTWH(0, 0, width, size.height); } @override bool shouldReclip(_CompareClipper oldClipper) { return oldClipper.clipX != clipX; } }