import 'dart:io'; import 'package:dio/dio.dart'; import 'package:dismissible_page/dismissible_page.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:file_saver/file_saver.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:gal/gal.dart'; import 'package:gap/gap.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:path/path.dart' show extension; import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view_gallery.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/types/attachment.dart'; import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/universal_image.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:uuid/uuid.dart'; class AttachmentZoomView extends StatefulWidget { final Iterable data; final int? initialIndex; final List? heroTags; const AttachmentZoomView({ super.key, required this.data, this.initialIndex, this.heroTags, }); @override State createState() => _AttachmentZoomViewState(); } class _AttachmentZoomViewState extends State { late final PageController _pageController = PageController(initialPage: widget.initialIndex ?? 0); void _updatePage() { setState(() { if (_isCompletedDownload) { setState(() => _isCompletedDownload = false); } }); } bool _isDownloading = false; bool _isCompletedDownload = false; double? _progressOfDownload = 0; Future _saveToAlbum(int idx) async { final sn = context.read(); final item = widget.data.elementAt(idx); final url = sn.getAttachmentUrl(item.rid); if (kIsWeb || Platform.isLinux) { await launchUrlString(url); return; } setState(() => _isDownloading = true); var extName = extension(item.name); if (extName.isEmpty) extName = '.png'; final imagePath = '${Directory.systemTemp.path}/${item.uuid}$extName'; await Dio().download( url, imagePath, onReceiveProgress: (count, total) { setState(() => _progressOfDownload = count / total); }, ); bool isSuccess = false; try { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!await Gal.hasAccess(toAlbum: true)) { if (!await Gal.requestAccess(toAlbum: true)) return; } await Gal.putImage(imagePath, album: 'Solar Network'); } else { await FileSaver.instance.saveFile( name: item.name, file: File(imagePath), ); } setState(() { isSuccess = true; _isDownloading = false; _isCompletedDownload = isSuccess; }); } catch (e) { if (!mounted) return; context.showErrorDialog(e); } if (!mounted) return; context.showSnackbar( (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) ? 'attachmentSaved'.tr() : 'attachmentSavedDesktop'.tr(), action: (!kIsWeb && (Platform.isIOS || Platform.isAndroid)) ? SnackBarAction( label: 'openInAlbum'.tr(), onPressed: () async => Gal.open(), ) : null, ); } @override void initState() { super.initState(); _pageController.addListener(_updatePage); Future.delayed(const Duration(milliseconds: 100), _updatePage); } @override void dispose() { _pageController.removeListener(_updatePage); _pageController.dispose(); super.dispose(); } Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withOpacity(0.75); @override Widget build(BuildContext context) { final sn = context.read(); final uuid = Uuid(); final metaTextStyle = GoogleFonts.roboto( fontSize: 12, color: _unFocusColor, height: 1, ); return DismissiblePage( onDismissed: () { Navigator.of(context).pop(); }, direction: DismissiblePageDismissDirection.down, backgroundColor: Colors.transparent, isFullScreen: true, child: Scaffold( body: Stack( children: [ Builder(builder: (context) { if (widget.data.length == 1) { final heroTag = widget.heroTags?.first ?? uuid.v4(); return Hero( tag: 'attachment-${widget.data.first.rid}-$heroTag', child: PhotoView( key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'), backgroundDecoration: BoxDecoration(color: Colors.transparent), imageProvider: UniversalImage.provider( sn.getAttachmentUrl(widget.data.first.rid), ), ), ); } return PhotoViewGallery.builder( pageController: _pageController, scrollPhysics: const BouncingScrollPhysics(), builder: (context, idx) { final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4(); return PhotoViewGalleryPageOptions( imageProvider: UniversalImage.provider( sn.getAttachmentUrl(widget.data.elementAt(idx).rid), ), heroAttributes: PhotoViewHeroAttributes( tag: 'attachment-${widget.data.first.rid}-$heroTag', ), ); }, itemCount: widget.data.length, loadingBuilder: (context, event) => Center( child: SizedBox( width: 20.0, height: 20.0, child: CircularProgressIndicator( value: event == null ? 0 : event.cumulativeBytesLoaded / (event.expectedTotalBytes ?? 1), ), ), ), backgroundDecoration: BoxDecoration(color: Colors.transparent), ); }), Align( alignment: Alignment.bottomCenter, child: IgnorePointer( child: Container( height: 300, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ Theme.of(context).colorScheme.surface, Colors.transparent, ], ), ), ), ), ), Positioned( left: 16, right: 16, bottom: 16 + MediaQuery.of(context).padding.bottom, child: Material( color: Colors.transparent, child: Builder(builder: (context) { final ud = context.read(); final item = widget.data.elementAt( widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0, ); final account = ud.getAccountFromCache(item.accountId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (item.accountId > 0) Row( children: [ IgnorePointer( child: AccountImage( content: account!.avatar, radius: 19, ), ), const Gap(8), Expanded( child: IgnorePointer( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'attachmentUploadBy'.tr(), style: Theme.of(context).textTheme.bodySmall, ), Text( account.nick, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ), if (widget.data.length > 1) IgnorePointer( child: Text( '${(_pageController.page?.round() ?? 0) + 1}/${widget.data.length}', style: GoogleFonts.robotoMono(fontSize: 13), ).padding(right: 8), ), InkWell( borderRadius: const BorderRadius.all(Radius.circular(16)), onTap: _isDownloading ? null : () => _saveToAlbum(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0), child: Container( padding: const EdgeInsets.all(6), child: !_isDownloading ? !_isCompletedDownload ? const Icon(Symbols.save_alt) : const Icon(Symbols.download_done) : SizedBox( width: 24, height: 24, child: CircularProgressIndicator( value: _progressOfDownload, strokeWidth: 3, ), ), ), ), ], ), const Gap(4), IgnorePointer( child: Text( item.alt, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w500, ), ), ), const Gap(2), IgnorePointer( child: Wrap( spacing: 6, children: [ if (item.metadata['exif'] == null) Text( '#${item.rid}', style: metaTextStyle, ), if (item.metadata['exif']?['Model'] != null) Text( 'attachmentShotOn'.tr(args: [ item.metadata['exif']?['Model'], ]), style: metaTextStyle, ).padding(right: 2), if (item.metadata['exif']?['ShutterSpeed'] != null) Text( item.metadata['exif']?['ShutterSpeed'], style: metaTextStyle, ).padding(right: 2), if (item.metadata['exif']?['ISO'] != null) Text( 'ISO${item.metadata['exif']?['ISO']}', style: metaTextStyle, ).padding(right: 2), if (item.metadata['exif']?['Aperture'] != null) Text( 'f/${item.metadata['exif']?['Aperture']}', style: metaTextStyle, ).padding(right: 2), if (item.metadata['exif']?['Megapixels'] != null && item.metadata['exif']?['Model'] != null) Text( '${item.metadata['exif']?['Megapixels']}MP', style: metaTextStyle, ) else Text( '${item.size} Bytes', style: metaTextStyle, ), if (item.metadata['width'] != null && item.metadata['height'] != null) Text( '${item.metadata['width']}x${item.metadata['height']}', style: metaTextStyle, ), if (item.metadata['ratio'] != null) Text( (item.metadata['ratio'] as num).toStringAsFixed(2), style: metaTextStyle, ), Text( item.mimetype, style: metaTextStyle, ), ], ), ), ], ); }), ), ), ], ), ), ); } }