feat(ui): support authenticated image loading with cache manager
Add Riverpod-aware image header and cache manager support for network images used in avatar and markdown widgets. Convert AvatarImage to a ConsumerWidget and read a self-signed cache manager and HTTP headers from Riverpod so CachedNetworkImage can send auth/custom headers and use the provided cache manager. In Markdown image builder, obtain headers and cache manager from a ProviderContainer (via ProviderScope.containerOf) to enable the same authenticated loading in non-consumer contexts. Introduce image_header_utils.dart to centralize building Authorization and custom headers from auth/api providers, with helpers for Ref, WidgetRef, and ProviderContainer. Add dependency adjustments in pubspec.lock for flutter_cache_manager and http marked as direct main. These changes ensure protected images (self-signed or auth-required) load correctly across the app and reuse the configured cache manager.
This commit is contained in:
47
lib/core/network/image_header_utils.dart
Normal file
47
lib/core/network/image_header_utils.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:conduit/core/providers/app_providers.dart';
|
||||||
|
import 'package:conduit/features/auth/providers/unified_auth_providers.dart';
|
||||||
|
|
||||||
|
/// Builds HTTP headers for protected image requests.
|
||||||
|
///
|
||||||
|
/// Includes Authorization (Bearer token or API key) and any server-configured
|
||||||
|
/// custom headers. Returns `null` if no headers are needed.
|
||||||
|
Map<String, String>? buildImageHeadersFromRef(Ref ref) {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final token = ref.read(authTokenProvider3);
|
||||||
|
return _build(api, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? buildImageHeadersFromWidgetRef(WidgetRef ref) {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final token = ref.read(authTokenProvider3);
|
||||||
|
return _build(api, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as [buildImageHeadersFromRef] but using a [ProviderContainer], useful
|
||||||
|
/// when you don't have a `Ref` (e.g., in non-Consumer widgets/utilities).
|
||||||
|
Map<String, String>? buildImageHeadersFromContainer(
|
||||||
|
ProviderContainer container,
|
||||||
|
) {
|
||||||
|
final api = container.read(apiServiceProvider);
|
||||||
|
final token = container.read(authTokenProvider3);
|
||||||
|
return _build(api, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _build(dynamic api, String? token) {
|
||||||
|
final headers = <String, String>{};
|
||||||
|
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer $token';
|
||||||
|
} else if (api?.serverConfig.apiKey != null &&
|
||||||
|
api!.serverConfig.apiKey!.isNotEmpty) {
|
||||||
|
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
||||||
|
headers.addAll(api.serverConfig.customHeaders);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers.isEmpty ? null : headers;
|
||||||
|
}
|
||||||
66
lib/core/network/self_signed_image_cache_manager.dart
Normal file
66
lib/core/network/self_signed_image_cache_manager.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/io_client.dart';
|
||||||
|
|
||||||
|
import '../models/server_config.dart';
|
||||||
|
import '../providers/app_providers.dart';
|
||||||
|
|
||||||
|
/// Returns a CacheManager that accepts a self-signed certificate for the
|
||||||
|
/// currently active server's host/port. Returns null when not needed.
|
||||||
|
///
|
||||||
|
/// Notes
|
||||||
|
/// - Scoped to the configured host and (optionally) port only.
|
||||||
|
/// - Not available on web (browsers enforce TLS validation).
|
||||||
|
final selfSignedImageCacheManagerProvider = Provider<CacheManager?>((ref) {
|
||||||
|
final active = ref.watch(activeServerProvider);
|
||||||
|
|
||||||
|
return active.maybeWhen(
|
||||||
|
data: (server) {
|
||||||
|
if (server == null) return null;
|
||||||
|
return _buildForServer(server);
|
||||||
|
},
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CacheManager? _buildForServer(ServerConfig server) {
|
||||||
|
if (kIsWeb) return null;
|
||||||
|
if (!server.allowSelfSignedCertificates) return null;
|
||||||
|
|
||||||
|
final uri = _parseUri(server.url);
|
||||||
|
if (uri == null) return null;
|
||||||
|
|
||||||
|
// Configure a HttpClient that accepts only this host (+ optional port).
|
||||||
|
final client = HttpClient();
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
final port = uri.hasPort ? uri.port : null;
|
||||||
|
|
||||||
|
client.badCertificateCallback =
|
||||||
|
(X509Certificate cert, String requestHost, int requestPort) {
|
||||||
|
if (requestHost.toLowerCase() != host) return false;
|
||||||
|
if (port == null) return true; // Any port on this host
|
||||||
|
return requestPort == port; // Exact host+port only
|
||||||
|
};
|
||||||
|
|
||||||
|
final ioClient = IOClient(client);
|
||||||
|
final fileService = HttpFileService(httpClient: ioClient);
|
||||||
|
|
||||||
|
// Use a stable key per host/port to share cache across widgets.
|
||||||
|
final key = 'conduit-selfsigned-$host:${port ?? 0}';
|
||||||
|
return CacheManager(Config(key, fileService: fileService));
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? _parseUri(String url) {
|
||||||
|
final trimmed = url.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
Uri? parsed = Uri.tryParse(trimmed);
|
||||||
|
if (parsed == null) return null;
|
||||||
|
if (!parsed.hasScheme) {
|
||||||
|
parsed =
|
||||||
|
Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import 'package:conduit/l10n/app_localizations.dart';
|
|||||||
import '../../../core/providers/app_providers.dart';
|
import '../../../core/providers/app_providers.dart';
|
||||||
import '../../auth/providers/unified_auth_providers.dart';
|
import '../../auth/providers/unified_auth_providers.dart';
|
||||||
import '../../../core/utils/debug_logger.dart';
|
import '../../../core/utils/debug_logger.dart';
|
||||||
|
import '../../../core/network/self_signed_image_cache_manager.dart';
|
||||||
|
import '../../../core/network/image_header_utils.dart';
|
||||||
|
|
||||||
// Simple global cache to prevent reloading
|
// Simple global cache to prevent reloading
|
||||||
final _globalImageCache = <String, String>{};
|
final _globalImageCache = <String, String>{};
|
||||||
@@ -414,29 +416,15 @@ class _EnhancedImageAttachmentState
|
|||||||
|
|
||||||
Widget _buildNetworkImage() {
|
Widget _buildNetworkImage() {
|
||||||
// Get authentication headers if available
|
// Get authentication headers if available
|
||||||
final api = ref.read(apiServiceProvider);
|
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||||
final authToken = ref.read(authTokenProvider3);
|
|
||||||
final headers = <String, String>{};
|
|
||||||
|
|
||||||
// Add auth token from unified auth provider
|
|
||||||
if (authToken != null && authToken.isNotEmpty) {
|
|
||||||
headers['Authorization'] = 'Bearer $authToken';
|
|
||||||
} else if (api?.serverConfig.apiKey != null &&
|
|
||||||
api!.serverConfig.apiKey!.isNotEmpty) {
|
|
||||||
// Fallback to API key from server config
|
|
||||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any custom headers from server config
|
|
||||||
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
|
||||||
headers.addAll(api.serverConfig.customHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||||
final imageWidget = CachedNetworkImage(
|
final imageWidget = CachedNetworkImage(
|
||||||
key: ValueKey('image_${widget.attachmentId}'),
|
key: ValueKey('image_${widget.attachmentId}'),
|
||||||
imageUrl: _cachedImageData!,
|
imageUrl: _cachedImageData!,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
httpHeaders: headers.isNotEmpty ? headers : null,
|
cacheManager: cacheManager,
|
||||||
|
httpHeaders: headers,
|
||||||
fadeInDuration: widget.disableAnimation
|
fadeInDuration: widget.disableAnimation
|
||||||
? Duration.zero
|
? Duration.zero
|
||||||
: const Duration(milliseconds: 200),
|
: const Duration(milliseconds: 200),
|
||||||
@@ -559,28 +547,14 @@ class FullScreenImageViewer extends ConsumerWidget {
|
|||||||
|
|
||||||
if (imageData.startsWith('http')) {
|
if (imageData.startsWith('http')) {
|
||||||
// Get authentication headers if available
|
// Get authentication headers if available
|
||||||
final api = ref.read(apiServiceProvider);
|
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||||
final authToken = ref.read(authTokenProvider3);
|
|
||||||
final headers = <String, String>{};
|
|
||||||
|
|
||||||
// Add auth token from unified auth provider
|
|
||||||
if (authToken != null && authToken.isNotEmpty) {
|
|
||||||
headers['Authorization'] = 'Bearer $authToken';
|
|
||||||
} else if (api?.serverConfig.apiKey != null &&
|
|
||||||
api!.serverConfig.apiKey!.isNotEmpty) {
|
|
||||||
// Fallback to API key from server config
|
|
||||||
headers['Authorization'] = 'Bearer ${api.serverConfig.apiKey}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any custom headers from server config
|
|
||||||
if (api != null && api.serverConfig.customHeaders.isNotEmpty) {
|
|
||||||
headers.addAll(api.serverConfig.customHeaders);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||||
imageWidget = CachedNetworkImage(
|
imageWidget = CachedNetworkImage(
|
||||||
imageUrl: imageData,
|
imageUrl: imageData,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
httpHeaders: headers.isNotEmpty ? headers : null,
|
cacheManager: cacheManager,
|
||||||
|
httpHeaders: headers,
|
||||||
placeholder: (context, url) => Center(
|
placeholder: (context, url) => Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: context.conduitTheme.buttonPrimary,
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:flutter_math_fork/flutter_math.dart';
|
import 'package:flutter_math_fork/flutter_math.dart';
|
||||||
import 'package:markdown/markdown.dart' as md;
|
import 'package:markdown/markdown.dart' as md;
|
||||||
@@ -16,6 +17,8 @@ import 'package:conduit/l10n/app_localizations.dart';
|
|||||||
import '../../theme/color_tokens.dart';
|
import '../../theme/color_tokens.dart';
|
||||||
import '../../theme/theme_extensions.dart';
|
import '../../theme/theme_extensions.dart';
|
||||||
import 'code_block_header.dart';
|
import 'code_block_header.dart';
|
||||||
|
import 'package:conduit/core/network/self_signed_image_cache_manager.dart';
|
||||||
|
import 'package:conduit/core/network/image_header_utils.dart';
|
||||||
|
|
||||||
typedef MarkdownLinkTapCallback = void Function(String url, String title);
|
typedef MarkdownLinkTapCallback = void Function(String url, String title);
|
||||||
|
|
||||||
@@ -413,8 +416,15 @@ class _ImageBuilder extends MarkdownElementBuilder {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
ConduitThemeExtension theme,
|
ConduitThemeExtension theme,
|
||||||
) {
|
) {
|
||||||
|
// Read headers and optional self-signed cache manager from Riverpod
|
||||||
|
final container = ProviderScope.containerOf(context, listen: false);
|
||||||
|
final headers = buildImageHeadersFromContainer(container);
|
||||||
|
final cacheManager = container.read(selfSignedImageCacheManagerProvider);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
|
cacheManager: cacheManager,
|
||||||
|
httpHeaders: headers,
|
||||||
placeholder: (context, _) => Container(
|
placeholder: (context, _) => Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../services/brand_service.dart';
|
import '../services/brand_service.dart';
|
||||||
import '../theme/theme_extensions.dart';
|
import '../theme/theme_extensions.dart';
|
||||||
|
import 'package:conduit/core/network/self_signed_image_cache_manager.dart';
|
||||||
|
import 'package:conduit/core/network/image_header_utils.dart';
|
||||||
|
|
||||||
typedef AvatarWidgetBuilder =
|
typedef AvatarWidgetBuilder =
|
||||||
Widget Function(BuildContext context, double size);
|
Widget Function(BuildContext context, double size);
|
||||||
|
|
||||||
class AvatarImage extends StatelessWidget {
|
class AvatarImage extends ConsumerWidget {
|
||||||
final double size;
|
final double size;
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final BorderRadius? borderRadius;
|
final BorderRadius? borderRadius;
|
||||||
@@ -29,7 +32,7 @@ class AvatarImage extends StatelessWidget {
|
|||||||
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(size / 2);
|
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(size / 2);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final url = imageUrl?.trim();
|
final url = imageUrl?.trim();
|
||||||
if (url == null || url.isEmpty) {
|
if (url == null || url.isEmpty) {
|
||||||
return fallbackBuilder(context, size);
|
return fallbackBuilder(context, size);
|
||||||
@@ -53,6 +56,11 @@ class AvatarImage extends StatelessWidget {
|
|||||||
return fallbackBuilder(context, size);
|
return fallbackBuilder(context, size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build auth/custom headers when loading from network
|
||||||
|
final headers = buildImageHeadersFromWidgetRef(ref);
|
||||||
|
|
||||||
|
final cacheManager = ref.read(selfSignedImageCacheManagerProvider);
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: _radius,
|
borderRadius: _radius,
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
@@ -60,6 +68,8 @@ class AvatarImage extends StatelessWidget {
|
|||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
cacheManager: cacheManager,
|
||||||
|
httpHeaders: headers,
|
||||||
placeholder: (context, _) =>
|
placeholder: (context, _) =>
|
||||||
(placeholderBuilder ?? _defaultPlaceholder)(context, size),
|
(placeholderBuilder ?? _defaultPlaceholder)(context, size),
|
||||||
errorWidget: (context, url, error) => fallbackBuilder(context, size),
|
errorWidget: (context, url, error) => fallbackBuilder(context, size),
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
version: "4.5.2"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
@@ -750,7 +750,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ dependencies:
|
|||||||
riverpod_annotation: ^3.0.0
|
riverpod_annotation: ^3.0.0
|
||||||
flutter_local_notifications: ^19.4.2
|
flutter_local_notifications: ^19.4.2
|
||||||
connectivity_plus: ^7.0.0
|
connectivity_plus: ^7.0.0
|
||||||
|
flutter_cache_manager: ^3.4.1
|
||||||
|
http: ^1.5.0
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user