From 59c88d9b6bd4a212be63c351885d0d96239efdc1 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Fri, 24 Apr 2026 17:48:17 +0530 Subject: [PATCH 1/9] feat: add AsyncSourceResolver interface and MimeResolver --- lib/src/resolver/mime_resolver.dart | 64 +++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 lib/src/resolver/mime_resolver.dart diff --git a/lib/src/resolver/mime_resolver.dart b/lib/src/resolver/mime_resolver.dart new file mode 100644 index 0000000..c5c9449 --- /dev/null +++ b/lib/src/resolver/mime_resolver.dart @@ -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 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']; + + 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; + } + } +} From 6eab3dd1085d8f0c20203b08c53d1f884fb5ecb4 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Fri, 24 Apr 2026 17:48:34 +0530 Subject: [PATCH 2/9] feat: add async resolver support to ResolverPipeline --- lib/src/resolver/resolver_pipeline.dart | 58 ++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/lib/src/resolver/resolver_pipeline.dart b/lib/src/resolver/resolver_pipeline.dart index 03ec86f..9dfd073 100644 --- a/lib/src/resolver/resolver_pipeline.dart +++ b/lib/src/resolver/resolver_pipeline.dart @@ -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 resolvers; - const ResolverPipeline({required this.resolvers}); + /// Async resolvers, run only when sync resolvers leave + /// location or format unresolved. + final List 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 resolve(String source) async { ImageLocation? location; ImageFormat? format; @@ -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, From 11b50fb40aa3320ab681b06d8d4aa7eecd682324 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Fri, 24 Apr 2026 17:49:15 +0530 Subject: [PATCH 3/9] feat: update AnyImage to support async pipeline and add withMimeSniffing constructor --- lib/src/widget/any_image.dart | 117 +++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 17 deletions(-) diff --git a/lib/src/widget/any_image.dart b/lib/src/widget/any_image.dart index 25ddb42..a5ea7e5 100644 --- a/lib/src/widget/any_image.dart +++ b/lib/src/widget/any_image.dart @@ -8,23 +8,28 @@ 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. +/// +/// By default uses sync resolution only. Pass [asyncResolvers] +/// such as [MimeResolver] to enable async resolution for +/// extension-less URLs. /// /// ```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. @@ -48,7 +53,13 @@ class AnyImage extends StatelessWidget { /// signals for the resolver to determine the correct type. final ImageFormat? format; - static const _pipeline = ResolverPipeline( + /// The resolver pipeline used to classify the source. + /// + /// Defaults to [PrefixResolver] and [ExtensionResolver]. + /// Override to add [MimeResolver] or custom resolvers. + final ResolverPipeline pipeline; + + static const _defaultPipeline = ResolverPipeline( resolvers: [PrefixResolver(), ExtensionResolver()], ); @@ -68,36 +79,108 @@ class AnyImage extends StatelessWidget { this.placeholder, this.errorWidget, this.format, + this.pipeline = _defaultPipeline, }); + /// Creates an [AnyImage] with MIME sniffing enabled. + /// + /// Use this when the source URL does not contain a file + /// extension or other format signals. Makes an HTTP HEAD + /// request to determine the image format from the + /// Content-Type header. + /// + /// Has no effect on asset sources — MIME sniffing only + /// applies to network URLs. + /// + /// ```dart + /// AnyImage.withMimeSniffing( + /// source: 'https://cdn.example.com/a8f3k', + /// ) + /// ``` + const AnyImage.withMimeSniffing({ + super.key, + required this.source, + this.width, + this.height, + this.fit, + this.placeholder, + this.errorWidget, + this.format, + }) : pipeline = const ResolverPipeline( + resolvers: [PrefixResolver(), ExtensionResolver()], + asyncResolvers: [MimeResolver()], + ); + @override - Widget build(BuildContext context) { - var resolved = _pipeline.resolve(source); + State createState() => _AnyImageState(); +} + +class _AnyImageState extends State { + late Future _resolved; + + @override + void initState() { + super.initState(); + _resolved = _resolve(); + } + + @override + void didUpdateWidget(AnyImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source) { + _resolved = _resolve(); + } + } - if (format != null) { + Future _resolve() async { + var resolved = await widget.pipeline.resolve(widget.source); + + if (widget.format != null) { resolved = ResolvedSource( raw: resolved.raw, location: resolved.location, - format: format, + format: widget.format, ); } - final renderer = _renderers.cast().firstWhere( + return resolved; + } + + Widget _render(ResolvedSource resolved) { + final renderer = AnyImage._renderers.cast().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( + 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!); + }, ); } } From 2a1d0b8a5c06f4e253f5e2106e6446b57a8e8220 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Fri, 24 Apr 2026 17:49:42 +0530 Subject: [PATCH 4/9] feat: add async source resolver and update resolver pipeline tests to use sync resolution --- lib/any_image.dart | 5 ++++- lib/src/resolver/async_source_resolver.dart | 17 +++++++++++++++++ test/resolver/resolver_pipeline_test.dart | 15 ++++++++------- 3 files changed, 29 insertions(+), 8 deletions(-) create mode 100644 lib/src/resolver/async_source_resolver.dart diff --git a/lib/any_image.dart b/lib/any_image.dart index 2f6c22a..ce73045 100644 --- a/lib/any_image.dart +++ b/lib/any_image.dart @@ -1,2 +1,5 @@ export 'src/model/source_type.dart'; -export 'src/widget/any_image.dart'; \ No newline at end of file +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'; diff --git a/lib/src/resolver/async_source_resolver.dart b/lib/src/resolver/async_source_resolver.dart new file mode 100644 index 0000000..6a2ffba --- /dev/null +++ b/lib/src/resolver/async_source_resolver.dart @@ -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 resolve(String source); +} diff --git a/test/resolver/resolver_pipeline_test.dart b/test/resolver/resolver_pipeline_test.dart index 6a8cd4c..1fbaf32 100644 --- a/test/resolver/resolver_pipeline_test.dart +++ b/test/resolver/resolver_pipeline_test.dart @@ -12,13 +12,13 @@ void main() { group('ResolverPipeline', () { group('network raster', () { test('resolves https png url correctly', () { - final result = pipeline.resolve('https://example.com/image.png'); + final result = pipeline.resolveSync('https://example.com/image.png'); expect(result.location, ImageLocation.network); expect(result.format, ImageFormat.raster); }); test('resolves https url with no extension to network raster', () { - final result = pipeline.resolve('https://example.com/image'); + final result = pipeline.resolveSync('https://example.com/image'); expect(result.location, ImageLocation.network); expect(result.format, ImageFormat.raster); }); @@ -26,13 +26,14 @@ void main() { group('network svg', () { test('resolves https svg url correctly', () { - final result = pipeline.resolve('https://example.com/logo.svg'); + final result = pipeline.resolveSync('https://example.com/logo.svg'); expect(result.location, ImageLocation.network); expect(result.format, ImageFormat.svg); }); test('resolves https svg url with query params correctly', () { - final result = pipeline.resolve('https://example.com/logo.svg?v=123'); + final result = + pipeline.resolveSync('https://example.com/logo.svg?v=123'); expect(result.location, ImageLocation.network); expect(result.format, ImageFormat.svg); }); @@ -40,7 +41,7 @@ void main() { group('asset raster', () { test('resolves asset png correctly', () { - final result = pipeline.resolve('assets/images/logo.png'); + final result = pipeline.resolveSync('assets/images/logo.png'); expect(result.location, ImageLocation.asset); expect(result.format, ImageFormat.raster); }); @@ -48,7 +49,7 @@ void main() { group('asset svg', () { test('resolves asset svg correctly', () { - final result = pipeline.resolve('assets/icons/logo.svg'); + final result = pipeline.resolveSync('assets/icons/logo.svg'); expect(result.location, ImageLocation.asset); expect(result.format, ImageFormat.svg); }); @@ -56,7 +57,7 @@ void main() { group('fallback', () { test('falls back to network raster for unrecognised source', () { - final result = pipeline.resolve('some-random-string'); + final result = pipeline.resolveSync('some-random-string'); expect(result.location, ImageLocation.network); expect(result.format, ImageFormat.raster); }); From c8212d85d3966f845b51336829255e3a8043201b Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Fri, 24 Apr 2026 17:50:04 +0530 Subject: [PATCH 5/9] chore: bump version to 0.0.2 --- pubspec.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ba7727a..ced5d31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: @@ -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: From 75ad02a06be693304006330fba7715951ed712ac Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Sat, 25 Apr 2026 19:00:26 +0530 Subject: [PATCH 6/9] feat: add support for MIME sniffing in AnyImage with new example --- example/lib/main.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/example/lib/main.dart b/example/lib/main.dart index 726932c..400e79c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -52,6 +52,14 @@ class ExampleScreen extends StatelessWidget { fit: BoxFit.cover, errorWidget: Center(child: Icon(Icons.broken_image)), ), + _SectionLabel('Network — no extension (MIME sniffing)'), + AnyImage.withMimeSniffing( + source: 'https://avatars.githubusercontent.com/u/33640448?v=4', + width: double.infinity, + height: 200, + placeholder: Center(child: CircularProgressIndicator()), + errorWidget: Center(child: Icon(Icons.broken_image)), + ), ], ), ), From 24723f88f854f53a5e864a556f37121d83e4109d Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Sat, 25 Apr 2026 19:00:36 +0530 Subject: [PATCH 7/9] feat: update changelog for version 0.0.2 with new MIME resolver features --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2491ec1..56f5712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 32661c62a61e2979400db681c4fc3835530c4e75 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Sat, 25 Apr 2026 19:33:04 +0530 Subject: [PATCH 8/9] style: remove unnecessary whitespace in ResolvedSource documentation --- lib/src/model/resolved_source.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/model/resolved_source.dart b/lib/src/model/resolved_source.dart index c0f8a92..d84f9cf 100644 --- a/lib/src/model/resolved_source.dart +++ b/lib/src/model/resolved_source.dart @@ -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 { From b046cc465025ccf95f37f6eda2f74ff68c0efc08 Mon Sep 17 00:00:00 2001 From: Sameer <330.sameer@gmail.com> Date: Sat, 2 May 2026 12:14:21 +0530 Subject: [PATCH 9/9] feat: enable MIME sniffing by default and allow opt-out in AnyImage widget --- example/analysis_options.yaml | 4 +- example/lib/main.dart | 5 ++- lib/src/widget/any_image.dart | 76 ++++++++++++----------------------- 3 files changed, 31 insertions(+), 54 deletions(-) diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index 0d29021..9288fa3 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -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 diff --git a/example/lib/main.dart b/example/lib/main.dart index 400e79c..c2d1586 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -53,8 +53,9 @@ class ExampleScreen extends StatelessWidget { errorWidget: Center(child: Icon(Icons.broken_image)), ), _SectionLabel('Network — no extension (MIME sniffing)'), - AnyImage.withMimeSniffing( - source: 'https://avatars.githubusercontent.com/u/33640448?v=4', + 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()), diff --git a/lib/src/widget/any_image.dart b/lib/src/widget/any_image.dart index a5ea7e5..0aa810a 100644 --- a/lib/src/widget/any_image.dart +++ b/lib/src/widget/any_image.dart @@ -19,10 +19,8 @@ import '../resolver/resolver_pipeline.dart'; /// /// Supports network URLs, asset paths, and SVG images without /// requiring the caller to know the image type in advance. -/// -/// By default uses sync resolution only. Pass [asyncResolvers] -/// such as [MimeResolver] to enable async resolution for -/// extension-less URLs. +/// MIME sniffing is enabled by default for extension-less URLs +/// and can be disabled via [allowMimeSniff]. /// /// ```dart /// AnyImage(source: 'https://example.com/image.png') @@ -53,15 +51,10 @@ class AnyImage extends StatefulWidget { /// signals for the resolver to determine the correct type. final ImageFormat? format; - /// The resolver pipeline used to classify the source. + /// Allows developer to opt-out of using Mime Sniffing /// - /// Defaults to [PrefixResolver] and [ExtensionResolver]. - /// Override to add [MimeResolver] or custom resolvers. - final ResolverPipeline pipeline; - - static const _defaultPipeline = ResolverPipeline( - resolvers: [PrefixResolver(), ExtensionResolver()], - ); + /// Mime Sniffing is enabled by default. + final bool allowMimeSniff; static const _renderers = [ NetworkRasterRenderer(), @@ -79,71 +72,54 @@ class AnyImage extends StatefulWidget { this.placeholder, this.errorWidget, this.format, - this.pipeline = _defaultPipeline, + this.allowMimeSniff = true, }); - /// Creates an [AnyImage] with MIME sniffing enabled. - /// - /// Use this when the source URL does not contain a file - /// extension or other format signals. Makes an HTTP HEAD - /// request to determine the image format from the - /// Content-Type header. - /// - /// Has no effect on asset sources — MIME sniffing only - /// applies to network URLs. - /// - /// ```dart - /// AnyImage.withMimeSniffing( - /// source: 'https://cdn.example.com/a8f3k', - /// ) - /// ``` - const AnyImage.withMimeSniffing({ - super.key, - required this.source, - this.width, - this.height, - this.fit, - this.placeholder, - this.errorWidget, - this.format, - }) : pipeline = const ResolverPipeline( - resolvers: [PrefixResolver(), ExtensionResolver()], - asyncResolvers: [MimeResolver()], - ); - @override State createState() => _AnyImageState(); } class _AnyImageState extends State { late Future _resolved; + late ResolverPipeline _pipeline; @override void initState() { super.initState(); + + _pipeline = _buildPipeline(); _resolved = _resolve(); } @override void didUpdateWidget(AnyImage oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.source != widget.source) { + if (oldWidget.allowMimeSniff != widget.allowMimeSniff) { + _pipeline = _buildPipeline(); + } + if (oldWidget.source != widget.source || + oldWidget.allowMimeSniff != widget.allowMimeSniff) { _resolved = _resolve(); } } - Future _resolve() async { - var resolved = await widget.pipeline.resolve(widget.source); + ResolverPipeline _buildPipeline() { + return ResolverPipeline( + resolvers: const [PrefixResolver(), ExtensionResolver()], + asyncResolvers: widget.allowMimeSniff ? const [MimeResolver()] : const [], + ); + } + Future _resolve() async { if (widget.format != null) { - resolved = ResolvedSource( - raw: resolved.raw, - location: resolved.location, + final sync = _pipeline.resolveSync(widget.source); + return ResolvedSource( + raw: sync.raw, + location: sync.location, format: widget.format, ); } - - return resolved; + return _pipeline.resolve(widget.source); } Widget _render(ResolvedSource resolved) {