From 91b2797fb9d9442f479c0355dd9034a85b76d0d6 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 13 Jan 2026 23:15:36 +0800 Subject: [PATCH] :alien: Authorized image load request :lipstick: Optimize image styling --- lib/widgets/content/image.dart | 178 +++++++++++++++++++++++++++++---- 1 file changed, 161 insertions(+), 17 deletions(-) diff --git a/lib/widgets/content/image.dart b/lib/widgets/content/image.dart index 954b63f9..bd17c54e 100644 --- a/lib/widgets/content/image.dart +++ b/lib/widgets/content/image.dart @@ -1,11 +1,16 @@ +import 'dart:math' as math; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/pods/config.dart'; +import 'package:island/pods/network.dart'; -class UniversalImage extends HookWidget { +class UniversalImage extends HookConsumerWidget { final String uri; final String? blurHash; final BoxFit fit; @@ -28,11 +33,19 @@ class UniversalImage extends HookWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final loaded = useState(false); final isCached = useState(null); final isSvgImage = isSvg || uri.toLowerCase().endsWith('.svg'); + final serverUrl = ref.watch(serverUrlProvider); + final token = ref.watch(tokenProvider); + + final Map? httpHeaders = + uri.startsWith(serverUrl) && token != null + ? {'Authorization': 'AtField ${token.token}'} + : null; + useEffect(() { DefaultCacheManager().getFileFromCache(uri).then((fileInfo) { isCached.value = fileInfo != null; @@ -73,6 +86,7 @@ class UniversalImage extends HookWidget { else if (isCached.value!) CachedNetworkImage( imageUrl: uri, + httpHeaders: httpHeaders, fit: fit, width: width, height: height, @@ -84,17 +98,18 @@ class UniversalImage extends HookWidget { width: width, height: height, ), - errorWidget: (context, url, error) => useFallbackImage - ? Image.asset( - 'assets/images/media-offline.jpg', - fit: BoxFit.cover, - key: Key('image-broke-$uri'), - ) - : SizedBox.shrink(), + errorWidget: (context, url, error) => CachedImageErrorWidget( + useFallbackImage: useFallbackImage, + uri: uri, + blurHash: blurHash, + error: error, + debug: true, + ), ) else CachedNetworkImage( imageUrl: uri, + httpHeaders: httpHeaders, fit: fit, width: width, height: height, @@ -123,13 +138,13 @@ class UniversalImage extends HookWidget { ), ); }, - errorWidget: (context, url, error) => useFallbackImage - ? Image.asset( - 'assets/images/media-offline.jpg', - fit: BoxFit.cover, - key: Key('image-broke-$uri'), - ) - : SizedBox.shrink(), + errorWidget: (context, url, error) => CachedImageErrorWidget( + useFallbackImage: useFallbackImage, + uri: uri, + blurHash: blurHash, + error: error, + debug: true, + ), ), ], ), @@ -137,6 +152,135 @@ class UniversalImage extends HookWidget { } } +class CachedImageErrorWidget extends StatelessWidget { + final bool useFallbackImage; + final String uri; + final String? blurHash; + final dynamic error; + final bool debug; + + const CachedImageErrorWidget({ + super.key, + required this.useFallbackImage, + required this.uri, + this.blurHash, + this.error, + this.debug = false, + }); + + int? _extractStatusCode(dynamic error) { + if (error == null) return null; + final errorString = error.toString(); + // Check for HttpException with status code + final httpExceptionRegex = RegExp(r'Invalid statusCode: (\d+)'); + final match = httpExceptionRegex.firstMatch(errorString); + if (match != null) { + return int.tryParse(match.group(1) ?? ''); + } + // Check if error has statusCode property (like DioError) + if (error.response?.statusCode != null) { + return error.response.statusCode; + } + return null; + } + + @override + Widget build(BuildContext context) { + if (debug && error != null) { + debugPrint('Image load error for $uri: $error'); + } + + if (!useFallbackImage) { + return SizedBox.shrink(); + } + + final statusCode = _extractStatusCode(error); + + return LayoutBuilder( + builder: (context, constraints) { + final minDimension = constraints.maxWidth < constraints.maxHeight + ? constraints.maxWidth + : constraints.maxHeight; + final iconSize = math.max( + minDimension * 0.3, + 28, + ); // 30% of the smaller dimension + final hasEnoughSpace = minDimension > 40; + + return Stack( + fit: StackFit.expand, + children: [ + if (blurHash != null) + BlurHash(hash: blurHash!) + else + Image.asset( + 'assets/images/media-offline.jpg', + fit: BoxFit.cover, + key: Key('-$uri'), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getErrorIcon(statusCode), + color: Colors.white, + size: iconSize * 0.5, + shadows: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + if (hasEnoughSpace && statusCode != null) ...[ + SizedBox(height: iconSize * 0.1), + Container( + padding: EdgeInsets.symmetric( + horizontal: iconSize * 0.15, + vertical: iconSize * 0.05, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(iconSize * 0.1), + ), + child: Text( + statusCode.toString(), + style: TextStyle( + color: Colors.white, + fontSize: iconSize * 0.15, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + ), + ], + ); + }, + ); + } + + IconData _getErrorIcon(int? statusCode) { + switch (statusCode) { + case 403: + case 401: + return Icons.lock_rounded; + case 404: + return Icons.broken_image_rounded; + case 500: + case 502: + case 503: + return Icons.error_rounded; + default: + return Icons.broken_image_rounded; + } + } +} + class AnimatedCircularProgressIndicator extends HookWidget { final double? value; final Color? color; @@ -172,4 +316,4 @@ class AnimatedCircularProgressIndicator extends HookWidget { backgroundColor: Colors.transparent, ); } -} \ No newline at end of file +}