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 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 726932c..c2d1586 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -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)), + ), ], ), ), 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/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 { 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/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; + } + } +} 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, diff --git a/lib/src/widget/any_image.dart b/lib/src/widget/any_image.dart index 25ddb42..0aa810a 100644 --- a/lib/src/widget/any_image.dart +++ b/lib/src/widget/any_image.dart @@ -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. @@ -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(), @@ -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 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.allowMimeSniff != widget.allowMimeSniff) { + _pipeline = _buildPipeline(); + } + if (oldWidget.source != widget.source || + oldWidget.allowMimeSniff != widget.allowMimeSniff) { + _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 _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().firstWhere( + 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!); + }, ); } } 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: 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); });