diff --git a/packages/flutter_svg/example/android/app/build.gradle b/packages/flutter_svg/example/android/app/build.gradle index fc7c0763..5b0d32e8 100644 --- a/packages/flutter_svg/example/android/app/build.gradle +++ b/packages/flutter_svg/example/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,12 +22,23 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - android { - compileSdkVersion 31 + namespace = 'com.example.example' + compileSdkVersion 34 + // Configure only for each module that uses Java 8 + // language features (either in its source code or + // through dependencies). + compileOptions { + // Flag to enable support for the new language APIs + coreLibraryDesugaringEnabled true + // Sets Java compatibility to Java 8 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + // For Kotlin projects + kotlinOptions { + jvmTarget = "1.8" + } sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -35,8 +47,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" - minSdkVersion 16 - targetSdkVersion 30 + minSdkVersion flutter.minSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName } @@ -55,5 +66,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + // For AGP 7.4+ + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' } diff --git a/packages/flutter_svg/example/android/build.gradle b/packages/flutter_svg/example/android/build.gradle index e688f0a6..8f31e8ca 100644 --- a/packages/flutter_svg/example/android/build.gradle +++ b/packages/flutter_svg/example/android/build.gradle @@ -1,22 +1,7 @@ -buildscript { - // Do not copy this. It's strictly because this application does not care - // about Kotlin version and wants to always do its best to work. - ext.kotlin_version = '+' - repositories { - google() - jcenter() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() - jcenter() + mavenCentral() } } @@ -28,6 +13,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir -} +} \ No newline at end of file diff --git a/packages/flutter_svg/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/flutter_svg/example/android/gradle/wrapper/gradle-wrapper.properties index 6b665338..a35eb1fa 100644 --- a/packages/flutter_svg/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/flutter_svg/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip diff --git a/packages/flutter_svg/example/android/settings.gradle b/packages/flutter_svg/example/android/settings.gradle index 44e62bcf..6bb0456e 100644 --- a/packages/flutter_svg/example/android/settings.gradle +++ b/packages/flutter_svg/example/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.5.2" apply false + id "org.jetbrains.kotlin.android" version "1.9.23" apply false +} + +include ":app" \ No newline at end of file diff --git a/packages/flutter_svg/example/assets/invalid.svg b/packages/flutter_svg/example/assets/invalid.svg new file mode 100644 index 00000000..20bd5452 --- /dev/null +++ b/packages/flutter_svg/example/assets/invalid.svg @@ -0,0 +1 @@ +< width="69" height="69" viewBox="0 0 69 69"> diff --git a/packages/flutter_svg/example/lib/grid.dart b/packages/flutter_svg/example/lib/grid.dart index 26e40be5..b7b6a5a2 100644 --- a/packages/flutter_svg/example/lib/grid.dart +++ b/packages/flutter_svg/example/lib/grid.dart @@ -1,8 +1,11 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; const List _assetNames = [ - // 'assets/notfound.svg', // uncomment to test an asset that doesn't exist. + 'assets/invalid.svg', + 'assets/notfound.svg', // uncomment to test an asset that doesn't exist. 'assets/flutter_logo.svg', 'assets/dart.svg', 'assets/simple/clip_path_3.svg', @@ -35,7 +38,7 @@ const List _assetNames = [ ]; /// Assets treated as "icons" - using a color filter to render differently. -const List iconNames = [ +const List _iconNames = [ 'assets/deborah_ufw/new-action-expander.svg', 'assets/deborah_ufw/new-camera.svg', 'assets/deborah_ufw/new-gif-button.svg', @@ -49,12 +52,27 @@ const List iconNames = [ ]; /// Assets to test network access. -const List uriNames = [ +const List _uriNames = [ 'http://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg', 'https://dev.w3.org/SVG/tools/svgweb/samples/svg-files/410.svg', 'https://upload.wikimedia.org/wikipedia/commons/b/b4/Chess_ndd45.svg', ]; +const List _uriFailedNames = [ + 'an error image url.svg', // invalid url. + 'https: /sadf.svg', // invalid url. + 'http://www.google.com/404', // 404 url. + 'https://picsum.photos/200', // wrong format image url. +]; + +const List _stringNames = [ + ''' ''', // Shows an example of an SVG image that will fetch a raster image from a URL. + ''' ''', // valid svg + '''''', // empty svg. + 'sdf sdf ', // invalid svg. + '', // empty string. +]; + void main() { runApp(_MyApp()); } @@ -81,58 +99,10 @@ class _MyHomePage extends StatefulWidget { } class _MyHomePageState extends State<_MyHomePage> { - final List _painters = []; - late double _dimension; - - @override - void initState() { - super.initState(); - _dimension = 203.0; - for (String assetName in _assetNames) { - _painters.add( - SvgPicture.asset(assetName), - ); - } - - for (int i = 0; i < iconNames.length; i++) { - _painters.add( - Directionality( - textDirection: TextDirection.ltr, - child: SvgPicture.asset( - iconNames[i], - colorFilter: ColorFilter.mode( - Colors.blueGrey[(i + 1) * 100] ?? Colors.blueGrey, - BlendMode.srcIn, - ), - matchTextDirection: true, - ), - ), - ); - } - - for (String uriName in uriNames) { - _painters.add( - SvgPicture.network( - uriName, - placeholderBuilder: (BuildContext context) => Container( - padding: const EdgeInsets.all(30.0), - child: const CircularProgressIndicator(), - ), - ), - ); - } - // Shows an example of an SVG image that will fetch a raster image from a URL. - _painters.add(SvgPicture.string(''' - -''')); - } + double _dimension = 60; @override Widget build(BuildContext context) { - if (_dimension > MediaQuery.of(context).size.width - 10.0) { - _dimension = MediaQuery.of(context).size.width - 10.0; - } return Scaffold( appBar: AppBar( title: Text(widget.title), @@ -143,7 +113,7 @@ class _MyHomePageState extends State<_MyHomePage> { max: MediaQuery.of(context).size.width - 10.0, value: _dimension, onChanged: (double val) { - setState(() => _dimension = val); + setState(() => _dimension = min(MediaQuery.of(context).size.width - 10.0, val)); }, ), Expanded( @@ -153,7 +123,86 @@ class _MyHomePageState extends State<_MyHomePage> { padding: const EdgeInsets.all(4.0), mainAxisSpacing: 4.0, crossAxisSpacing: 4.0, - children: _painters.toList(), + children: [ + ..._assetNames.map( + (e) => SvgPicture.asset( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.brown, + width: 10, + height: 10, + ), + ), + ), + ..._iconNames.map( + (e) => Directionality( + textDirection: TextDirection.ltr, + child: SvgPicture.asset( + e, + colorFilter: ColorFilter.mode( + Colors.blueGrey[(_iconNames.indexOf(e) + 1) * 100] ?? Colors.blueGrey, + BlendMode.srcIn, + ), + matchTextDirection: true, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.yellow, + width: 10, + height: 10, + ), + ), + ), + ), + ..._uriNames.map( + (e) => SvgPicture.network( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.red, + width: 10, + height: 10, + ), + ), + ), + ..._uriFailedNames.map( + (e) => SvgPicture.network( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.deepPurple, + width: 10, + height: 10, + ), + ), + ), + ..._stringNames.map( + (e) => SvgPicture.string( + e, + placeholderBuilder: (BuildContext context) => Container( + padding: const EdgeInsets.all(30.0), + child: const CircularProgressIndicator(), + ), + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.pinkAccent, + width: 10, + height: 10, + ), + ), + ), + ], ), ), ]), diff --git a/packages/flutter_svg/lib/src/loaders.dart b/packages/flutter_svg/lib/src/loaders.dart index 2996cc11..a366fc98 100644 --- a/packages/flutter_svg/lib/src/loaders.dart +++ b/packages/flutter_svg/lib/src/loaders.dart @@ -152,20 +152,29 @@ abstract class SvgLoader extends BytesLoader { final SvgTheme theme = getTheme(context); return prepareMessage(context).then((T? message) { return compute((T? message) { - return vg - .encodeSvg( - xml: provideSvg(message), - theme: theme.toVgTheme(), - colorMapper: colorMapper == null - ? null - : _DelegateVgColorMapper(colorMapper!), - debugName: 'Svg loader', - enableClippingOptimizer: false, - enableMaskingOptimizer: false, - enableOverdrawOptimizer: false, - ) - .buffer - .asByteData(); + try { + debugPrint('SvgLoader._load.provideSvg: empty'); + final String xml = provideSvg(message); + if (xml.isEmpty) { + return Future.value(ByteData.new(0)); + } else { + return vg + .encodeSvg( + xml: xml, + theme: theme.toVgTheme(), + colorMapper: colorMapper == null ? null : _DelegateVgColorMapper(colorMapper!), + debugName: 'Svg loader', + enableClippingOptimizer: false, + enableMaskingOptimizer: false, + enableOverdrawOptimizer: false, + ) + .buffer + .asByteData(); + } + } catch (e) { + debugPrint('SvgLoader._load.error: $e'); + return Future.value(ByteData.new(0)); + } }, message, debugLabel: 'Load Bytes'); }); } @@ -373,15 +382,19 @@ class SvgAssetLoader extends SvgLoader { } @override - Future prepareMessage(BuildContext? context) { - return _resolveBundle(context).load( - packageName == null ? assetName : 'packages/$packageName/$assetName', - ); + Future prepareMessage(BuildContext? context) async { + try { + return await _resolveBundle(context).load( + packageName == null ? assetName : 'packages/$packageName/$assetName', + ); + } catch (e) { + debugPrint('SvgAssetLoader.prepareMessage.error: $e'); + return Future.value(); + } } @override - String provideSvg(ByteData? message) => - utf8.decode(message!.buffer.asUint8List(), allowMalformed: true); + String provideSvg(ByteData? message) => utf8.decode(message!.buffer.asUint8List(), allowMalformed: true); @override SvgCacheKey cacheKey(BuildContext? context) { @@ -437,13 +450,18 @@ class SvgNetworkLoader extends SvgLoader { @override Future prepareMessage(BuildContext? context) async { - final http.Client client = _httpClient ?? http.Client(); - return (await client.get(Uri.parse(url), headers: headers)).bodyBytes; + try { + final http.Client client = _httpClient ?? http.Client(); + final http.Response res = await client.get(Uri.parse(url), headers: headers); + return res.bodyBytes; + } catch (e) { + debugPrint('SvgNetworkLoader.prepareMessage.error: $e'); + return null; + } } @override - String provideSvg(Uint8List? message) => - utf8.decode(message!, allowMalformed: true); + String provideSvg(Uint8List? message) => message == null ? '' : utf8.decode(message, allowMalformed: true); @override int get hashCode => Object.hash(url, headers, theme, colorMapper); diff --git a/packages/flutter_svg/lib/svg.dart b/packages/flutter_svg/lib/svg.dart index d39e47ba..ea3dd830 100644 --- a/packages/flutter_svg/lib/svg.dart +++ b/packages/flutter_svg/lib/svg.dart @@ -91,6 +91,7 @@ class SvgPicture extends StatelessWidget { 'The SVG theme must be set on the bytesLoader.') SvgTheme? theme, @deprecated bool cacheColorFilter = false, + this.errorBuilder, }) : super(key: key); /// Instantiates a widget that renders an SVG picture from an [AssetBundle]. @@ -189,6 +190,7 @@ class SvgPicture extends StatelessWidget { @deprecated ui.Color? color, @deprecated ui.BlendMode colorBlendMode = ui.BlendMode.srcIn, @deprecated bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgAssetLoader( assetName, packageName: package, @@ -250,6 +252,7 @@ class SvgPicture extends StatelessWidget { @deprecated bool cacheColorFilter = false, SvgTheme? theme, http.Client? httpClient, + this.errorBuilder, }) : bytesLoader = SvgNetworkLoader( url, headers: headers, @@ -307,6 +310,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @deprecated bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgFileLoader(file, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode), super(key: key); @@ -356,6 +360,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @deprecated bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgBytesLoader(bytes, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode), super(key: key); @@ -405,6 +410,7 @@ class SvgPicture extends StatelessWidget { this.clipBehavior = Clip.hardEdge, SvgTheme? theme, @deprecated bool cacheColorFilter = false, + this.errorBuilder, }) : bytesLoader = SvgStringLoader(string, theme: theme), colorFilter = colorFilter ?? _getColorFilter(color, colorBlendMode), super(key: key); @@ -490,6 +496,9 @@ class SvgPicture extends StatelessWidget { /// The color filter, if any, to apply to this widget. final ColorFilter? colorFilter; + /// The widget to show when failed to fetch, decode, and parse the SVG data. + final SvgPictureErrorWidgetBuilder? errorBuilder; + @override Widget build(BuildContext context) { return createCompatVectorGraphic( @@ -505,6 +514,7 @@ class SvgPicture extends StatelessWidget { placeholderBuilder: placeholderBuilder, clipViewbox: !allowDrawingOutsideViewBox, matchTextDirection: matchTextDirection, + errorBuilder: errorBuilder, ); } @@ -567,3 +577,10 @@ class SvgPicture extends StatelessWidget { )); } } + +/// The signature that [VectorGraphic.errorBuilder] uses to report exceptions. +typedef SvgPictureErrorWidgetBuilder = Widget Function( + BuildContext context, + Object error, + StackTrace stackTrace, +); diff --git a/packages/flutter_svg/test/svg_picture_error_test.dart b/packages/flutter_svg/test/svg_picture_error_test.dart new file mode 100644 index 00000000..263f23db --- /dev/null +++ b/packages/flutter_svg/test/svg_picture_error_test.dart @@ -0,0 +1,153 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +void main() { + group('SvgPicture.string - use placeHolder or errorWidget if an error causes', () { + setUp(() {}); + tearDown(() {}); + testWidgets('load an empty string', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string('')); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string( + 'an invalid svg format string', + placeholderBuilder: buildPlaceHolderWidget, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.string( + 'an invalid svg format string', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); + + group('SvgPicture.asset - use placeHolder or errorWidget if an error causes', () { + late FakeAssetBundle assetBundle; + setUp(() { + assetBundle = FakeAssetBundle(); + }); + tearDown(() {}); + testWidgets('load an empty asset', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'empty', + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'an invalid asset', + placeholderBuilder: buildPlaceHolderWidget, + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(DefaultAssetBundle( + bundle: assetBundle, + child: SvgPicture.asset( + 'an invalid asset', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + ))); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); + + group('SvgPicture.network - use placeHolder or errorWidget if an error causes', () { + late FakeHttpClient httpClient = FakeHttpClient(); + setUp(() { + httpClient = FakeHttpClient(); + }); + tearDown(() {}); + testWidgets('http exception', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network('/404', httpClient: httpClient)); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + }); + testWidgets('wrong svg format - show placeholder', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network( + '/200', + placeholderBuilder: buildPlaceHolderWidget, + httpClient: httpClient, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expect(find.text('placeholder'), findsOneWidget); + }); + testWidgets('show placeholder - show errorWidget', (WidgetTester tester) async { + await tester.pumpWidget(SvgPicture.network( + '/200', + placeholderBuilder: buildPlaceHolderWidget, + errorBuilder: buildErrorWidget, + httpClient: httpClient, + )); + await tester.pumpAndSettle(); + expectOneSvgPicture(tester); + await tester.pumpAndSettle(); + expectOneErrorWidget(tester); + }); + }); +} + +void expectOneSvgPicture(WidgetTester tester) => expect(find.byType(SvgPicture), findsOneWidget); +void expectOnePlaceHolderWidget(WidgetTester tester) => expect(find.text('placeholder'), findsOneWidget); +void expectOneErrorWidget(WidgetTester tester) => expect(find.text('error'), findsOneWidget); + +Widget buildPlaceHolderWidget(BuildContext context) => const Text('placeholder', textDirection: TextDirection.ltr); +Widget buildErrorWidget(BuildContext context, Object error, StackTrace stackTrace) => + const Text('error', textDirection: TextDirection.ltr); + +class FakeAssetBundle extends Fake implements AssetBundle { + @override + Future load(String key) async { + if (key == 'empty') { + return Future.value(ByteData(0)); + } + throw Exception('error'); + } +} + +class FakeHttpClient extends Fake implements http.Client { + FakeHttpClient(); + + @override + Future get(Uri url, {Map? headers}) async { + debugPrint('FakeHttpClient.get: ${url.path}'); + if (url.path == '/404') { + return Future.value(http.Response('', HttpStatus.notFound)); + } else if (url.path == '/200') { + return Future.value(http.Response('''invalid svg format''', HttpStatus.ok)); + } else { + throw Exception('$url is invalid'); + } + } +}