740 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			740 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:io';
 | 
						|
import 'dart:math' as math;
 | 
						|
import 'dart:ui';
 | 
						|
import 'package:dismissible_page/dismissible_page.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter_blurhash/flutter_blurhash.dart';
 | 
						|
import 'package:file_saver/file_saver.dart';
 | 
						|
import 'package:flutter/foundation.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:gal/gal.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/file.dart';
 | 
						|
import 'package:island/pods/config.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/content/cloud_files.dart';
 | 
						|
import 'package:island/widgets/content/file_info_sheet.dart';
 | 
						|
import 'package:island/widgets/content/sensitive.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:path/path.dart' show extension;
 | 
						|
import 'package:path_provider/path_provider.dart';
 | 
						|
import 'package:photo_view/photo_view.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
import 'package:uuid/uuid.dart';
 | 
						|
 | 
						|
class CloudFileList extends HookConsumerWidget {
 | 
						|
  final List<SnCloudFile> files;
 | 
						|
  final double maxHeight;
 | 
						|
  final double maxWidth;
 | 
						|
  final double? minWidth;
 | 
						|
  final bool disableZoomIn;
 | 
						|
  final bool disableConstraint;
 | 
						|
  final EdgeInsets? padding;
 | 
						|
  final bool isColumn;
 | 
						|
  const CloudFileList({
 | 
						|
    super.key,
 | 
						|
    required this.files,
 | 
						|
    this.maxHeight = 560,
 | 
						|
    this.maxWidth = double.infinity,
 | 
						|
    this.minWidth,
 | 
						|
    this.disableZoomIn = false,
 | 
						|
    this.disableConstraint = false,
 | 
						|
    this.padding,
 | 
						|
    this.isColumn = false,
 | 
						|
  });
 | 
						|
 | 
						|
  double calculateAspectRatio() {
 | 
						|
    double total = 0;
 | 
						|
    for (var ratio in files.map((e) => e.fileMeta?['ratio'] ?? 1)) {
 | 
						|
      if (ratio is double) total += ratio;
 | 
						|
      if (ratio is String) total += double.parse(ratio);
 | 
						|
    }
 | 
						|
    if (total == 0) return 1;
 | 
						|
    return total / files.length;
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final heroTags = useMemoized(
 | 
						|
      () => List.generate(
 | 
						|
        files.length,
 | 
						|
        (index) => 'cloud-files#${const Uuid().v4()}',
 | 
						|
      ),
 | 
						|
      [files],
 | 
						|
    );
 | 
						|
 | 
						|
    if (files.isEmpty) return const SizedBox.shrink();
 | 
						|
 | 
						|
    if (isColumn) {
 | 
						|
      final children = <Widget>[];
 | 
						|
      const maxFiles = 2;
 | 
						|
      final filesToShow = files.take(maxFiles).toList();
 | 
						|
 | 
						|
      for (var i = 0; i < filesToShow.length; i++) {
 | 
						|
        final file = filesToShow[i];
 | 
						|
        final isImage = file.mimeType?.startsWith('image') ?? false;
 | 
						|
        final isAudio = file.mimeType?.startsWith('audio') ?? false;
 | 
						|
        final widgetItem = ClipRRect(
 | 
						|
          borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
						|
          child: _CloudFileListEntry(
 | 
						|
            file: file,
 | 
						|
            heroTag: heroTags[i],
 | 
						|
            isImage: isImage,
 | 
						|
            disableZoomIn: disableZoomIn,
 | 
						|
            onTap: () {
 | 
						|
              if (!isImage) {
 | 
						|
                return;
 | 
						|
              }
 | 
						|
              if (!disableZoomIn) {
 | 
						|
                context.pushTransparentRoute(
 | 
						|
                  CloudFileZoomIn(item: file, heroTag: heroTags[i]),
 | 
						|
                  rootNavigator: true,
 | 
						|
                );
 | 
						|
              }
 | 
						|
            },
 | 
						|
          ),
 | 
						|
        );
 | 
						|
 | 
						|
        Widget item;
 | 
						|
        if (isAudio) {
 | 
						|
          item = SizedBox(height: 120, child: widgetItem);
 | 
						|
        } else {
 | 
						|
          item = AspectRatio(
 | 
						|
            aspectRatio: file.fileMeta?['ratio'] as double? ?? 1.0,
 | 
						|
            child: widgetItem,
 | 
						|
          );
 | 
						|
        }
 | 
						|
        children.add(item);
 | 
						|
        if (i < filesToShow.length - 1) {
 | 
						|
          children.add(const Gap(8));
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (files.length > maxFiles) {
 | 
						|
        children.add(const Gap(8));
 | 
						|
        children.add(
 | 
						|
          Text(
 | 
						|
            'filesListAdditional'.plural(files.length - filesToShow.length),
 | 
						|
            textAlign: TextAlign.center,
 | 
						|
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
 | 
						|
              color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      }
 | 
						|
 | 
						|
      return Padding(
 | 
						|
        padding: padding ?? EdgeInsets.zero,
 | 
						|
        child: Column(
 | 
						|
          mainAxisSize: MainAxisSize.min,
 | 
						|
          crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
          children: children,
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
    if (files.length == 1) {
 | 
						|
      final isImage = files.first.mimeType?.startsWith('image') ?? false;
 | 
						|
      final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
 | 
						|
      final widgetItem = ClipRRect(
 | 
						|
        borderRadius: const BorderRadius.all(Radius.circular(8)),
 | 
						|
        child: _CloudFileListEntry(
 | 
						|
          file: files.first,
 | 
						|
          heroTag: heroTags.first,
 | 
						|
          isImage: isImage,
 | 
						|
          disableZoomIn: disableZoomIn,
 | 
						|
          onTap: () {
 | 
						|
            if (!isImage) {
 | 
						|
              return;
 | 
						|
            }
 | 
						|
            if (!disableZoomIn) {
 | 
						|
              context.pushTransparentRoute(
 | 
						|
                CloudFileZoomIn(item: files.first, heroTag: heroTags.first),
 | 
						|
                rootNavigator: true,
 | 
						|
              );
 | 
						|
            }
 | 
						|
          },
 | 
						|
        ),
 | 
						|
      );
 | 
						|
      return Container(
 | 
						|
        padding: padding,
 | 
						|
        constraints: BoxConstraints(
 | 
						|
          maxHeight: disableConstraint ? double.infinity : maxHeight,
 | 
						|
          minWidth: minWidth ?? 0,
 | 
						|
          maxWidth: files.length == 1 ? maxWidth : double.infinity,
 | 
						|
        ),
 | 
						|
        height: isAudio ? 120 : null,
 | 
						|
        child:
 | 
						|
            isAudio
 | 
						|
                ? widgetItem
 | 
						|
                : AspectRatio(
 | 
						|
                  aspectRatio: calculateAspectRatio(),
 | 
						|
                  child: widgetItem,
 | 
						|
                ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    final allImages =
 | 
						|
        !files.any(
 | 
						|
          (e) => e.mimeType == null || !e.mimeType!.startsWith('image'),
 | 
						|
        );
 | 
						|
 | 
						|
    if (allImages) {
 | 
						|
      return ConstrainedBox(
 | 
						|
        constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
 | 
						|
        child: AspectRatio(
 | 
						|
          aspectRatio: calculateAspectRatio(),
 | 
						|
          child: Padding(
 | 
						|
            padding: padding ?? EdgeInsets.zero,
 | 
						|
            child: CarouselView(
 | 
						|
              itemSnapping: true,
 | 
						|
              itemExtent: math.min(
 | 
						|
                math.min(
 | 
						|
                  MediaQuery.of(context).size.width * 0.75,
 | 
						|
                  maxWidth * 0.75,
 | 
						|
                ),
 | 
						|
                640,
 | 
						|
              ),
 | 
						|
              shape: RoundedRectangleBorder(
 | 
						|
                borderRadius: const BorderRadius.all(Radius.circular(16)),
 | 
						|
              ),
 | 
						|
              children: [
 | 
						|
                for (var i = 0; i < files.length; i++)
 | 
						|
                  Stack(
 | 
						|
                    children: [
 | 
						|
                      _CloudFileListEntry(
 | 
						|
                        file: files[i],
 | 
						|
                        heroTag: heroTags[i],
 | 
						|
                        isImage:
 | 
						|
                            files[i].mimeType?.startsWith('image') ?? false,
 | 
						|
                        disableZoomIn: disableZoomIn,
 | 
						|
                      ),
 | 
						|
                      Positioned(
 | 
						|
                        bottom: 12,
 | 
						|
                        left: 16,
 | 
						|
                        child: Text('${i + 1}/${files.length}')
 | 
						|
                            .textColor(Colors.white)
 | 
						|
                            .textShadow(
 | 
						|
                              color: Colors.black54,
 | 
						|
                              offset: Offset(1, 1),
 | 
						|
                              blurRadius: 3,
 | 
						|
                            ),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
              ],
 | 
						|
              onTap: (i) {
 | 
						|
                if (!(files[i].mimeType?.startsWith('image') ?? false)) {
 | 
						|
                  return;
 | 
						|
                }
 | 
						|
                if (!disableZoomIn) {
 | 
						|
                  context.pushTransparentRoute(
 | 
						|
                    CloudFileZoomIn(item: files[i], heroTag: heroTags[i]),
 | 
						|
                    rootNavigator: true,
 | 
						|
                  );
 | 
						|
                }
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return ConstrainedBox(
 | 
						|
      constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
 | 
						|
      child: AspectRatio(
 | 
						|
        aspectRatio: calculateAspectRatio(),
 | 
						|
        child: ListView.separated(
 | 
						|
          scrollDirection: Axis.horizontal,
 | 
						|
          itemCount: files.length,
 | 
						|
          padding: padding,
 | 
						|
          itemBuilder: (context, index) {
 | 
						|
            return AspectRatio(
 | 
						|
              aspectRatio:
 | 
						|
                  files[index].fileMeta?['ratio'] is num
 | 
						|
                      ? files[index].fileMeta!['ratio'].toDouble()
 | 
						|
                      : 1.0,
 | 
						|
              child: Stack(
 | 
						|
                children: [
 | 
						|
                  ClipRRect(
 | 
						|
                    borderRadius: const BorderRadius.all(Radius.circular(16)),
 | 
						|
                    child: _CloudFileListEntry(
 | 
						|
                      file: files[index],
 | 
						|
                      heroTag: heroTags[index],
 | 
						|
                      isImage:
 | 
						|
                          files[index].mimeType?.startsWith('image') ?? false,
 | 
						|
                      disableZoomIn: disableZoomIn,
 | 
						|
                      onTap: () {
 | 
						|
                        if (!(files[index].mimeType?.startsWith('image') ??
 | 
						|
                            false)) {
 | 
						|
                          return;
 | 
						|
                        }
 | 
						|
                        if (!disableZoomIn) {
 | 
						|
                          context.pushTransparentRoute(
 | 
						|
                            CloudFileZoomIn(
 | 
						|
                              item: files[index],
 | 
						|
                              heroTag: heroTags[index],
 | 
						|
                            ),
 | 
						|
                            rootNavigator: true,
 | 
						|
                          );
 | 
						|
                        }
 | 
						|
                      },
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                  Positioned(
 | 
						|
                    bottom: 12,
 | 
						|
                    left: 16,
 | 
						|
                    child: Text('${index + 1}/${files.length}')
 | 
						|
                        .textColor(Colors.white)
 | 
						|
                        .textShadow(
 | 
						|
                          color: Colors.black54,
 | 
						|
                          offset: Offset(1, 1),
 | 
						|
                          blurRadius: 3,
 | 
						|
                        ),
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            );
 | 
						|
          },
 | 
						|
          separatorBuilder: (_, _) => const Gap(8),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class CloudFileZoomIn extends HookConsumerWidget {
 | 
						|
  final SnCloudFile item;
 | 
						|
  final String heroTag;
 | 
						|
  const CloudFileZoomIn({super.key, required this.item, required this.heroTag});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final serverUrl = ref.watch(serverUrlProvider);
 | 
						|
    final photoViewController = useMemoized(() => PhotoViewController(), []);
 | 
						|
    final rotation = useState(0);
 | 
						|
 | 
						|
    final showOriginal = useState(false);
 | 
						|
 | 
						|
    Future<void> saveToGallery() async {
 | 
						|
      try {
 | 
						|
        // Show loading indicator
 | 
						|
        showSnackBar('Saving image...');
 | 
						|
 | 
						|
        // Get the image URL
 | 
						|
        final client = ref.watch(apiClientProvider);
 | 
						|
 | 
						|
        // Create a temporary file to save the image
 | 
						|
        final tempDir = await getTemporaryDirectory();
 | 
						|
        var extName = extension(item.name).trim();
 | 
						|
        if (extName.isEmpty) {
 | 
						|
          extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
 | 
						|
        }
 | 
						|
        final filePath = '${tempDir.path}/${item.id}.$extName';
 | 
						|
 | 
						|
        await client.download(
 | 
						|
          '/drive/files/${item.id}',
 | 
						|
          filePath,
 | 
						|
          queryParameters: {'original': true},
 | 
						|
        );
 | 
						|
        if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
 | 
						|
          // Save to gallery
 | 
						|
          await Gal.putImage(filePath, album: 'Solar Network');
 | 
						|
          // Show success message
 | 
						|
          showSnackBar('Image saved to gallery');
 | 
						|
        } else {
 | 
						|
          await FileSaver.instance.saveFile(
 | 
						|
            name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
 | 
						|
            file: File(filePath),
 | 
						|
          );
 | 
						|
          showSnackBar('Image saved to $filePath');
 | 
						|
        }
 | 
						|
      } catch (e) {
 | 
						|
        showErrorAlert(e);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    void showInfoSheet() {
 | 
						|
      showModalBottomSheet(
 | 
						|
        useRootNavigator: true,
 | 
						|
        context: context,
 | 
						|
        isScrollControlled: true,
 | 
						|
        builder: (context) => FileInfoSheet(item: item),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    final shadow = [
 | 
						|
      Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
 | 
						|
    ];
 | 
						|
 | 
						|
    return DismissiblePage(
 | 
						|
      isFullScreen: true,
 | 
						|
      backgroundColor: Colors.transparent,
 | 
						|
      direction: DismissiblePageDismissDirection.down,
 | 
						|
      onDismissed: () {
 | 
						|
        Navigator.of(context).pop();
 | 
						|
      },
 | 
						|
      child: Stack(
 | 
						|
        children: [
 | 
						|
          Positioned.fill(
 | 
						|
            child: PhotoView(
 | 
						|
              backgroundDecoration: BoxDecoration(
 | 
						|
                color: Colors.black.withOpacity(0.9),
 | 
						|
              ),
 | 
						|
              controller: photoViewController,
 | 
						|
              heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
 | 
						|
              imageProvider: CloudImageWidget.provider(
 | 
						|
                fileId: item.id,
 | 
						|
                serverUrl: serverUrl,
 | 
						|
                original: showOriginal.value,
 | 
						|
              ),
 | 
						|
              // Apply rotation transformation
 | 
						|
              customSize: MediaQuery.of(context).size,
 | 
						|
              basePosition: Alignment.center,
 | 
						|
              filterQuality: FilterQuality.high,
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
          // Close button and save button
 | 
						|
          Positioned(
 | 
						|
            top: MediaQuery.of(context).padding.top + 16,
 | 
						|
            right: 16,
 | 
						|
            left: 16,
 | 
						|
            child: Row(
 | 
						|
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
						|
              children: [
 | 
						|
                Row(
 | 
						|
                  children: [
 | 
						|
                    if (!kIsWeb)
 | 
						|
                      IconButton(
 | 
						|
                        icon: Icon(
 | 
						|
                          Icons.save_alt,
 | 
						|
                          color: Colors.white,
 | 
						|
                          shadows: shadow,
 | 
						|
                        ),
 | 
						|
                        onPressed: () async {
 | 
						|
                          saveToGallery();
 | 
						|
                        },
 | 
						|
                      ),
 | 
						|
                    IconButton(
 | 
						|
                      onPressed: () {
 | 
						|
                        showOriginal.value = !showOriginal.value;
 | 
						|
                      },
 | 
						|
                      icon: Icon(
 | 
						|
                        showOriginal.value ? Symbols.hd : Symbols.sd,
 | 
						|
                        color: Colors.white,
 | 
						|
                        shadows: shadow,
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(Icons.close, color: Colors.white, shadows: shadow),
 | 
						|
                  onPressed: () => Navigator.of(context).pop(),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
          // Rotation controls
 | 
						|
          Positioned(
 | 
						|
            bottom: MediaQuery.of(context).padding.bottom + 16,
 | 
						|
            left: 16,
 | 
						|
            right: 16,
 | 
						|
            child: Row(
 | 
						|
              children: [
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(
 | 
						|
                    Icons.info_outline,
 | 
						|
                    color: Colors.white,
 | 
						|
                    shadows: shadow,
 | 
						|
                  ),
 | 
						|
                  onPressed: showInfoSheet,
 | 
						|
                ),
 | 
						|
                Spacer(),
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(
 | 
						|
                    Icons.remove,
 | 
						|
                    color: Colors.white,
 | 
						|
                    shadows: shadow,
 | 
						|
                  ),
 | 
						|
                  onPressed: () {
 | 
						|
                    photoViewController.scale =
 | 
						|
                        (photoViewController.scale ?? 1) - 0.05;
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
 | 
						|
                  onPressed: () {
 | 
						|
                    photoViewController.scale =
 | 
						|
                        (photoViewController.scale ?? 1) + 0.05;
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
                const Gap(8),
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(
 | 
						|
                    Icons.rotate_left,
 | 
						|
                    color: Colors.white,
 | 
						|
                    shadows: shadow,
 | 
						|
                  ),
 | 
						|
                  onPressed: () {
 | 
						|
                    rotation.value = (rotation.value - 1) % 4;
 | 
						|
                    photoViewController.rotation =
 | 
						|
                        rotation.value * -math.pi / 2;
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
                IconButton(
 | 
						|
                  icon: Icon(
 | 
						|
                    Icons.rotate_right,
 | 
						|
                    color: Colors.white,
 | 
						|
                    shadows: [
 | 
						|
                      Shadow(
 | 
						|
                        color: Colors.black54,
 | 
						|
                        blurRadius: 5.0,
 | 
						|
                        offset: Offset(1.0, 1.0),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                  onPressed: () {
 | 
						|
                    rotation.value = (rotation.value + 1) % 4;
 | 
						|
                    photoViewController.rotation =
 | 
						|
                        rotation.value * -math.pi / 2;
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _CloudFileListEntry extends HookConsumerWidget {
 | 
						|
  final SnCloudFile file;
 | 
						|
  final String heroTag;
 | 
						|
  final bool isImage;
 | 
						|
  final bool disableZoomIn;
 | 
						|
  final VoidCallback? onTap;
 | 
						|
 | 
						|
  const _CloudFileListEntry({
 | 
						|
    required this.file,
 | 
						|
    required this.heroTag,
 | 
						|
    required this.isImage,
 | 
						|
    required this.disableZoomIn,
 | 
						|
    this.onTap,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final dataSaving = ref.watch(
 | 
						|
      appSettingsNotifierProvider.select((s) => s.dataSavingMode),
 | 
						|
    );
 | 
						|
    final showMature = useState(false);
 | 
						|
    final showDataSaving = useState(!dataSaving);
 | 
						|
    final lockedByDS = dataSaving && !showDataSaving.value;
 | 
						|
    final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
 | 
						|
    final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
 | 
						|
    final hasRatio =
 | 
						|
        meta.containsKey('ratio') &&
 | 
						|
        (meta['ratio'] is num && (meta['ratio'] as num) != 0);
 | 
						|
    final ratio =
 | 
						|
        (meta['ratio'] is num && (meta['ratio'] as num) != 0)
 | 
						|
            ? (meta['ratio'] as num).toDouble()
 | 
						|
            : 1.0;
 | 
						|
 | 
						|
    final fit = hasRatio ? BoxFit.cover : BoxFit.contain;
 | 
						|
 | 
						|
    Widget bg = const SizedBox.shrink();
 | 
						|
    if (isImage) {
 | 
						|
      if (meta['blur'] is String) {
 | 
						|
        bg = BlurHash(hash: meta['blur'] as String);
 | 
						|
      } else if (!lockedByDS && !lockedByMature) {
 | 
						|
        bg = ImageFiltered(
 | 
						|
          imageFilter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
 | 
						|
          child: CloudFileWidget(
 | 
						|
            fit: BoxFit.cover,
 | 
						|
            item: file,
 | 
						|
            noBlurhash: true,
 | 
						|
            useInternalGate: false,
 | 
						|
          ),
 | 
						|
        );
 | 
						|
      } else {
 | 
						|
        bg = const ColoredBox(color: Colors.black26);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    final bool fullyUnlocked = !lockedByDS && !lockedByMature;
 | 
						|
    Widget fg =
 | 
						|
        fullyUnlocked
 | 
						|
            ? (isImage
 | 
						|
                ? CloudFileWidget(
 | 
						|
                  item: file,
 | 
						|
                  heroTag: heroTag,
 | 
						|
                  noBlurhash: true,
 | 
						|
                  fit: fit,
 | 
						|
                  useInternalGate: false,
 | 
						|
                )
 | 
						|
                : CloudFileWidget(
 | 
						|
                  item: file,
 | 
						|
                  heroTag: heroTag,
 | 
						|
                  fit: fit,
 | 
						|
                  useInternalGate: false,
 | 
						|
                ))
 | 
						|
            : AspectRatio(aspectRatio: ratio, child: const SizedBox.shrink());
 | 
						|
 | 
						|
    Widget overlays;
 | 
						|
    if (lockedByDS) {
 | 
						|
      overlays = _DataSavingOverlay();
 | 
						|
    } else if (file.sensitiveMarks.isNotEmpty) {
 | 
						|
      overlays = _SensitiveOverlay(
 | 
						|
        file: file,
 | 
						|
        isRevealed: showMature.value,
 | 
						|
        onHide: () => showMature.value = false,
 | 
						|
      );
 | 
						|
    } else {
 | 
						|
      overlays = const SizedBox.shrink();
 | 
						|
    }
 | 
						|
 | 
						|
    final content = Stack(
 | 
						|
      fit: StackFit.expand,
 | 
						|
      children: [if (isImage) Positioned.fill(child: bg), fg, overlays],
 | 
						|
    );
 | 
						|
 | 
						|
    return InkWell(
 | 
						|
      borderRadius: const BorderRadius.all(Radius.circular(16)),
 | 
						|
      onTap: () {
 | 
						|
        if (lockedByDS) {
 | 
						|
          showDataSaving.value = true;
 | 
						|
        } else if (lockedByMature) {
 | 
						|
          showMature.value = true;
 | 
						|
        } else {
 | 
						|
          onTap?.call();
 | 
						|
        }
 | 
						|
      },
 | 
						|
      child: content,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _SensitiveOverlay extends StatelessWidget {
 | 
						|
  final SnCloudFile file;
 | 
						|
  final VoidCallback? onHide;
 | 
						|
  final bool isRevealed;
 | 
						|
 | 
						|
  const _SensitiveOverlay({
 | 
						|
    required this.file,
 | 
						|
    this.onHide,
 | 
						|
    this.isRevealed = false,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    if (isRevealed) {
 | 
						|
      return Positioned(
 | 
						|
        top: 3,
 | 
						|
        left: 4,
 | 
						|
        child: IconButton(
 | 
						|
          iconSize: 16,
 | 
						|
          constraints: const BoxConstraints(),
 | 
						|
          icon: const Icon(
 | 
						|
            Icons.visibility_off,
 | 
						|
            color: Colors.white,
 | 
						|
            shadows: [
 | 
						|
              Shadow(
 | 
						|
                color: Colors.black,
 | 
						|
                blurRadius: 5.0,
 | 
						|
                offset: Offset(1.0, 1.0),
 | 
						|
              ),
 | 
						|
            ],
 | 
						|
          ),
 | 
						|
          tooltip: 'Blur content',
 | 
						|
          onPressed: onHide,
 | 
						|
        ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    return BackdropFilter(
 | 
						|
      filter: ImageFilter.blur(sigmaX: 64, sigmaY: 64),
 | 
						|
      child: Container(
 | 
						|
        color: Colors.transparent,
 | 
						|
        child: Center(
 | 
						|
          child: _OverlayCard(
 | 
						|
            icon: Icons.warning,
 | 
						|
            title: file.sensitiveMarks
 | 
						|
                .map((e) => SensitiveCategory.values[e].i18nKey.tr())
 | 
						|
                .join(' · '),
 | 
						|
            subtitle: 'Sensitive Content',
 | 
						|
            hint: 'Tap to Reveal',
 | 
						|
          ),
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _DataSavingOverlay extends StatelessWidget {
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return ColoredBox(
 | 
						|
      color: Colors.black38,
 | 
						|
      child: Center(
 | 
						|
        child: _OverlayCard(
 | 
						|
          icon: Symbols.image,
 | 
						|
          title: 'Data Saving Mode',
 | 
						|
          subtitle: '',
 | 
						|
          hint: 'Tap to Load',
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _OverlayCard extends StatelessWidget {
 | 
						|
  final IconData icon;
 | 
						|
  final String title;
 | 
						|
  final String subtitle;
 | 
						|
  final String hint;
 | 
						|
 | 
						|
  const _OverlayCard({
 | 
						|
    required this.icon,
 | 
						|
    required this.title,
 | 
						|
    required this.subtitle,
 | 
						|
    required this.hint,
 | 
						|
  });
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context) {
 | 
						|
    return Container(
 | 
						|
      margin: const EdgeInsets.all(12),
 | 
						|
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
 | 
						|
      decoration: BoxDecoration(
 | 
						|
        color: Colors.black54,
 | 
						|
        borderRadius: BorderRadius.circular(12),
 | 
						|
      ),
 | 
						|
      constraints: const BoxConstraints(maxWidth: 280),
 | 
						|
      child: Column(
 | 
						|
        mainAxisSize: MainAxisSize.min,
 | 
						|
        children: [
 | 
						|
          Icon(icon, color: Colors.white, size: 24),
 | 
						|
          const Gap(4),
 | 
						|
          Text(
 | 
						|
            title,
 | 
						|
            style: const TextStyle(
 | 
						|
              color: Colors.white,
 | 
						|
              fontWeight: FontWeight.w600,
 | 
						|
            ),
 | 
						|
            textAlign: TextAlign.center,
 | 
						|
          ),
 | 
						|
          Text(
 | 
						|
            subtitle,
 | 
						|
            style: const TextStyle(color: Colors.white, fontSize: 13),
 | 
						|
          ),
 | 
						|
          const Gap(4),
 | 
						|
          Text(hint, style: const TextStyle(color: Colors.white, fontSize: 11)),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |