Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to `any_image` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---
## [0.0.2] - 2026-04-26

### Added
- `MimeResolver` — resolves image format via HTTP HEAD request for extension-less URLs
- `AsyncSourceResolver` — interface for async resolvers
- `ResolverPipeline.resolveSync` — sync-only resolution path
- `AnyImage.withMimeSniffing` — named constructor with `MimeResolver` pre-configured
- `pipeline` param on `AnyImage` — allows custom resolver pipelines

---

## [0.0.1] - 2026-04-22
Expand Down
4 changes: 2 additions & 2 deletions example/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
avoid_print: false # Uncomment to disable the `avoid_print` rule
prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
9 changes: 9 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ class ExampleScreen extends StatelessWidget {
fit: BoxFit.cover,
errorWidget: Center(child: Icon(Icons.broken_image)),
),
_SectionLabel('Network — no extension (MIME sniffing)'),
AnyImage(
// source: 'https://avatars.githubusercontent.com/u/33640448?v=4',
source: 'https://skillicons.dev/icons?i=flutter',
width: double.infinity,
height: 200,
placeholder: Center(child: CircularProgressIndicator()),
errorWidget: Center(child: Icon(Icons.broken_image)),
),
],
),
),
Expand Down
5 changes: 4 additions & 1 deletion lib/any_image.dart
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
export 'src/model/source_type.dart';
export 'src/widget/any_image.dart';
export 'src/widget/any_image.dart';
export 'src/resolver/async_source_resolver.dart';
export 'src/resolver/mime_resolver.dart';
export 'src/resolver/resolver_pipeline.dart';
2 changes: 1 addition & 1 deletion lib/src/model/resolved_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'source_type.dart';

/// Represents an image source after it has been resolved
/// to a specific type.
///
///
/// This is the handoff object between the resolver layer
/// and the renderer layer
class ResolvedSource {
Expand Down
17 changes: 17 additions & 0 deletions lib/src/resolver/async_source_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import '../model/resolved_source.dart';

/// Contract for resolvers that require async operations
///
/// Use this interface when resolution cannot be determined
/// from the source string alone and requires an external
/// call such as an HTTP request.
///
/// Sync resolvers should implement [SourceResolver] instead
/// to avoid unnecessary async overhead in the pipeline
abstract interface class AsyncSourceResolver {
/// Attempts to resolve [source] into a [ResolvedSource]
///
/// Returns null if this resolver cannot classify the source,
/// allowing the pipeline to fall through to the next resolver
Future<ResolvedSource?> resolve(String source);
}
64 changes: 64 additions & 0 deletions lib/src/resolver/mime_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'package:http/http.dart' as http;
import '../model/resolved_source.dart';
import '../model/source_type.dart';
import 'async_source_resolver.dart';

/// Resolves image format by making an HTTP HEAD request
/// and reading the Content-Type header
///
/// Use this resolver for network URLs that do not contain
/// a file extension or other format signals
///
/// This resolver only handles network sources. Non-network
/// sources are returned as null immediately without any
/// HTTP request
///
/// Register it via [ResolverPipeline] as an optional async
/// resolver:
///
/// ```dart
/// ResolverPipeline(
/// resolvers: [PrefixResolver(), ExtensionResolver()],
/// asyncResolvers: [MimeResolver()],
/// )
/// ```
class MimeResolver implements AsyncSourceResolver {
const MimeResolver();

static const _mimeToFormat = {
'image/svg+xml': ImageFormat.svg,
'image/png': ImageFormat.raster,
'image/jpeg': ImageFormat.raster,
'image/webp': ImageFormat.raster,
'image/gif': ImageFormat.raster,
};

@override
Future<ResolvedSource?> resolve(String source) async {
// Only network sources can be resolved via MIME sniffing.
// Asset, file, and other source types are not applicable.
if (!source.startsWith('http://') && !source.startsWith('https://')) {
return null;
}

try {
final response = await http.head(Uri.parse(source));
final contentType = response.headers['content-type'];
Comment on lines +45 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The network request lacks a timeout and does not verify the HTTP status code. This can lead to the widget hanging in a loading state indefinitely if the server is unresponsive, or incorrectly resolving a source if the server returns an error page (e.g., 404) with a valid image Content-Type header. It is recommended to add a timeout and check for a 200 OK response.

Suggested change
final response = await http.head(Uri.parse(source));
final contentType = response.headers['content-type'];
final response = await http.head(Uri.parse(source)).timeout(
const Duration(seconds: 10),
);
if (response.statusCode != 200) return null;
final contentType = response.headers['content-type'];


if (contentType == null) return null;

final mimeType = contentType.split(';').first.trim().toLowerCase();
final format = _mimeToFormat[mimeType];

if (format == null) return null;

return ResolvedSource(
raw: source,
location: ImageLocation.network,
format: format,
);
} catch (_) {
return null;
}
}
}
58 changes: 51 additions & 7 deletions lib/src/resolver/resolver_pipeline.dart
Original file line number Diff line number Diff line change
@@ -1,20 +1,54 @@
import '../model/resolved_source.dart';
import '../model/source_type.dart';
import 'async_source_resolver.dart';
import 'source_resolver.dart';

/// Runs all resolvers and merges location and format
/// from the best available meta data
/// from the best available signals.
///
/// Does not stop at the first non-null result
/// Collects location and format from resolvers independently, then
/// combines them
/// Sync resolvers run first in priority order. Async resolvers
/// run only if location or format remain unresolved after the
/// sync pass. Falls back to [ImageLocation.network] and
/// [ImageFormat.raster] if no resolver produces a result.
class ResolverPipeline {
/// Synchronous resolvers, run in priority order.
final List<SourceResolver> resolvers;

const ResolverPipeline({required this.resolvers});
/// Async resolvers, run only when sync resolvers leave
/// location or format unresolved.
final List<AsyncSourceResolver> asyncResolvers;

/// Resolves [source] by merging results from all resolvers
ResolvedSource resolve(String source) {
const ResolverPipeline({
required this.resolvers,
this.asyncResolvers = const [],
});

/// Resolves [source] using sync resolvers only.
///
/// Use this when async resolution is not needed or not
/// available, such as during widget build without a
/// FutureBuilder.
ResolvedSource resolveSync(String source) {
ImageLocation? location;
ImageFormat? format;

for (final resolver in resolvers) {
final result = resolver.resolve(source);
if (result == null) continue;
location ??= result.location;
format ??= result.format;
}

return ResolvedSource(
raw: source,
location: location ?? ImageLocation.network,
format: format ?? ImageFormat.raster,
);
}

/// Resolves [source] using sync resolvers first, then async
/// resolvers if location or format remain unresolved.
Future<ResolvedSource> resolve(String source) async {
ImageLocation? location;
ImageFormat? format;

Expand All @@ -25,6 +59,16 @@ class ResolverPipeline {
format ??= result.format;
}

if (location == null || format == null) {
for (final resolver in asyncResolvers) {
final result = await resolver.resolve(source);
if (result == null) continue;
location ??= result.location;
format ??= result.format;
if (location != null && format != null) break;
}
}

return ResolvedSource(
raw: source,
location: location ?? ImageLocation.network,
Expand Down
103 changes: 81 additions & 22 deletions lib/src/widget/any_image.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,26 @@ import '../renderer/image_renderer.dart';
import '../renderer/network_raster_renderer.dart';
import '../renderer/network_svg_renderer.dart';
import '../resolver/extension_resolver.dart';
import '../resolver/mime_resolver.dart';
import '../resolver/prefix_resolver.dart';
import '../resolver/resolver_pipeline.dart';

/// A universal image widget that renders any image source
/// A universal image widget that renders any image source.
///
/// Accepts an opaque [source] string and automatically resolves
/// the correct renderer based on location and format signals
/// the correct renderer based on location and format signals.
///
/// Supports network URLs, asset paths, and SVG images without
/// requiring the caller to know the image type in advance
/// requiring the caller to know the image type in advance.
/// MIME sniffing is enabled by default for extension-less URLs
/// and can be disabled via [allowMimeSniff].
///
/// ```dart
/// AnyImage(source: 'https://example.com/image.png')
/// AnyImage(source: 'assets/icons/logo.svg')
/// ```
class AnyImage extends StatelessWidget {
/// The image source — a URL, asset path, or file path.
class AnyImage extends StatefulWidget {
/// The image source — a URL or asset path.
final String source;

/// The width of the rendered image.
Expand All @@ -48,9 +51,10 @@ class AnyImage extends StatelessWidget {
/// signals for the resolver to determine the correct type.
final ImageFormat? format;

static const _pipeline = ResolverPipeline(
resolvers: [PrefixResolver(), ExtensionResolver()],
);
/// Allows developer to opt-out of using Mime Sniffing
///
/// Mime Sniffing is enabled by default.
final bool allowMimeSniff;

static const _renderers = [
NetworkRasterRenderer(),
Expand All @@ -68,36 +72,91 @@ class AnyImage extends StatelessWidget {
this.placeholder,
this.errorWidget,
this.format,
this.allowMimeSniff = true,
});

@override
Widget build(BuildContext context) {
var resolved = _pipeline.resolve(source);
State<AnyImage> createState() => _AnyImageState();
}

class _AnyImageState extends State<AnyImage> {
late Future<ResolvedSource> _resolved;
late ResolverPipeline _pipeline;

@override
void initState() {
super.initState();

_pipeline = _buildPipeline();
_resolved = _resolve();
}

@override
void didUpdateWidget(AnyImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.allowMimeSniff != widget.allowMimeSniff) {
_pipeline = _buildPipeline();
}
if (oldWidget.source != widget.source ||
oldWidget.allowMimeSniff != widget.allowMimeSniff) {
_resolved = _resolve();
}
}
Comment on lines +95 to +104
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The didUpdateWidget implementation only triggers a re-resolution when the source changes. However, the widget's behavior also depends on the format override and the pipeline configuration. If either of these properties is updated by the parent widget while the source remains the same, the image will not reflect the changes. You should include these fields in the update check.

  @override
  void didUpdateWidget(AnyImage oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.source != widget.source ||
        oldWidget.format != widget.format ||
        oldWidget.pipeline != widget.pipeline) {
      _resolved = _resolve();
    }
  }


ResolverPipeline _buildPipeline() {
return ResolverPipeline(
resolvers: const [PrefixResolver(), ExtensionResolver()],
asyncResolvers: widget.allowMimeSniff ? const [MimeResolver()] : const [],
);
}

if (format != null) {
resolved = ResolvedSource(
raw: resolved.raw,
location: resolved.location,
format: format,
Future<ResolvedSource> _resolve() async {
if (widget.format != null) {
final sync = _pipeline.resolveSync(widget.source);
return ResolvedSource(
raw: sync.raw,
location: sync.location,
format: widget.format,
);
}
return _pipeline.resolve(widget.source);
}

final renderer = _renderers.cast<ImageRenderer?>().firstWhere(
Widget _render(ResolvedSource resolved) {
final renderer = AnyImage._renderers.cast<ImageRenderer?>().firstWhere(
(r) => r!.canRender(resolved),
orElse: () => null,
);

if (renderer == null) {
return errorWidget ?? const SizedBox.shrink();
return widget.errorWidget ?? const SizedBox.shrink();
}

return renderer.render(
resolved,
width: width,
height: height,
fit: fit,
placeholder: placeholder,
errorWidget: errorWidget,
width: widget.width,
height: widget.height,
fit: widget.fit,
placeholder: widget.placeholder,
errorWidget: widget.errorWidget,
);
}

@override
Widget build(BuildContext context) {
return FutureBuilder<ResolvedSource>(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using FutureBuilder without initialData introduces a mandatory one-frame delay (asynchrony) before the image is rendered, even for synchronous sources like local assets or URLs with extensions. This results in a visible flicker where the placeholder is shown briefly whenever the source changes.

To maintain the performance of the previous synchronous implementation, consider providing initialData by calling widget.pipeline.resolveSync(widget.source) (and applying the widget.format override). This ensures that if the source can be resolved immediately, it renders without delay.

future: _resolved,
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorWidget ?? const SizedBox.shrink();
}

if (!snapshot.hasData) {
return widget.placeholder ?? const SizedBox.shrink();
}

return _render(snapshot.data!);
},
);
}
}
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: any_image
description: "A universal Flutter image widget that renders any source"
version: 0.0.1
version: 0.0.2
homepage: https://github.com/Sameer330/any_image

environment:
Expand All @@ -12,6 +12,7 @@ dependencies:
sdk: flutter
cached_network_image: ^3.4.1
flutter_svg: ^2.2.4
http: ^1.6.0

dev_dependencies:
flutter_test:
Expand Down
Loading