diff --git a/packages/sealed_currencies/lib/src/data/currencies/ars.data.dart b/packages/sealed_currencies/lib/src/data/currencies/ars.data.dart index a70832aba..f9918a621 100644 --- a/packages/sealed_currencies/lib/src/data/currencies/ars.data.dart +++ b/packages/sealed_currencies/lib/src/data/currencies/ars.data.dart @@ -20,8 +20,8 @@ final class FiatArs extends FiatCurrency { decimalMark: ",", thousandsSeparator: ".", symbol: r"$", - alternateSymbols: const [r"$m/n", r"m$n"], - disambiguateSymbol: r"$m/n", + alternateSymbols: const [r"AR$"], + disambiguateSymbol: r"AR$", htmlEntity: r"$", codeNumeric: "032", namesNative: const ["Argentine peso"], diff --git a/packages/sealed_currencies/test/src/model/currency/currency_test.dart b/packages/sealed_currencies/test/src/model/currency/currency_test.dart index b0ff9afb3..a36e0b5a0 100644 --- a/packages/sealed_currencies/test/src/model/currency/currency_test.dart +++ b/packages/sealed_currencies/test/src/model/currency/currency_test.dart @@ -18,7 +18,7 @@ void main() => group("$Currency", () { currency.toString(short: false), 'FiatCurrency(code: "ARS", name: "Argentine Peso", ' r'decimalMark: ",", thousandsSeparator: ".", symbol: r"$", ' - r'alternateSymbols: ["$m/n","m$n"], disambiguateSymbol: r"$m/n", ' + r'alternateSymbols: ["AR$"], disambiguateSymbol: r"AR$", ' r'htmlEntity: r"$", codeNumeric: "032", ' 'namesNative: ["Argentine peso"], priority: 100, ' 'smallestDenomination: 1, subunit: "Centavo", subunitToUnit: 100, ' diff --git a/packages/world_countries/example/macos/Runner/Info.plist b/packages/world_countries/example/macos/Runner/Info.plist index 4789daa6a..d41f1ad1f 100644 --- a/packages/world_countries/example/macos/Runner/Info.plist +++ b/packages/world_countries/example/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + FLTEnableImpeller + diff --git a/packages/world_flags/example/android/app/build.gradle.kts b/packages/world_flags/example/android/app/build.gradle.kts index eab824980..5c8d9b02f 100644 --- a/packages/world_flags/example/android/app/build.gradle.kts +++ b/packages/world_flags/example/android/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.concurrent.TimeUnit + plugins { id("com.android.application") id("kotlin-android") @@ -15,10 +17,6 @@ android { targetCompatibility = JavaVersion.VERSION_24 } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_24.toString() - } - defaultConfig { // Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.example.world_flags_example" @@ -39,6 +37,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_24) + } +} + flutter { source = "../.." -} +} \ No newline at end of file diff --git a/packages/world_flags/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/world_flags/example/android/gradle/wrapper/gradle-wrapper.properties index 2d428bfb1..8600ef6cc 100644 --- a/packages/world_flags/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/world_flags/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-all.zip diff --git a/packages/world_flags/example/lib/main.dart b/packages/world_flags/example/lib/main.dart index e66bb7f4a..751e7d5b1 100644 --- a/packages/world_flags/example/lib/main.dart +++ b/packages/world_flags/example/lib/main.dart @@ -6,11 +6,7 @@ import "settings_dialog.dart"; void main() { /// Provide flag decorations globally. const extensions = [ - FlagThemeData( - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4)), - ), - ), + FlagThemeData(decoration: BoxDecoration(borderRadius: .all(.circular(4)))), ]; runApp( @@ -33,8 +29,7 @@ class _MainState extends State
{ // ignore: specify_nonobvious_property_types, a double as it's divided by 2.0. static const _size = kMinInteractiveDimension / 2.0; static const _items = { - ...uniqueSimplifiedFlagsMap, - CountryGuf(): StarFlag(flagGufPropertiesAlt), + ...smallSimplifiedFlagsMap, FiatEur(): StarFlag(flagEurProperties), ...smallSimplifiedLanguageFlagsMap, }; diff --git a/packages/world_flags/example/lib/settings_dialog.dart b/packages/world_flags/example/lib/settings_dialog.dart index e043050fd..f05d70362 100644 --- a/packages/world_flags/example/lib/settings_dialog.dart +++ b/packages/world_flags/example/lib/settings_dialog.dart @@ -1,20 +1,18 @@ -import "dart:async"; - import "package:flutter/material.dart"; +import "package:meta/meta.dart"; // ignore: depend_on_referenced_packages, example app. import "package:world_flags/world_flags.dart"; class SettingsDialog extends StatefulWidget { const SettingsDialog(this.aspectRatio, this.country, {super.key}); - static void show( + @awaitNotRequired + static Future show( ValueNotifier aspectRatio, BuildContext context, WorldCountry country, - ) => unawaited( - showDialog( - context: context, - builder: (_) => SettingsDialog(aspectRatio, country), - ), + ) => showDialog( + context: context, + builder: (_) => SettingsDialog(aspectRatio, country), ); final WorldCountry country; @@ -25,7 +23,7 @@ class SettingsDialog extends StatefulWidget { } class _SettingsDialogState extends State { - final _opacity = ValueNotifier(1 / 2); + final _opacity = ValueNotifier(1); WorldCountry get _country => widget.country; @@ -72,19 +70,15 @@ class _SettingsDialogState extends State { ), body: Center( child: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - fit: BoxFit.fitHeight, - image: NetworkImage(_country.flagPngUrl()), - ), - ), - child: AspectRatio( - aspectRatio: - ratio ?? - _country.flagProperties?.aspectRatio ?? - FlagConstants.defaultAspectRatio, - child: Opacity(opacity: opacityValue, child: flag), - ), + decoration: opacityValue == 1 + ? const BoxDecoration() + : BoxDecoration( + image: DecorationImage( + fit: BoxFit.fitHeight, + image: NetworkImage(_country.flagPngUrl()), + ), + ), + child: Opacity(opacity: opacityValue, child: flag), ), ), bottomNavigationBar: SliderTheme( @@ -101,6 +95,7 @@ class _SettingsDialogState extends State { ratio ?? _country.flagProperties?.aspectRatio ?? FlagConstants.minAspectRatio, + secondaryTrackValue: _country.flagProperties?.aspectRatio, onChanged: (newRatio) => widget.aspectRatio.value = newRatio, min: FlagConstants.minAspectRatio, @@ -120,7 +115,7 @@ class _SettingsDialogState extends State { ), ), ), - child: CountryFlag.simplified(_country, aspectRatio: ratio), + child: FlagShaderSurface(_country, aspectRatio: ratio), ), ), ), diff --git a/packages/world_flags/example/macos/Runner/Info.plist b/packages/world_flags/example/macos/Runner/Info.plist index 4789daa6a..d41f1ad1f 100644 --- a/packages/world_flags/example/macos/Runner/Info.plist +++ b/packages/world_flags/example/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + FLTEnableImpeller + diff --git a/packages/world_flags/example/pubspec.yaml b/packages/world_flags/example/pubspec.yaml index 85d376c64..ba0744cb8 100644 --- a/packages/world_flags/example/pubspec.yaml +++ b/packages/world_flags/example/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: none resolution: workspace environment: - sdk: ^3.9.2 + sdk: ^3.10.4 dependencies: flutter: diff --git a/packages/world_flags/lib/src/data/flags_map_part_3.data.dart b/packages/world_flags/lib/src/data/flags_map_part_3.data.dart index 59bbc39db..67be30e36 100644 --- a/packages/world_flags/lib/src/data/flags_map_part_3.data.dart +++ b/packages/world_flags/lib/src/data/flags_map_part_3.data.dart @@ -2385,7 +2385,7 @@ const flagLcaProperties = FlagProperties( baseElementType: FlagElementsType.rectangle, elementsProperties: [ ElementsProperties( - Color(0xff65cfff), + Color(0x0065cfff), shape: Rectangle(), offset: Offset(0, 4.5), heightFactor: 0.82, diff --git a/packages/world_flags/lib/src/helpers/extensions/aspect_ratio_extension.dart b/packages/world_flags/lib/src/helpers/extensions/aspect_ratio_extension.dart new file mode 100644 index 000000000..51e1e7fb3 --- /dev/null +++ b/packages/world_flags/lib/src/helpers/extensions/aspect_ratio_extension.dart @@ -0,0 +1,8 @@ +import "package:flutter/foundation.dart" show internal; + +@internal +extension AspectRatioExtension on T? { + /// Calculated aspect ratio. + double? aspectRatio(double? width) => + width == null || this == null ? null : width / (this ?? 1); +} diff --git a/packages/world_flags/lib/src/helpers/extensions/decorated_flag_interface_extension.dart b/packages/world_flags/lib/src/helpers/extensions/decorated_flag_interface_extension.dart index 8e63a8236..15d611b9b 100644 --- a/packages/world_flags/lib/src/helpers/extensions/decorated_flag_interface_extension.dart +++ b/packages/world_flags/lib/src/helpers/extensions/decorated_flag_interface_extension.dart @@ -1,9 +1,9 @@ import "../../interfaces/decorated_flag_interface.dart"; +import "aspect_ratio_extension.dart"; /// An extension on [DecoratedFlagInterface] that provides a method to calculate /// the aspect ratio of the flag based on its width and height. extension DecoratedFlagInterfaceExtension on DecoratedFlagInterface { /// The calculated aspect ratio of the flag based on its width and height. - double? get calculatedAspectRatio => - width == null || height == null ? null : (width ?? 1) / (height ?? 1); + double? get calculatedAspectRatio => height.aspectRatio(width); } diff --git a/packages/world_flags/lib/src/helpers/extensions/iso_flag_extension.dart b/packages/world_flags/lib/src/helpers/extensions/iso_flag_extension.dart index 4d8c04179..72f05f036 100644 --- a/packages/world_flags/lib/src/helpers/extensions/iso_flag_extension.dart +++ b/packages/world_flags/lib/src/helpers/extensions/iso_flag_extension.dart @@ -3,6 +3,7 @@ import "package:flutter/widgets.dart"; import "package:sealed_countries/sealed_countries.dart" show IsoStandardized; +import "../../ui/effects/flag_shader_delegate.dart"; import "../../ui/flags/basic_flag.dart"; import "../../ui/iso_flag.dart"; @@ -36,20 +37,22 @@ extension IsoFlagExtension BoxDecoration? decoration, DecorationPosition? decorationPosition, EdgeInsetsGeometry? padding, + FlagShaderDelegate? shader, Widget? child, Key? key, }) => IsoFlag( - item ?? this.item, - map ?? this.map, alternativeMap: alternativeMap ?? this.alternativeMap, - orElse: orElse ?? this.orElse, - height: height ?? this.height, - width: width ?? this.width, aspectRatio: aspectRatio ?? this.aspectRatio, decoration: decoration ?? this.decoration, decorationPosition: decorationPosition ?? this.decorationPosition, - padding: padding ?? this.padding, + height: height ?? this.height, + item ?? this.item, key: key ?? this.key, + map ?? this.map, + orElse: orElse ?? this.orElse, + padding: padding ?? this.padding, + width: width ?? this.width, + shader: shader ?? this.shader, child: child ?? this.child, ); } diff --git a/packages/world_flags/lib/src/model/typedefs.dart b/packages/world_flags/lib/src/model/typedefs.dart index 3be40f8a1..dd7070f5c 100644 --- a/packages/world_flags/lib/src/model/typedefs.dart +++ b/packages/world_flags/lib/src/model/typedefs.dart @@ -1,6 +1,17 @@ import "package:flutter/widgets.dart"; +import "../ui/effects/flag_shader_delegate.dart"; +import "../ui/effects/flag_shader_options.dart"; import "elements/elements_properties.dart"; +import "flag_properties.dart"; + +/// Signature for building shader delegate when no custom delegate is supplied. +typedef FlagShaderDelegateBuilder = + FlagShaderDelegate Function( + TickerProvider vsync, + FlagShaderOptions options, + FlagProperties properties, + ); /// A type definition for a list of [ElementsProperties]. typedef ElementsProps = List; diff --git a/packages/world_flags/lib/src/ui/country_flag.dart b/packages/world_flags/lib/src/ui/country_flag.dart index 13212b852..07e61041a 100644 --- a/packages/world_flags/lib/src/ui/country_flag.dart +++ b/packages/world_flags/lib/src/ui/country_flag.dart @@ -3,8 +3,7 @@ import "package:sealed_countries/sealed_countries.dart"; // ignore: avoid-importing-entrypoint-exports, only shows maps. -import "../../world_flags.dart" - show smallSimplifiedAlternativeFlagsMap, smallSimplifiedFlagsMap; +import "../../world_flags.dart" show smallSimplifiedFlagsMap; import "flags/basic_flag.dart"; import "iso_flag.dart"; @@ -40,13 +39,14 @@ class CountryFlag extends IsoFlag { /// - [key]: The key for the widget. const CountryFlag.simplified( WorldCountry country, { - super.alternativeMap = smallSimplifiedAlternativeFlagsMap, + super.alternativeMap, super.height, super.width, super.aspectRatio, super.decoration, super.decorationPosition, super.padding, + super.shader, super.orElse, super.child, super.key, @@ -78,6 +78,7 @@ class CountryFlag extends IsoFlag { super.padding, super.height, super.width, + super.shader, super.orElse, super.child, super.key, diff --git a/packages/world_flags/lib/src/ui/effects/flag_shader_delegate.dart b/packages/world_flags/lib/src/ui/effects/flag_shader_delegate.dart new file mode 100644 index 000000000..fd87809b1 --- /dev/null +++ b/packages/world_flags/lib/src/ui/effects/flag_shader_delegate.dart @@ -0,0 +1,39 @@ +import "dart:ui" show Canvas, Image, Size; + +import "package:flutter/foundation.dart" show Listenable; + +/// A delegate that knows how to post-process flag content with a shader. +/// +/// Implementations can decide whether to apply a shader or bail out and let the +/// fallback painter run. +abstract class FlagShaderDelegate implements Listenable { + /// Creates a new instance of [FlagShaderDelegate]. + const FlagShaderDelegate( // coverage:ignore-line + { + this.contentScale = 1, // Dart 3.8 formatting. + this.shouldClipContent = false, + }); + + /// Whether the painter should clip to the flag bounds before invoking the + /// shader. + /// + /// Defaults to `false`, allowing shader effects to extend beyond the + /// rectangle. + final bool shouldClipContent; + + /// Uniform scale factor to apply to the painted flag before handing it to the + /// shader. + /// + /// Values below `1.0` shrink the base content to leave visual headroom for + /// shader-driven overflow. Defaults to `1.0` (no scaling). + final double contentScale; + + /// Attempts to paint [image] with a shader. + /// + /// Returns `true` when the shader path was taken. If `false` is returned, the + /// caller should paint the content normally. + bool paintWithShader(Canvas destination, Size size, {required Image image}); + + /// Releases any resources allocated by the delegate. + void dispose(); +} diff --git a/packages/world_flags/lib/src/ui/effects/flag_shader_options.dart b/packages/world_flags/lib/src/ui/effects/flag_shader_options.dart new file mode 100644 index 000000000..8ae10626d --- /dev/null +++ b/packages/world_flags/lib/src/ui/effects/flag_shader_options.dart @@ -0,0 +1,279 @@ +// ignore_for_file: prefer-class-destructuring + +import "dart:ui" show Offset; + +import "package:flutter/foundation.dart"; + +import "../../theme/flag_theme_data.dart"; +import "flag_shader_surface.dart" show FlagShaderSurface; + +/// Immutable configuration for waved-flag style shader delegates. +/// +/// These options describe purely visual preferences (amplitude, turbulence, +/// highlights, etc.) and can be stored inside [FlagThemeData] or supplied to +/// a [FlagShaderSurface]. They do *not* manage shader life-cycles, tickers, +/// or GPU resources. +/// +/// ### Usage +/// +/// ```dart +/// const options = FlagShaderOptions( +/// waveAmplitude: 0.04, +/// turbulence: 0.6, +/// ); +/// +/// return FlagShaderSurface( +/// isoObject, +/// simplifiedFlagsMap, +/// options: options, +/// ); +/// ``` +/// +/// ### Recommendations +/// - Works best for flags rendered at widths >= 320 logical pixels; extremely +/// small icons cannot showcase the cloth motion and may look blurry. +/// - Shader effects are relatively GPU intensive; avoid animating multiple +/// full-size flags simultaneously on low-end devices. +/// - Maintain sufficient padding/"breathing space" above and below the flag when +/// increasing [waveAmplitude] to minimize clipping artifacts. +@immutable +class FlagShaderOptions { + /// Creates a new set of shader options. + const FlagShaderOptions({ + this.animate = true, + this.animationSpeed = 1, + this.frozenPhase = 0.25, + this.waveAmplitude = 0.03, + this.waveFrequency = 2, + this.wavePhaseShift = 0, + this.secondaryAmplitude = 0.02, + this.secondaryFrequency = 1.8, + this.leftEdgePinned = true, + this.rightEdgePinned = false, + this.pinSoftness = 0.15, + this.poleMargin = 0.02, + this.shadingEnabled = true, + this.foldStrength = 0.4, + this.highlightStrength = 0.3, + this.shadowStrength = 0.3, + this.sheenStrength = 0.1, + this.sheenFrequency = 4, + this.perspective = 0.12, + this.seed = 1, + this.turbulence = 0.8, + this.waveDirection = const Offset(0.8, 0.3), + this.fabricVisibility = 0, + this.clipContent = false, + this.overflowScale = 0.9, + }); + + /// Whether animation should advance automatically using a ticker. + final bool animate; + + /// Multiplier applied to time advancement for slower/faster motion. + final double animationSpeed; + + /// Phase used when [animate] is `false`; determines frozen pose. + final double frozenPhase; + + /// Primary wave amplitude controlling displacement magnitude. + final double waveAmplitude; + + /// Primary wave frequency; higher values add more folds within the flag. + final double waveFrequency; + + /// Phase offset applied to the primary wave function. + final double wavePhaseShift; + + /// Secondary wave amplitude for layered, softer motion. + final double secondaryAmplitude; + + /// Secondary wave frequency for the supplemental wave layer. + final double secondaryFrequency; + + /// Pins the hoist (left) edge to the flag pole, reducing displacement there. + final bool leftEdgePinned; + + /// Pins the fly (right) edge to dampen movement. + final bool rightEdgePinned; + + /// Distance (0-1) over which the pinning mask eases in. + final double pinSoftness; + + /// Margin reserved near pinned edges where displacement should be minimal. + final double poleMargin; + + /// Enables lighting calculations (folds, shading, highlights). + final bool shadingEnabled; + + /// Strength of the fold/jitter applied along the vertical axis. + final double foldStrength; + + /// Brightness boost applied on lit areas. + final double highlightStrength; + + /// Darkness applied to shadowed folds. + final double shadowStrength; + + /// Intensity of the silky sheen running across the fabric. + final double sheenStrength; + + /// Frequency of the sheen shimmer. + final double sheenFrequency; + + /// Fake perspective intensity (use small values, e.g. 0.1). + final double perspective; + + /// Seed that randomizes the noise/turbulence field. + final double seed; + + /// Global turbulence factor that adds organic irregularities. + final double turbulence; + + /// Default wave direction unit vector. + final Offset waveDirection; + + /// Visibility of the procedural cloth texture (0 = off, 1 = fully visible). + final double fabricVisibility; + + /// Whether to clip shader output to flag bounds. + final bool clipContent; + + /// Scale applied to the source flag before shader processing when + /// [clipContent] is false, leaving headroom for overflow. + final double overflowScale; + + @override + String toString() => + "FlagShaderOptions(animate: $animate, " + "animationSpeed: $animationSpeed, frozenPhase: $frozenPhase, " + "waveAmplitude: $waveAmplitude, waveFrequency: $waveFrequency, " + "wavePhaseShift: $wavePhaseShift, " + "secondaryAmplitude: $secondaryAmplitude, " + "secondaryFrequency: $secondaryFrequency, " + "leftEdgePinned: $leftEdgePinned, rightEdgePinned: $rightEdgePinned, " + "pinSoftness: $pinSoftness, poleMargin: $poleMargin, " + "shadingEnabled: $shadingEnabled, foldStrength: $foldStrength, " + "highlightStrength: $highlightStrength, shadowStrength: $shadowStrength, " + "sheenStrength: $sheenStrength, sheenFrequency: $sheenFrequency, " + "perspective: $perspective, seed: $seed, turbulence: $turbulence, " + "waveDirection: $waveDirection, fabricVisibility: $fabricVisibility, " + "clipContent: $clipContent, overflowScale: $overflowScale)"; + + /// Returns a copy with selectively replaced fields. + FlagShaderOptions copyWith({ + bool? animate, + double? animationSpeed, + double? frozenPhase, + double? waveAmplitude, + double? waveFrequency, + double? wavePhaseShift, + double? secondaryAmplitude, + double? secondaryFrequency, + bool? leftEdgePinned, + bool? rightEdgePinned, + double? pinSoftness, + double? poleMargin, + bool? shadingEnabled, + double? foldStrength, + double? highlightStrength, + double? shadowStrength, + double? sheenStrength, + double? sheenFrequency, + double? perspective, + double? seed, + double? turbulence, + Offset? waveDirection, + double? fabricVisibility, + bool? clipContent, + double? overflowScale, + }) => FlagShaderOptions( + animate: animate ?? this.animate, + animationSpeed: animationSpeed ?? this.animationSpeed, + frozenPhase: frozenPhase ?? this.frozenPhase, + waveAmplitude: waveAmplitude ?? this.waveAmplitude, + waveFrequency: waveFrequency ?? this.waveFrequency, + wavePhaseShift: wavePhaseShift ?? this.wavePhaseShift, + secondaryAmplitude: secondaryAmplitude ?? this.secondaryAmplitude, + secondaryFrequency: secondaryFrequency ?? this.secondaryFrequency, + leftEdgePinned: leftEdgePinned ?? this.leftEdgePinned, + rightEdgePinned: rightEdgePinned ?? this.rightEdgePinned, + pinSoftness: pinSoftness ?? this.pinSoftness, + poleMargin: poleMargin ?? this.poleMargin, + shadingEnabled: shadingEnabled ?? this.shadingEnabled, + foldStrength: foldStrength ?? this.foldStrength, + highlightStrength: highlightStrength ?? this.highlightStrength, + shadowStrength: shadowStrength ?? this.shadowStrength, + sheenStrength: sheenStrength ?? this.sheenStrength, + sheenFrequency: sheenFrequency ?? this.sheenFrequency, + perspective: perspective ?? this.perspective, + seed: seed ?? this.seed, + turbulence: turbulence ?? this.turbulence, + waveDirection: waveDirection ?? this.waveDirection, + fabricVisibility: fabricVisibility ?? this.fabricVisibility, + clipContent: clipContent ?? this.clipContent, + overflowScale: overflowScale ?? this.overflowScale, + ); + + @override + int get hashCode => Object.hashAll([ + animate, + animationSpeed, + frozenPhase, + waveAmplitude, + waveFrequency, + wavePhaseShift, + secondaryAmplitude, + secondaryFrequency, + leftEdgePinned, + rightEdgePinned, + pinSoftness, + poleMargin, + shadingEnabled, + foldStrength, + highlightStrength, + shadowStrength, + sheenStrength, + sheenFrequency, + perspective, + seed, + turbulence, + waveDirection, + fabricVisibility, + clipContent, + overflowScale, + ]); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + // ignore: avoid-complex-conditions, it's regular == override in big class. + return other is FlagShaderOptions && + other.animate == animate && + other.animationSpeed == animationSpeed && + other.frozenPhase == frozenPhase && + other.waveAmplitude == waveAmplitude && + other.waveFrequency == waveFrequency && + other.wavePhaseShift == wavePhaseShift && + other.secondaryAmplitude == secondaryAmplitude && + other.secondaryFrequency == secondaryFrequency && + other.leftEdgePinned == leftEdgePinned && + other.rightEdgePinned == rightEdgePinned && + other.pinSoftness == pinSoftness && + other.poleMargin == poleMargin && + other.shadingEnabled == shadingEnabled && + other.foldStrength == foldStrength && + other.highlightStrength == highlightStrength && + other.shadowStrength == shadowStrength && + other.sheenStrength == sheenStrength && + other.sheenFrequency == sheenFrequency && + other.perspective == perspective && + other.seed == seed && + other.turbulence == turbulence && + other.waveDirection == waveDirection && + other.fabricVisibility == fabricVisibility && + other.clipContent == clipContent && + other.overflowScale == overflowScale; + } +} diff --git a/packages/world_flags/lib/src/ui/effects/flag_shader_surface.dart b/packages/world_flags/lib/src/ui/effects/flag_shader_surface.dart new file mode 100644 index 000000000..780eedc06 --- /dev/null +++ b/packages/world_flags/lib/src/ui/effects/flag_shader_surface.dart @@ -0,0 +1,329 @@ +// ignore_for_file: prefer-class-destructuring + +import "dart:async" show unawaited; + +import "package:flutter/foundation.dart"; +import "package:flutter/widgets.dart"; +import "package:sealed_countries/sealed_countries.dart" + show FiatEur, IsoStandardized; + +// ignore: avoid-importing-entrypoint-exports, only shows maps. +import "../../../world_flags.dart" + show smallSimplifiedFlagsMap, smallSimplifiedLanguageFlagsMap; +import "../../data/other_iso_flags_map.dart"; +import "../../helpers/extensions/aspect_ratio_extension.dart"; +import "../../model/flag_properties.dart"; +import "../../model/typedefs.dart"; +import "../flags/basic_flag.dart"; +import "../flags/star_flag.dart"; +import "../painters/basic/shader_stripes_painter.dart"; +import "flag_shader_delegate.dart"; +import "flag_shader_options.dart"; +import "waved_flag_shader_delegate.dart"; + +/// An animated, shader-driven flag widget that applies GPU-based visual +/// effects to country/currency/language flags. +/// +/// This widget provides a declarative API for rendering flags with real-time +/// shader animations (e.g., waving cloth effect) while maintaining +/// compatibility with the existing [BasicFlag] ecosystem. +/// +/// {@template flag_shader_surface_usage} +/// ## Usage +/// +/// Basic usage with default shader options: +/// ```dart +/// FlagShaderSurface(CountryUsa()) +/// ``` +/// +/// With custom shader options: +/// ```dart +/// FlagShaderSurface( +/// CountryFra(), +/// options: FlagShaderOptions( +/// turbulence: 0.1, +/// ), +/// ) +/// ``` +/// +/// With explicit dimensions: +/// ```dart +/// FlagShaderSurface( +/// CountryDeu(), +/// width: 200, +/// height: 120, +/// ) +/// ``` +/// {@endtemplate} +/// +/// ## Shader Delegate Lifecycle +/// +/// The widget manages shader delegate lifecycle automatically: +/// - If [shader] is provided externally, the caller is responsible for +/// disposal. +/// - If no [shader] is provided, an internal [WavedFlagShaderDelegate] is +/// created and disposed automatically when the widget is removed from the +/// tree or when [options] change. +/// +/// ## Performance Considerations +/// +/// - The shader painter caches rasterized flag content to minimize GPU work. +/// - Animation is driven by the delegate's internal ticker, avoiding +/// unnecessary widget rebuilds. +/// - For lists of animated flags, consider using [TickerMode] to pause +/// off-screen animations. +/// +/// See also: +/// - [FlagShaderOptions] for configuring shader visual parameters. +/// - [FlagShaderDelegate] for implementing custom shader effects. +/// - [WavedFlagShaderDelegate] for the default waving cloth implementation. +class FlagShaderSurface extends StatefulWidget { + /// Creates an animated flag surface for the given [item]. + /// + /// The [item] must be a valid ISO-standardized object (country, language, + /// or currency) that has a corresponding flag in [map] or [alternativeMap]. + /// + /// {@macro flag_shader_surface_usage} + const FlagShaderSurface( + this.item, { + Map map = const { + ...smallSimplifiedFlagsMap, + FiatEur(): StarFlag(flagEurProperties), + ...smallSimplifiedLanguageFlagsMap, + }, + Map? alternativeMap, + this.orElse = const SizedBox.shrink(), + this.options = const FlagShaderOptions(), + this.shader, + this.delegateBuilder, + this.height, + this.width, + this.aspectRatio, + super.key, + }) : _alternativeMap = alternativeMap, + _map = map; + + /// The ISO-standardized object whose flag should be displayed. + /// + /// This can be a country, language, or currency that conforms to + /// [IsoStandardized]. + final IsoStandardized item; + + /// Primary map of ISO objects to their flag representations. + final Map _map; + + /// Optional alternative map that takes precedence over [_map] when looking + /// up flags. + /// + /// Useful for providing regional flag variants or custom flag designs. + final Map? _alternativeMap; + + /// Widget to display when no flag is found for [item]. + /// + /// Defaults to [SizedBox.shrink]. + final Widget orElse; + + /// Configuration options for the shader effect. + /// + /// Controls visual parameters like wave amplitude, turbulence, shading + /// intensity, and animation speed. + /// + /// Defaults to [FlagShaderOptions] with standard values suitable for + /// most use cases. + final FlagShaderOptions options; + + /// Optional externally-managed shader delegate. + /// + /// When provided, the widget uses this delegate instead of creating an + /// internal one. The caller is responsible for disposing this delegate. + /// + /// This is useful when sharing a single delegate across multiple flag + /// surfaces or when using a custom shader implementation. + final FlagShaderDelegate? shader; + + /// Optional factory for creating custom shader delegates. + /// + /// When provided and [shader] is null, this builder is used instead of the + /// default [WavedFlagShaderDelegate] constructor. + /// + /// The builder receives: + /// - A [TickerProvider] for animation timing. + /// - The current [FlagShaderOptions]. + /// - The flag's [FlagProperties] for context-aware delegate creation. + final FlagShaderDelegateBuilder? delegateBuilder; + + /// Optional fixed height for the flag. + /// + /// When `null`, uses the flag's default height. + final double? height; + + /// Optional fixed width for the flag. + /// + /// When `null`, uses the flag's default width. + final double? width; + + /// Optional aspect ratio override. + /// + /// When `null`, uses the flag's natural aspect ratio. + final double? aspectRatio; + + /// Resolves the flag for [item] from available maps. + BasicFlag? get _flag => _alternativeMap?[item] ?? _map[item]; + + @override + String toStringShort() => "FlagShaderSurface(${item.code})"; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(StringProperty("iso", item.code)) + ..add(DiagnosticsProperty("options", options)) + ..add(DiagnosticsProperty("shader", shader)) + ..add(IntProperty("mapSize", _map.length)) + ..add(IntProperty("alternativeMapSize", _alternativeMap?.length ?? 0)) + ..add(DoubleProperty("height", height)) + ..add(DoubleProperty("width", width)) + ..add(DoubleProperty("aspectRatio", aspectRatio)) + ..add( + ObjectFlagProperty.has( + "delegateBuilder", + delegateBuilder, + ), + ); + } + + @override + State createState() => _FlagShaderSurfaceState(); +} + +class _FlagShaderSurfaceState extends State + with TickerProviderStateMixin { + FlagShaderDelegate? _managedDelegate; + FlagShaderOptions? _cachedOptions; + ShaderStripesPainter? _painter; + + /// Creates the default waved-flag shader delegate. + /// + /// Ensures the shader program is warmed up before creating the delegate + /// to minimize first-frame compilation junk. + static FlagShaderDelegate _defaultDelegateBuilder( + TickerProvider tickerProvider, + FlagShaderOptions options, + FlagProperties properties, + ) { + unawaited(WavedFlagShaderDelegate.warmUp()); + + return WavedFlagShaderDelegate(vsync: tickerProvider, options: options); + } + + void _disposeManagedDelegate() { + _managedDelegate?.dispose(); + _managedDelegate = null; + _cachedOptions = null; + } + + void _disposePainter() { + _painter?.dispose(); + _painter = null; + } + + /// Resolves or creates the appropriate shader delegate for the given flag. + /// + /// Returns the externally-provided `widget.shader` if available, otherwise + /// creates and caches an internal delegate using `widget.delegateBuilder`, + /// or the default builder. + FlagShaderDelegate? _resolveDelegate(BasicFlag flag) { + final widgetShader = widget.shader; + if (widgetShader != null) { + if (_managedDelegate != null) _disposeManagedDelegate(); + + return widgetShader; + } + + final opts = widget.options; + final needsNewDelegate = _managedDelegate == null || _cachedOptions != opts; + + if (needsNewDelegate) { + _managedDelegate?.dispose(); + _managedDelegate = (widget.delegateBuilder ?? _defaultDelegateBuilder)( + this, + opts, + flag.properties, + ); + _cachedOptions = opts; + } + + return _managedDelegate; + } + + /// Creates or updates the shader painter for the given flag and delegate. + /// + /// The painter is cached and only recreated when the delegate or flag + /// properties change, minimizing object allocation during animation frames. + ShaderStripesPainter _resolvePainter( + BasicFlag flag, + FlagShaderDelegate delegate, + ) { + final currentPainter = _painter; + if (currentPainter != null && currentPainter.shader == delegate) { + return currentPainter; // Reuse painter if delegate hasn't changed. + } + + _disposePainter(); // Dispose old painter and create new one. + _painter = ShaderStripesPainter( + flag.properties, + flag.elementsBuilder?.call( + flag.properties.elementsProperties, + flag.flagAspectRatio, + ), + shader: delegate, + ); + + return _painter!; // ignore: avoid-non-null-assertion, just assigned above. + } + + @override + void didUpdateWidget(covariant FlagShaderSurface oldWidget) { + super.didUpdateWidget(oldWidget); + final delegateChanged = + oldWidget.delegateBuilder != widget.delegateBuilder || + oldWidget.options != widget.options || + oldWidget.shader != widget.shader; + + if (delegateChanged) { + _disposePainter(); + _disposeManagedDelegate(); + } + } + + @override + void dispose() { + _disposePainter(); + _disposeManagedDelegate(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final flag = widget._flag; + if (flag == null) return widget.orElse; + final delegate = _resolveDelegate(flag); // Null is practically impossible. + if (delegate == null) return widget.orElse; // coverage:ignore-line + + return SizedBox( + height: widget.height ?? flag.height, + width: widget.width ?? flag.width, + child: AspectRatio( + aspectRatio: + widget.aspectRatio ?? + widget.height.aspectRatio(widget.width) ?? + flag.flagAspectRatio, + child: CustomPaint( + painter: _resolvePainter(flag, delegate), + willChange: widget.options.animate, + ), + ), + ); + } +} diff --git a/packages/world_flags/lib/src/ui/effects/waved_flag_shader_delegate.dart b/packages/world_flags/lib/src/ui/effects/waved_flag_shader_delegate.dart new file mode 100644 index 000000000..2b20d864b --- /dev/null +++ b/packages/world_flags/lib/src/ui/effects/waved_flag_shader_delegate.dart @@ -0,0 +1,197 @@ +import "dart:async"; +import "dart:io" show Platform; +import "dart:math" as math; +import "dart:ui"; + +import "package:flutter/foundation.dart"; +import "package:flutter/scheduler.dart"; + +import "../painters/basic/shader_stripes_painter.dart" + show ShaderStripesPainter; +import "flag_shader_delegate.dart"; +import "flag_shader_options.dart"; + +/// A reusable delegate that applies the waved-flag shader to cached flag +/// imagery. +/// +/// ## Image Ownership +/// +/// The delegate does NOT own images passed to [paintWithShader]. Images are +/// owned by the calling painter (typically [ShaderStripesPainter]) and must +/// not be disposed by this delegate. +class WavedFlagShaderDelegate extends ChangeNotifier + implements FlagShaderDelegate { + /// Creates a new instance of [WavedFlagShaderDelegate]. + WavedFlagShaderDelegate({ + required TickerProvider vsync, + this.options = const FlagShaderOptions(), + this.onError = _debugPrintError, + }) + // ignore: avoid-non-empty-constructor-bodies, for readability. + { + _ticker = vsync.createTicker(_handleTick); + _updateTicker(); + unawaited(_initShader()); + } + + /// Visual preferences of shader output. + final FlagShaderOptions options; + + /// Optional callback to track/handle errors on shader painting process, + /// default to `debugPrint`. + final void Function(Object error, StackTrace stackTrace)? onError; + + static FragmentProgram? _program; + static Future? _programLoader; + // ignore: avoid-late-keyword, initialized in the constructor, first line. + late final Ticker _ticker; + + static void _debugPrintError(Object error, StackTrace stackTrace) => + debugPrint("WavedFlagShaderDelegate paint failed: $error\n$stackTrace"); + + static final _shaderPath = + !kIsWeb && Platform.environment.containsKey("FLUTTER_TEST") + ? "shaders/waved_flag.frag" + : "packages/world_flags/shaders/waved_flag.frag"; + + /// Ensures the shader program is loaded before instantiating delegates. + /// + /// [assetKey] - represents path to the fragment program from the asset, + /// default to package's `packages/world_flags/shaders/waved_flag.frag`. + static Future warmUp([String? assetKey]) async { + _programLoader ??= FragmentProgram.fromAsset(assetKey ?? _shaderPath); + _program ??= await _programLoader; + } + + FragmentShader? _shader; + final _paint = Paint(); + double _time = 0; + Duration _lastTick = Duration.zero; + + Offset get _effectiveWaveDirection => options.waveDirection; + + @override + bool get shouldClipContent => options.clipContent; + + @override + double get contentScale => options.clipContent ? 1.0 : options.overflowScale; + + Future _initShader() async { + await warmUp(); + if (_program == null) return; + _shader?.dispose(); + _shader = _program?.fragmentShader(); + _configureShader(); + notifyListeners(); + } + + void _updateTicker() { + if (!options.animate) { + _ticker.stop(); + _lastTick = Duration.zero; + + return; + } + + if (!_ticker.isActive) { + _lastTick = Duration.zero; + unawaited(_ticker.start()); + } + } + + Duration? _handleTick(Duration timestamp) { + if (_lastTick == Duration.zero) return _lastTick = timestamp; + + final delta = timestamp - _lastTick; + _lastTick = timestamp; + final deltaSeconds = delta.inMicroseconds / 1e6; + if (deltaSeconds <= 0) return null; + + _time += deltaSeconds * options.animationSpeed; + if (_time > 1e4) _time -= 1e4; + _configureShader(timeOnly: true); + notifyListeners(); + + return null; + } + + void _configureShader({bool timeOnly = false}) { + if (_shader == null) return; + // Time uniform (always updated). + final timeValue = options.animate ? _time : options.frozenPhase; + _shader + ?..setFloat(2, timeValue) + ..setFloat(3, options.animationSpeed); + if (timeOnly) return; + + // Wave parameters (indices 4-8). + _shader + ?..setFloat(4, options.waveAmplitude) + ..setFloat(5, options.waveFrequency) + ..setFloat(6, options.wavePhaseShift) + ..setFloat(7, options.secondaryAmplitude) + ..setFloat(8, options.secondaryFrequency) + ..setFloat(9, options.leftEdgePinned ? 1 : 0) // Edge pinning (9-12). + ..setFloat(10, options.rightEdgePinned ? 1.0 : 0.0) + ..setFloat(11, options.pinSoftness) + ..setFloat(12, options.poleMargin) + ..setFloat(13, options.shadingEnabled ? 1 : 0) // Shading (13-19). + ..setFloat(14, options.foldStrength) + ..setFloat(15, options.highlightStrength) + ..setFloat(16, options.shadowStrength) + ..setFloat(17, options.sheenStrength) + ..setFloat(18, options.sheenFrequency) + ..setFloat(19, options.perspective) + ..setFloat(20, options.seed); // Seed/wave direction (indices 20-22). + final waveDir = _normalizeWaveDirection(_effectiveWaveDirection); + _shader + ?..setFloat(21, waveDir.dx) + ..setFloat(22, waveDir.dy) + ..setFloat(23, options.turbulence); // Turbulence, (index 23). + + _shader?.setFloat(24, options.fabricVisibility.clamp(0.0, 1.0)); + } + + static Offset _normalizeWaveDirection(Offset dir) { + final x = dir.dx; + final y = dir.dy; + final len = math.sqrt(x * x + y * y); + + return len > 0.001 ? Offset(x / len, y / len) : const Offset(1, 0); + } + + @override + bool paintWithShader(Canvas destination, Size size, {required Image image}) { + if (_shader == null || size.isEmpty) return false; + try { + // CRITICAL: Always update size and image sampler each frame. + // Do NOT cache the image reference - this causes issues when the + // painter replaces the image on Skia (!) backends. + _shader + ?..setFloat(0, size.width) + ..setFloat(1, size.height) + ..setImageSampler(0, image); + + _paint.shader = _shader; + destination.drawRect(Offset.zero & size, _paint); + _paint.shader = null; + + return true; + } on Object catch (error, stackTrace) { + onError?.call(error, stackTrace); + + return false; + } + } + + @override + void dispose() { + _ticker.dispose(); + _shader?.dispose(); + _paint.shader?.dispose(); + _paint.shader = null; + // NOTE: Do NOT dispose any cached image here! + // Images are owned by [ShaderStripesPainter], not by this delegate. + super.dispose(); + } +} diff --git a/packages/world_flags/lib/src/ui/iso_flag.dart b/packages/world_flags/lib/src/ui/iso_flag.dart index e55ea716c..0f77c590a 100644 --- a/packages/world_flags/lib/src/ui/iso_flag.dart +++ b/packages/world_flags/lib/src/ui/iso_flag.dart @@ -7,6 +7,7 @@ import "package:sealed_countries/sealed_countries.dart" show IsoStandardized; import "../debug/iso_diagnostics_property.dart"; import "../helpers/extensions/basic_flag_extension_copy_with.dart"; import "decorated_flag_widget.dart"; +import "effects/flag_shader_delegate.dart"; import "flags/basic_flag.dart"; /// A widget that displays a flag for a given ISO object. @@ -25,6 +26,7 @@ import "flags/basic_flag.dart"; /// decoration: BoxDecoration(border: Border.all(color: Colors.black)), /// ) /// ``` +@optionalTypeArgs class IsoFlag extends DecoratedFlagWidget { /// Creates a [IsoFlag] widget with a simplified flag representation. @@ -48,6 +50,7 @@ class IsoFlag this._map, { Map? alternativeMap, this.orElse, + this.shader, super.height, super.width, super.aspectRatio, @@ -75,6 +78,9 @@ class IsoFlag /// A widget to display if the flag is not found in the map. final Widget? orElse; + /// Optional shader delegate applied when painting the stripes. + final FlagShaderDelegate? shader; + @override String toStringShort() => "IsoFlag($debugLabel)"; @@ -187,6 +193,13 @@ class IsoFlag map, ifEmpty: "empty flags map provided", ), + ) + ..add( + DiagnosticsProperty( + "shader", + shader, + ifNull: "no shader provided", + ), ); } diff --git a/packages/world_flags/lib/src/ui/painters/basic/shader_stripes_painter.dart b/packages/world_flags/lib/src/ui/painters/basic/shader_stripes_painter.dart new file mode 100644 index 000000000..d05410604 --- /dev/null +++ b/packages/world_flags/lib/src/ui/painters/basic/shader_stripes_painter.dart @@ -0,0 +1,133 @@ +// ignore_for_file: avoid-returning-void + +import "dart:math"; +import "dart:ui"; + +import "package:flutter/foundation.dart" show Listenable; +import "package:flutter/rendering.dart"; + +import "../../../model/flag_properties.dart"; +import "../../effects/flag_shader_delegate.dart"; +import "../../effects/flag_shader_surface.dart" show FlagShaderSurface; +import "stripes_painter.dart"; + +/// Painter that caches the flag render output and feeds it into a shader +/// delegate for animation without re-rendering every frame. +/// +/// ## Image Ownership +/// +/// This painter OWNS the [_image] and is responsible for its lifecycle. +/// The [shader] delegate receives the image for rendering but must NOT +/// dispose it. +class ShaderStripesPainter extends StripesPainter { + /// Creates a new instance of [ShaderStripesPainter]. + /// + /// The [shader] delegate drives both animation (via its [Listenable] + /// interface) and the shader rendering path. + ShaderStripesPainter( + FlagProperties properties, + T? elementsPainter, { + required this.shader, + }) : super(properties, null, elementsPainter, repaint: shader); + + /// The shader delegate responsible for applying visual effects. + final FlagShaderDelegate shader; + + bool? _clip; + Image? _image; + double? _scale; + Size? _size; + + @override + void paint(Canvas canvas, Size size) { + _ensureCache(size); + final image = _image; // Null is practically impossible here, defensive. + if (image == null) return super.paint(canvas, size); // coverage:ignore-line + + final painted = _renderWithShader(canvas, size, image); + if (!painted) _drawCachedImage(canvas, size, image); + } + + void _ensureCache(Size size) { + if (_image != null && + _size == size && + _scale == shader.contentScale && + _clip == shader.shouldClipContent) { + return; + } + _rebuildCache(size, shader.contentScale); + } + + void _rebuildCache(Size size, double scale) { + _image?.dispose(); // Dispose previous image before creating new one. + _image = null; + + final recorder = PictureRecorder(); + final tempCanvas = Canvas(recorder); + if (shader.shouldClipContent) applyFlagClipping(tempCanvas, size); + _paintScaledStripes(tempCanvas, size, scale); + final picture = recorder.endRecording(); + + final width = max(1, size.width.ceil()); + final height = max(1, size.height.ceil()); + _image = picture.toImageSync(width, height); + picture.dispose(); // Dispose picture after converting to image. + _size = size; + _scale = scale; + _clip = shader.shouldClipContent; + } + + void _paintScaledStripes(Canvas canvas, Size size, double scaleY) { + if (scaleY == 1) return paintStripes(canvas, size); + + final center = Offset(size.width / 2, size.height / 2); + canvas + ..save() + ..translate(center.dx, center.dy) + ..scale(1, scaleY) + ..translate(-center.dx, -center.dy); + paintStripes(canvas, size); + canvas.restore(); + } + + bool _renderWithShader(Canvas canvas, Size size, Image image) { + if (shader.shouldClipContent) { + canvas.save(); + applyFlagClipping(canvas, size); + } + final painted = shader.paintWithShader(canvas, size, image: image); + if (shader.shouldClipContent) canvas.restore(); + + return painted; + } + + void _drawCachedImage(Canvas canvas, Size size, Image image) { + if (shader.shouldClipContent) { + canvas.save(); + applyFlagClipping(canvas, size); + } + final source = Rect.fromLTWH(0, 0, _size?.width ?? 0, _size?.height ?? 0); + final destination = Rect.fromLTWH(0, 0, size.width, size.height); + canvas.drawImageRect(image, source, destination, Paint()); + if (shader.shouldClipContent) canvas.restore(); + } + + @override + bool shouldRepaint(covariant ShaderStripesPainter oldDelegate) => + oldDelegate.shader != shader || + oldDelegate.properties != properties || + oldDelegate.decoration != decoration || + oldDelegate.elementsPainter != elementsPainter; + + /// Disposes resources owned by this painter. + /// + /// This disposes [_image] which this painter owns. The [shader] delegate + /// is NOT disposed here - its lifecycle is managed by [FlagShaderSurface]. + void dispose() { + _image?.dispose(); + _image = null; + _size = null; + _scale = null; + _clip = null; + } +} diff --git a/packages/world_flags/lib/src/ui/painters/basic/stripes_painter.dart b/packages/world_flags/lib/src/ui/painters/basic/stripes_painter.dart index ac501b132..b9c449fef 100644 --- a/packages/world_flags/lib/src/ui/painters/basic/stripes_painter.dart +++ b/packages/world_flags/lib/src/ui/painters/basic/stripes_painter.dart @@ -1,5 +1,6 @@ import "dart:math"; +import "package:flutter/foundation.dart"; import "package:flutter/rendering.dart"; import "../../../helpers/extensions/box_decoration_extension.dart"; @@ -19,7 +20,12 @@ class StripesPainter extends CustomPainter { /// color and border radius. /// - [elementsPainter]: An optional custom painter for additional elements on /// the flag. - const StripesPainter(this.properties, this.decoration, this.elementsPainter); + const StripesPainter( + this.properties, + this.decoration, + this.elementsPainter, { + super.repaint, + }); /// The properties of the flag. final FlagProperties properties; @@ -40,9 +46,20 @@ class StripesPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - _applyFlagClipping(canvas, size); - // ignore: prefer-correct-identifier-length, CP for [ColorsProperties]. - final total = properties.stripeColors.fold(0, (sum, cp) => sum + cp.ratio); + applyFlagClipping(canvas, size); + paintStripes(canvas, size); + } + + @protected + @visibleForTesting + /// Paints the stripes on the flag based on the [FlagProperties]. Every flag + /// should have at least one stripe. + void paintStripes(Canvas canvas, Size size) { + final total = properties.stripeColors.fold( + 0, + // ignore: prefer-correct-identifier-length, CP for [ColorsProperties]. + (sum, cp) => sum + cp.ratio, + ); switch (properties.stripeOrientation) { case StripeOrientation.horizontal: _drawHorizontalStripes(canvas, size, total); @@ -60,7 +77,9 @@ class StripesPainter extends CustomPainter { elementsPainter?.paint(canvas, size); } - void _applyFlagClipping(Canvas canvas, Size size) { + @protected + /// Applies clipping to the flag based on the [decoration]. + void applyFlagClipping(Canvas canvas, Size size) { final rect = Rect.fromLTWH(0, 0, size.width, size.height); if (decoration.isCircle) { final radius = size.height / 2; @@ -110,6 +129,7 @@ class StripesPainter extends CustomPainter { canvas ..save() + ..clipRect(Rect.fromLTWH(0, 0, size.width, height)) ..translate(size.width / 2, height / 2) ..rotate(isTopLeftToBottom ? angle : -angle) ..translate(-diagonalLength, -height * 2); diff --git a/packages/world_flags/lib/src/ui/painters/custom/blr_painter.dart b/packages/world_flags/lib/src/ui/painters/custom/blr_painter.dart index bfeb64184..30afcfd1c 100644 --- a/packages/world_flags/lib/src/ui/painters/custom/blr_painter.dart +++ b/packages/world_flags/lib/src/ui/painters/custom/blr_painter.dart @@ -168,13 +168,13 @@ final class BlrPainter extends CustomElementsPainter { ..lineTo(width * 0.75, height * 0.03) ..moveTo(width * 0.24, height * 0.9) ..lineTo(width * 0.41, height * 0.96) - ..lineTo(width * 0.24, height * 1.03) + ..lineTo(width * 0.24, height) ..lineTo(width * 0.07, height * 0.96) ..lineTo(width * 0.22, height * 0.9) ..lineTo(width * 0.24, height * 0.89) ..moveTo(width * 0.75, height * 0.89) ..lineTo(width * 0.93, height * 0.96) - ..lineTo(width * 0.75, height * 1.03) + ..lineTo(width * 0.75, height) ..lineTo(width * 0.58, height * 0.96) ..lineTo(width * 0.75, height * 0.89) ..lineTo(width * 0.75, height * 0.89) @@ -182,7 +182,11 @@ final class BlrPainter extends CustomElementsPainter { final bounds = path.getBounds(); final redPaint = paintCreator(customColors.first); - final rect = Rect.fromLTRB(bounds.left, 0, width * 0.99, height * 1.1); + final rect = Rect.fromCenter( + center: bounds.center, + width: width * 0.99, + height: height, + ); canvas ..translate(center.dx - bounds.center.dx, center.dy - bounds.center.dy) diff --git a/packages/world_flags/lib/src/ui/painters/custom/clipped_triangle_painter.dart b/packages/world_flags/lib/src/ui/painters/custom/clipped_triangle_painter.dart new file mode 100644 index 000000000..e3fb45bc1 --- /dev/null +++ b/packages/world_flags/lib/src/ui/painters/custom/clipped_triangle_painter.dart @@ -0,0 +1,36 @@ +import "dart:ui"; + +import "../../../model/typedefs.dart"; +import "../basic/elements_painter.dart"; + +/// Painter that clips Bahrain's serrated seam so shader overflow cannot expose +/// triangles outside the white band while reusing the stock triangle logic. +final class ClippedTrianglePainter extends ElementsPainter { + /// Creates a [ClippedTrianglePainter] with the given [properties] + /// and [aspectRatio]. + const ClippedTrianglePainter(super.properties, super.aspectRatio); + + @override + FlagParentBounds paintFlagElements(Canvas canvas, Size size) { + canvas.clipRect(Offset.zero & size); // Clip to flag bounds/parent stripes. + + return _leftToRightTriangle(canvas, size); + } + + FlagParentBounds _leftToRightTriangle(Canvas canvas, Size size) { + final compensation = (aspectRatio / calculateAspectRatio(size) + 1) / 2; + final width = size.width * (property.widthFactor ?? 1) * compensation; + final height = size.height * property.heightFactor; + final horizontal = (size.width / 2) * (property.offset.dx + 1); + final vertical = (size.height / 2) * (property.offset.dy + 1); + + final path = Path() + ..moveTo(horizontal, vertical) + ..lineTo(horizontal + width, vertical + height / 2) + ..lineTo(horizontal, vertical + height) + ..close(); + canvas.drawPath(path, paintCreator()); + + return (canvas: canvas, bounds: path.getBounds(), child: property.child); + } +} diff --git a/packages/world_flags/lib/src/ui/painters/multi_element_painter.dart b/packages/world_flags/lib/src/ui/painters/multi_element_painter.dart index 45e72a424..099d8fc5a 100644 --- a/packages/world_flags/lib/src/ui/painters/multi_element_painter.dart +++ b/packages/world_flags/lib/src/ui/painters/multi_element_painter.dart @@ -73,6 +73,7 @@ final class MultiElementPainter extends CustomElementsPainter { /// elements. @override FlagParentBounds? paint(Canvas canvas, Size size) { + canvas.clipRect(Offset.zero & size); // Clip to flag bounds/parent stripes. for (final props in properties) { final shape = props.shape; if (shape != null) { diff --git a/packages/world_flags/lib/world_flags.dart b/packages/world_flags/lib/world_flags.dart index b96e46aa2..59849fdd2 100644 --- a/packages/world_flags/lib/world_flags.dart +++ b/packages/world_flags/lib/world_flags.dart @@ -23,6 +23,7 @@ import "src/ui/painters/custom/ata_painter.dart"; import "src/ui/painters/custom/blr_painter.dart"; import "src/ui/painters/custom/brb_painter.dart"; import "src/ui/painters/custom/btn_painter.dart"; +import "src/ui/painters/custom/clipped_triangle_painter.dart"; import "src/ui/painters/custom/cyp_painter.dart"; import "src/ui/painters/custom/david_star_painter.dart"; import "src/ui/painters/custom/eagle_painter.dart"; @@ -87,6 +88,10 @@ export "src/model/typedefs.dart"; export "src/theme/flag_theme_data.dart"; export "src/ui/country_flag.dart"; export "src/ui/decorated_flag_widget.dart"; +export "src/ui/effects/flag_shader_delegate.dart"; +export "src/ui/effects/flag_shader_options.dart"; +export "src/ui/effects/flag_shader_surface.dart"; +export "src/ui/effects/waved_flag_shader_delegate.dart"; export "src/ui/flags/basic_flag.dart"; export "src/ui/flags/ellipse_flag.dart"; export "src/ui/flags/moon_flag.dart"; @@ -97,6 +102,7 @@ export "src/ui/flags/triangle_flag.dart"; export "src/ui/iso_flag.dart"; export "src/ui/painters/basic/custom_elements_painter.dart"; export "src/ui/painters/basic/elements_painter.dart"; +export "src/ui/painters/basic/shader_stripes_painter.dart"; export "src/ui/painters/basic/stripes_painter.dart"; export "src/ui/painters/common/diagonal_line_painter.dart"; export "src/ui/painters/common/ellipse_painter.dart"; @@ -111,6 +117,7 @@ export "src/ui/painters/custom/ata_painter.dart"; export "src/ui/painters/custom/blr_painter.dart"; export "src/ui/painters/custom/brb_painter.dart"; export "src/ui/painters/custom/btn_painter.dart"; +export "src/ui/painters/custom/clipped_triangle_painter.dart"; export "src/ui/painters/custom/cyp_painter.dart"; export "src/ui/painters/custom/david_star_painter.dart"; export "src/ui/painters/custom/eagle_painter.dart"; @@ -194,7 +201,10 @@ const uniqueSimplifiedFlagsMap = { CountryBfa(): StarFlag(flagBfaProperties), CountryBgd(): EllipseFlag(flagBgdProperties), CountryBgr(): BasicFlag(flagBgrProperties), - CountryBhr(): TriangleFlag(flagBhrProperties), + CountryBhr(): BasicFlag( + flagBhrProperties, + elementsBuilder: ClippedTrianglePainter.new, + ), CountryBhs(): TriangleFlag(flagBhsProperties), CountryBih(): MultiElementFlag(flagBihProperties), CountryBlm(): BasicFlag(flagBlmProperties), @@ -547,14 +557,13 @@ const uniqueSimplifiedFlagsMap = { /// ``` const smallSimplifiedFlagsMap = { ...uniqueSimplifiedFlagsMap, - CountryGuf(): BasicFlag(flagGufProperties), + ...smallSimplifiedAlternativeFlagsMap, }; /// Alternative flags for specific countries. As an alternative for flags from -/// the [smallSimplifiedFlagsMap]. For example Afghanistan flag is no longer -/// using the old version but rather using the new flag properties (after 2021). -/// Also French Guiana flag is represented by unofficial, but very popular -/// and more commonly used green-yellow flag with a red star in the center. +/// the [smallSimplifiedFlagsMap]. For French Guiana flag is represented by +/// unofficial, but very popular and more commonly used green-yellow flag with +/// a red star in the center. const smallSimplifiedAlternativeFlagsMap = { CountryGuf(): StarFlag(flagGufPropertiesAlt), }; diff --git a/packages/world_flags/pubspec.yaml b/packages/world_flags/pubspec.yaml index 5496518e6..eabdc8d23 100644 --- a/packages/world_flags/pubspec.yaml +++ b/packages/world_flags/pubspec.yaml @@ -20,7 +20,7 @@ screenshots: path: doc/example.webp environment: - sdk: ^3.9.2 + sdk: ^3.10.4 dependencies: flutter: @@ -33,3 +33,7 @@ dev_dependencies: dart_code_metrics_presets: any # Constrained in the workspace pubspec.yaml flutter_test: # From Google sdk: flutter + +flutter: + shaders: + - shaders/waved_flag.frag diff --git a/packages/world_flags/shaders/waved_flag.frag b/packages/world_flags/shaders/waved_flag.frag new file mode 100644 index 000000000..390ad770c --- /dev/null +++ b/packages/world_flags/shaders/waved_flag.frag @@ -0,0 +1,313 @@ +#version 460 core +#include +precision highp float; + +// Uniforms (indices 0-24, unchanged) +uniform vec2 uSize; +uniform sampler2D uTexture; + +uniform float uTime; +uniform float uAnimationSpeed; + +uniform float uWaveAmplitude; +uniform float uWaveFrequency; +uniform float uWavePhaseShift; +uniform float uSecondaryAmp; +uniform float uSecondaryFreq; + +uniform float uLeftEdgePinned; +uniform float uRightEdgePinned; +uniform float uPinSoftness; +uniform float uPoleMargin; + +uniform float uShadingEnabled; +uniform float uFoldStrength; +uniform float uHighlightStrength; +uniform float uShadowStrength; +uniform float uSheenStrength; +uniform float uSheenFrequency; +uniform float uPerspective; + +uniform float uSeed; +uniform float uWaveDirX; +uniform float uWaveDirY; +uniform float uTurbulence; +uniform float uFabricVisibility; + +out vec4 fragColor; + +// Constants +const float PI = 3.14159265359; +const float TAU = 6.28318530718; +const vec3 LIGHT = vec3(0.371, 0.464, 0.743); + +// ───────────────────────────────────────────────────────────────────────────── +// NOISE +// ───────────────────────────────────────────────────────────────────────────── + +float hash(vec2 p) { + p += vec2(uSeed * 17.31, uSeed * 23.17); + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i), hash(i + vec2(1, 0)), u.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), + u.y + ); +} + +// FBM with 2 octaves - unrolled for SkSL compatibility. +// SkSL requires loop bounds to be compile-time constants; dynamic `int octaves` +// parameter causes "loop index must be compared with a constant" error in tests. +float fbm2(vec2 p) { + float value = 0.0; + float amp = 0.5; + value += amp * noise(p); + amp *= 0.5; + value += amp * noise(p * 2.0); + return value; +} + +// FBM with 3 octaves - unrolled for SkSL compatibility. +float fbm3(vec2 p) { + float value = 0.0; + float amp = 0.5; + value += amp * noise(p); + amp *= 0.5; + value += amp * noise(p * 2.0); + amp *= 0.5; + value += amp * noise(p * 4.0); + return value; +} + +// ───────────────────────────────────────────────────────────────────────────── +// WAVE HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +vec2 waveDir() { + vec2 d = vec2(uWaveDirX, uWaveDirY); + float len = length(d); + return len > 0.001 ? d / len : vec2(1.0, 0.0); +} + +float edgeRamp(float u) { + float r = 1.0; + if (uLeftEdgePinned > 0.5) { + r *= smoothstep(uPoleMargin, uPoleMargin + uPinSoftness, u); + } + if (uRightEdgePinned > 0.5) { + r *= smoothstep(uPoleMargin, uPoleMargin + uPinSoftness, 1.0 - u); + } + return r; +} + +float borderMask(vec2 uv) { + return min(uv.x * (1.0 - uv.x), uv.y * (1.0 - uv.y)) * 4.0; +} + +float hemMask(float v) { + return smoothstep(0.02, 0.1, v) * smoothstep(0.02, 0.1, 1.0 - v); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ORGANIC WAVE +// ───────────────────────────────────────────────────────────────────────────── + +float organicWave(vec2 uv, float baseArg, float turb, float time) { + float wave = sin(baseArg); + + if (turb < 0.02) return wave; + + vec2 noisePos = uv * 3.0 + vec2(time * 0.3, time * 0.2); + + // Frequency modulation: random wave intervals + float freqNoise = noise(noisePos * 0.7) * 2.0 - 1.0; + float modulatedArg = baseArg + turb * freqNoise * 1.5; + + // Amplitude modulation: cotton breathing + float ampNoise = noise(noisePos * 1.3 + 100.0); + float ampMod = 1.0 + turb * (ampNoise - 0.5) * 0.8; + + wave = sin(modulatedArg) * ampMod; + + // Soft harmonics + float h1 = sin(modulatedArg * 2.1 + 0.8) * 0.35; + float h2 = sin(modulatedArg * 3.3 + 1.6) * 0.15; + wave += turb * (h1 + h2); + + // Cotton drift - uses fbm2 (was: fbm(..., 2)) + float drift = fbm2(noisePos * 0.5) * 2.0 - 1.0; + wave += turb * drift * 0.4; + + return wave / (1.0 + turb * 0.7); +} + +float organicDeriv(vec2 uv, float baseArg, float turb, float time) { + float d = cos(baseArg); + + if (turb < 0.02) return d; + + vec2 noisePos = uv * 3.0 + vec2(time * 0.3, time * 0.2); + float freqNoise = noise(noisePos * 0.7) * 2.0 - 1.0; + float modulatedArg = baseArg + turb * freqNoise * 1.5; + + float ampNoise = noise(noisePos * 1.3 + 100.0); + float ampMod = 1.0 + turb * (ampNoise - 0.5) * 0.8; + + d = cos(modulatedArg) * ampMod; + d += turb * 0.35 * 2.1 * cos(modulatedArg * 2.1 + 0.8); + d += turb * 0.15 * 3.3 * cos(modulatedArg * 3.3 + 1.6); + + return d / (1.0 + turb * 0.7); +} + +// ───────────────────────────────────────────────────────────────────────────── +// FABRIC +// ───────────────────────────────────────────────────────────────────────────── + +float fabric(vec2 uv) { + // Uses fbm3 (was: fbm(..., 3)) + float cotton = fbm3(uv * 8.0); + + vec2 t = uv * 55.0; + float warp = abs(fract(t.x) * 2.0 - 1.0); + float weft = abs(fract(t.y + 0.5 * floor(mod(t.x, 2.0))) * 2.0 - 1.0); + float weave = (warp + weft) * 0.5; + + float f = mix(cotton, weave, 0.3); + return 1.0 - 0.08 * (1.0 - f); +} + +// ───────────────────────────────────────────────────────────────────────────── +// WAVE COMPUTATION +// ───────────────────────────────────────────────────────────────────────────── + +vec3 computeWave(vec2 uv, float phase, vec2 dir, float time) { + float ramp = edgeRamp(uv.x); + float border = borderMask(uv); + float hem = hemMask(uv.y); + + float sPhase = uSeed * TAU + uWavePhaseShift; + float k = TAU * uWaveFrequency; + float baseArg = k * dot(uv, dir) - phase + sPhase; + + float localTurb = uTurbulence * mix(0.3, 1.0, hem) * mix(0.5, 1.0, border); + + float wave = organicWave(uv, baseArg, localTurb, time); + float deriv = organicDeriv(uv, baseArg, localTurb, time); + + float secOffset = noise(uv * 2.0 + time * 0.5) * 0.5; + float secArg = TAU * uSecondaryFreq * (uv.x + uv.y * 0.3) - phase * 1.3 + secOffset; + float sec = sin(secArg); + + float total = uWaveAmplitude * wave + uSecondaryAmp * sec; + + vec2 ortho = vec2(-dir.y, dir.x); + vec2 disp = ramp * total * ortho * mix(0.8, 1.0, hem); + disp.x *= border; + + float slope = ramp * uWaveAmplitude * k * deriv * mix(0.75, 1.0, hem); + + return vec3(disp, slope); +} + +// ───────────────────────────────────────────────────────────────────────────── +// UV WARPING +// ───────────────────────────────────────────────────────────────────────────── + +vec2 warpUV(vec2 uv, float phase, vec2 dir, float time, out float slope) { + vec3 wave = computeWave(uv, phase, dir, time); + slope = wave.z; + + float border = borderMask(uv); + float hem = hemMask(uv.y); + + float persp = 1.0; + if (uPerspective > 0.0 && uLeftEdgePinned > 0.5) { + persp = 1.0 - uv.x * uPerspective * 0.1; + } + + float fold = wave.y * uFoldStrength * 0.3 * persp * border * mix(0.6, 1.0, hem); + + vec2 warped = vec2(uv.x + fold + wave.x, uv.y - wave.y); + return clamp(warped, 0.001, 0.999); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LIGHTING (FIXED: shadowStrength now controls ALL darkening) +// ───────────────────────────────────────────────────────────────────────────── + +vec3 light(vec3 color, float slope, vec2 uv, float phase, float fabricMask, float hem) { + if (uShadingEnabled < 0.5) return color; + + // Surface normal from wave slope + vec3 N = normalize(vec3(-slope * 2.5, 0.0, 1.0)); + + // Raw diffuse: 0 (facing away) to 1 (facing light) + float diff = max(dot(N, LIGHT), 0.0); + + // Wrapped diffuse for softer look (range 0.3 to 1.0) + float wrap = min(diff + 0.3, 1.0); + + // Combined shading factor: how much to darken + // At shadowStrength=0: shadeFactor = 1.0 (no darkening) + // At shadowStrength=1: shadeFactor = wrap * extraShadow (full effect) + float extraShadow = 1.0 - (1.0 - wrap) * mix(0.35, 0.6, hem); + float fullShade = wrap * extraShadow; + + // THE FIX: blend between flat (1.0) and shaded based on shadowStrength + float shadeFactor = mix(1.0, fullShade, uShadowStrength); + + // Highlights (additive, always visible if highlightStrength > 0) + float hi = uHighlightStrength * diff * diff * 0.3 * fabricMask; + + // Sheen (silk shimmer) + float sheenArg = (uv.x + uv.y) * uSheenFrequency - phase * 0.35; + float sheen = uSheenStrength * (0.5 + 0.5 * sin(sheenArg)); + sheen *= (1.0 - N.z) * 0.25 * fabricMask * hem; + + return clamp(color * shadeFactor + hi + sheen, 0.0, 1.0); +} + +// ───────────────────────────────────────────────────────────────────────────── +// MAIN +// ───────────────────────────────────────────────────────────────────────────── + +void main() { + vec2 uv = FlutterFragCoord().xy / uSize; + + #ifdef IMPELLER_TARGET_OPENGLES + uv.y = 1.0 - uv.y; + #endif + + vec2 dir = waveDir(); + float time = uTime * uAnimationSpeed; + float phase = time * TAU; + float hem = hemMask(uv.y); + + float slope = 0.0; + vec2 warped = warpUV(uv, phase, dir, time, slope); + + vec4 tex = texture(uTexture, warped); + + // Fabric texture with luminance protection + float fabricMask = 1.0; + if (uFabricVisibility > 0.01) { + float f = fabric(warped); + float lum = dot(tex.rgb, vec3(0.299, 0.587, 0.114)); + float protection = smoothstep(0.85, 1.0, lum); + tex.rgb *= mix(mix(1.0, f, uFabricVisibility), 1.0, protection); + fabricMask = mix(0.4, 0.95, f); + } + + fabricMask *= mix(0.5, 1.0, hem); + + vec3 lit = light(tex.rgb, slope, uv, phase, fabricMask, hem); + + fragColor = vec4(lit * tex.a, tex.a); +} diff --git a/packages/world_flags/test/goldens/simplified/blr.png b/packages/world_flags/test/goldens/simplified/blr.png index 25aa05b34..7364b8591 100644 Binary files a/packages/world_flags/test/goldens/simplified/blr.png and b/packages/world_flags/test/goldens/simplified/blr.png differ diff --git a/packages/world_flags/test/goldens/simplified/isl.png b/packages/world_flags/test/goldens/simplified/isl.png index 3967633bf..ce75829e8 100644 Binary files a/packages/world_flags/test/goldens/simplified/isl.png and b/packages/world_flags/test/goldens/simplified/isl.png differ diff --git a/packages/world_flags/test/goldens/simplified/mhl.png b/packages/world_flags/test/goldens/simplified/mhl.png index 0ed5b1f1f..7b0315134 100644 Binary files a/packages/world_flags/test/goldens/simplified/mhl.png and b/packages/world_flags/test/goldens/simplified/mhl.png differ diff --git a/packages/world_flags/test/goldens/waved/kor.png b/packages/world_flags/test/goldens/waved/kor.png new file mode 100644 index 000000000..7740b2457 Binary files /dev/null and b/packages/world_flags/test/goldens/waved/kor.png differ diff --git a/packages/world_flags/test/goldens/waved/mkd.png b/packages/world_flags/test/goldens/waved/mkd.png new file mode 100644 index 000000000..92b29d354 Binary files /dev/null and b/packages/world_flags/test/goldens/waved/mkd.png differ diff --git a/packages/world_flags/test/goldens/waved/shn.png b/packages/world_flags/test/goldens/waved/shn.png new file mode 100644 index 000000000..c080517d8 Binary files /dev/null and b/packages/world_flags/test/goldens/waved/shn.png differ diff --git a/packages/world_flags/test/goldens/waved/svk.png b/packages/world_flags/test/goldens/waved/svk.png new file mode 100644 index 000000000..f7f9d6212 Binary files /dev/null and b/packages/world_flags/test/goldens/waved/svk.png differ diff --git a/packages/world_flags/test/helpers/extensions/golden_widget_tester_extension.dart b/packages/world_flags/test/helpers/extensions/golden_widget_tester_extension.dart index c8f6b220f..15b1bff44 100644 --- a/packages/world_flags/test/helpers/extensions/golden_widget_tester_extension.dart +++ b/packages/world_flags/test/helpers/extensions/golden_widget_tester_extension.dart @@ -10,13 +10,13 @@ import "../flag_type.dart"; // ignore: avoid-top-level-members-in-tests, it's not a test, but extension. extension GoldenWidgetTesterExtension on WidgetTester { static const _items = { - ...uniqueSimplifiedFlagsMap, - ...smallSimplifiedAlternativeFlagsMap, + ...smallSimplifiedFlagsMap, FiatEur(): StarFlag(flagEurProperties), ...smallSimplifiedLanguageFlagsMap, }; Future flagGolden(T iso, FlagType type) async { + final isWaved = type == FlagType.waved; final aspectRatio = iso.mapWhenOrNull( country: (country) => country.flagProperties?.aspectRatio, ); @@ -25,19 +25,24 @@ extension GoldenWidgetTesterExtension on WidgetTester { final filePath = "../../goldens/${type.name}/${iso.code.toLowerCase()}.png"; await binding.setSurfaceSize(Size(width, height)); + final widget = isWaved + ? FlagShaderSurface(iso, height: height, width: width) + : IsoFlag(iso, _items); + await pumpWidget( MaterialApp( - home: IsoFlag(iso, _items), + home: widget, theme: ThemeData( extensions: [FlagThemeData(decoration: type.decoration)], ), ), ); + if (isWaved) await pump(const Duration(milliseconds: 100)); return expectLater( - find.byType(IsoFlag), - matchesGoldenFile(filePath), - skip: !Platform.isLinux && _ignoreOnNonLinux.contains(iso), + find.byType(isWaved ? FlagShaderSurface : IsoFlag), + matchesGoldenFile(isWaved ? "../$filePath" : filePath), + skip: !Platform.isLinux && (_ignoreOnNonLinux.contains(iso) || isWaved), reason: "Non-Linux platforms rendering those flags slightly differently", ); } diff --git a/packages/world_flags/test/helpers/flag_type.dart b/packages/world_flags/test/helpers/flag_type.dart index 8b5d957fe..3840f2480 100644 --- a/packages/world_flags/test/helpers/flag_type.dart +++ b/packages/world_flags/test/helpers/flag_type.dart @@ -3,11 +3,8 @@ import "package:flutter/material.dart"; // ignore: avoid-top-level-members-in-tests, it support model. enum FlagType { full(height: 320), - simplified( - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4)), - ), - ); + simplified(decoration: BoxDecoration(borderRadius: .all(.circular(4)))), + waved(height: 60); const FlagType({this.decoration, this.height = kMinInteractiveDimension}); diff --git a/packages/world_flags/test/src/ui/effects/flag_shader_options_test.dart b/packages/world_flags/test/src/ui/effects/flag_shader_options_test.dart new file mode 100644 index 000000000..06c9642cb --- /dev/null +++ b/packages/world_flags/test/src/ui/effects/flag_shader_options_test.dart @@ -0,0 +1,232 @@ +import "package:flutter_test/flutter_test.dart"; +import "package:world_flags/src/ui/effects/flag_shader_options.dart"; + +void main() => group("$FlagShaderOptions", () { + const defaultOptions = FlagShaderOptions(); + + test("default values", () { + expect(defaultOptions.animate, isTrue); + expect(defaultOptions.animationSpeed, 1); + expect(defaultOptions.frozenPhase, 0.25); + expect(defaultOptions.waveAmplitude, 0.03); + expect(defaultOptions.waveFrequency, 2); + expect(defaultOptions.wavePhaseShift, 0); + expect(defaultOptions.secondaryAmplitude, 0.02); + expect(defaultOptions.secondaryFrequency, 1.8); + expect(defaultOptions.leftEdgePinned, isTrue); + expect(defaultOptions.rightEdgePinned, isFalse); + expect(defaultOptions.pinSoftness, 0.15); + expect(defaultOptions.poleMargin, 0.02); + expect(defaultOptions.shadingEnabled, isTrue); + expect(defaultOptions.foldStrength, 0.4); + expect(defaultOptions.highlightStrength, 0.3); + expect(defaultOptions.shadowStrength, 0.3); + expect(defaultOptions.sheenStrength, 0.1); + expect(defaultOptions.sheenFrequency, 4); + expect(defaultOptions.perspective, 0.12); + expect(defaultOptions.seed, 1); + expect(defaultOptions.turbulence, 0.8); + expect(defaultOptions.waveDirection, const Offset(0.8, 0.3)); + expect(defaultOptions.fabricVisibility, 0); + expect(defaultOptions.clipContent, isFalse); + expect(defaultOptions.overflowScale, 0.9); + }); + + test("custom values", () { + const options = FlagShaderOptions( + animate: false, + animationSpeed: 2, + frozenPhase: 0.5, + waveAmplitude: 0.05, + waveFrequency: 3, + wavePhaseShift: 0.1, + secondaryAmplitude: 0.03, + secondaryFrequency: 2, + leftEdgePinned: false, + rightEdgePinned: true, + pinSoftness: 0.2, + poleMargin: 0.03, + shadingEnabled: false, + foldStrength: 0.5, + highlightStrength: 0.4, + shadowStrength: 0.4, + sheenStrength: 0.2, + sheenFrequency: 5, + perspective: 0.15, + seed: 2, + turbulence: 0.9, + waveDirection: Offset(1, 0), + fabricVisibility: 0.5, + clipContent: true, + overflowScale: 0.95, + ); + + expect(options.animate, isFalse); + expect(options.animationSpeed, 2); + expect(options.frozenPhase, 0.5); + expect(options.waveAmplitude, 0.05); + expect(options.waveFrequency, 3); + expect(options.wavePhaseShift, 0.1); + expect(options.secondaryAmplitude, 0.03); + expect(options.secondaryFrequency, 2); + expect(options.leftEdgePinned, isFalse); + expect(options.rightEdgePinned, isTrue); + expect(options.pinSoftness, 0.2); + expect(options.poleMargin, 0.03); + expect(options.shadingEnabled, isFalse); + expect(options.foldStrength, 0.5); + expect(options.highlightStrength, 0.4); + expect(options.shadowStrength, 0.4); + expect(options.sheenStrength, 0.2); + expect(options.sheenFrequency, 5); + expect(options.perspective, 0.15); + expect(options.seed, 2); + expect(options.turbulence, 0.9); + expect(options.waveDirection, const Offset(1, 0)); + expect(options.fabricVisibility, 0.5); + expect(options.clipContent, isTrue); + expect(options.overflowScale, 0.95); + }); + + test("toString", () { + const options = FlagShaderOptions(); + final string = options.toString(); + + expect(string, startsWith("FlagShaderOptions(")); + expect(string, contains("animate: true")); + expect(string, contains("animationSpeed: 1")); + expect(string, contains("waveAmplitude: 0.03")); + }); + + test("copyWith - no changes", () { + const options = FlagShaderOptions(); + final copied = options.copyWith(); + + expect(copied, equals(options)); + expect(copied.animate, options.animate); + expect(copied.animationSpeed, options.animationSpeed); + expect(copied.waveAmplitude, options.waveAmplitude); + }); + + test("copyWith - partial changes", () { + const options = FlagShaderOptions(); + final copied = options.copyWith( + animate: false, + waveAmplitude: 0.1, + turbulence: 0.5, + ); + + expect(copied.animate, isFalse); + expect(copied.waveAmplitude, 0.1); + expect(copied.turbulence, 0.5); + // Other values should remain the same. + expect(copied.animationSpeed, options.animationSpeed); + expect(copied.frozenPhase, options.frozenPhase); + expect(copied.waveFrequency, options.waveFrequency); + }); + + test("copyWith - all changes", () { + const base = FlagShaderOptions(); + final modified = base.copyWith( + animate: false, + animationSpeed: 2.5, + frozenPhase: 0.75, + waveAmplitude: 0.06, + waveFrequency: 4, + wavePhaseShift: 0.2, + secondaryAmplitude: 0.04, + secondaryFrequency: 2.2, + leftEdgePinned: false, + rightEdgePinned: true, + pinSoftness: 0.25, + poleMargin: 0.04, + shadingEnabled: false, + foldStrength: 0.6, + highlightStrength: 0.5, + shadowStrength: 0.5, + sheenStrength: 0.3, + sheenFrequency: 6, + perspective: 0.2, + seed: 3, + turbulence: 0.95, + waveDirection: const Offset(0.5, 0.5), + fabricVisibility: 0.8, + clipContent: true, + overflowScale: 0.98, + ); + + expect(modified.animate, isFalse); + expect(modified.animationSpeed, 2.5); + expect(modified.frozenPhase, 0.75); + expect(modified.waveAmplitude, 0.06); + expect(modified.waveFrequency, 4); + expect(modified.wavePhaseShift, 0.2); + expect(modified.secondaryAmplitude, 0.04); + expect(modified.secondaryFrequency, 2.2); + expect(modified.leftEdgePinned, isFalse); + expect(modified.rightEdgePinned, isTrue); + expect(modified.pinSoftness, 0.25); + expect(modified.poleMargin, 0.04); + expect(modified.shadingEnabled, isFalse); + expect(modified.foldStrength, 0.6); + expect(modified.highlightStrength, 0.5); + expect(modified.shadowStrength, 0.5); + expect(modified.sheenStrength, 0.3); + expect(modified.sheenFrequency, 6); + expect(modified.perspective, 0.2); + expect(modified.seed, 3); + expect(modified.turbulence, 0.95); + expect(modified.waveDirection, const Offset(0.5, 0.5)); + expect(modified.fabricVisibility, 0.8); + expect(modified.clipContent, isTrue); + expect(modified.overflowScale, 0.98); + }); + + test("equality - same values", () { + const first = FlagShaderOptions(); + const second = FlagShaderOptions(); + + expect(first, equals(second)); + expect(first.hashCode, equals(second.hashCode)); + }); + + test("equality - different values", () { + const first = FlagShaderOptions(); + const second = FlagShaderOptions(animate: false); + + expect(first, isNot(equals(second))); + }); + + test("equality - different wave direction", () { + const first = FlagShaderOptions(); + const second = FlagShaderOptions(waveDirection: Offset(1, 1)); + + expect(first, isNot(equals(second))); + }); + + test("equality - identical instance", () { + const instance = FlagShaderOptions(); + + expect(instance, equals(instance)); + }); + + test("hashCode - consistent", () { + const instance = FlagShaderOptions( + animate: false, + waveAmplitude: 0.05, + turbulence: 0.6, + ); + + final firstHash = instance.hashCode; + final secondHash = instance.hashCode; + + expect(firstHash, equals(secondHash)); + }); + + test("hashCode - different for different values", () { + const first = FlagShaderOptions(); + const second = FlagShaderOptions(animate: false); + + expect(first.hashCode, isNot(equals(second.hashCode))); + }); +}); diff --git a/packages/world_flags/test/src/ui/effects/flag_shader_surface_test.dart b/packages/world_flags/test/src/ui/effects/flag_shader_surface_test.dart new file mode 100644 index 000000000..9f3c25c71 --- /dev/null +++ b/packages/world_flags/test/src/ui/effects/flag_shader_surface_test.dart @@ -0,0 +1,188 @@ +import "package:flutter/foundation.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:world_flags/world_flags.dart"; + +import "../../../helpers/extensions/golden_widget_tester_extension.dart"; +import "../../../helpers/flag_type.dart"; + +void main() => group("$FlagShaderSurface", () { + testWidgets("renders CustomPaint for shader flags", (tester) async { + await tester.pumpWidget( + const MaterialApp(home: FlagShaderSurface(CountryUsa())), + ); + await tester.pump(); + expect( + find.descendant( + of: find.byType(FlagShaderSurface), + matching: find.byType(CustomPaint), + ), + findsWidgets, + ); + }); + + testWidgets("renders orElse when item not found", (tester) async { + const testWidget = FlutterLogo(key: Key("orElse")); + await tester.pumpWidget( + const MaterialApp( + home: FlagShaderSurface(FiatEur(), map: {}, orElse: testWidget), + ), + ); + expect(find.byKey(const Key("orElse")), findsOneWidget); + }); + + testWidgets("uses alternative map when provided", (tester) async { + const customFlag = BasicFlag( + FlagProperties([ColorsProperties(Color(0xFFFF0000))]), + ); + await tester.pumpWidget( + const MaterialApp( + home: FlagShaderSurface( + CountryUsa(), + alternativeMap: {CountryUsa(): customFlag}, + ), + ), + ); + expect(find.byType(FlagShaderSurface), findsOneWidget); + }); + + testWidgets("applies custom dimensions", (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: FlagShaderSurface(CountryUsa(), width: 200, height: 120), + ), + ); + final sizedBox = tester.widget( + find + .descendant( + of: find.byType(FlagShaderSurface), + matching: find.byType(SizedBox), + ) + .first, + ); + expect(sizedBox.height, 120); + expect(sizedBox.width, 200); + }); + + testWidgets("applies custom aspect ratio", (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: FlagShaderSurface(CountryUsa(), aspectRatio: 1.5), + ), + ); + final aspectRatio = tester.widget( + find + .descendant( + of: find.byType(FlagShaderSurface), + matching: find.byType(AspectRatio), + ) + .first, + ); + expect(aspectRatio.aspectRatio, 1.5); + }); + + testWidgets("toStringShort returns correct format", (tester) async { + const surface = FlagShaderSurface(CountryUsa()); + expect(surface.toStringShort(), "FlagShaderSurface(USA)"); + }); + + testWidgets("debugFillProperties includes relevant props", (tester) async { + const surface = FlagShaderSurface( + CountryUsa(), + height: 100, + width: 200, + aspectRatio: 2, + ); + final builder = DiagnosticPropertiesBuilder(); + surface.debugFillProperties(builder); + final properties = builder.properties; + expect( + properties.any((prop) => prop.name == "iso" && prop.value == "USA"), + isTrue, + ); + expect(properties.any((prop) => prop.name == "options"), isTrue); + expect( + properties.any((prop) => prop.name == "height" && prop.value == 100), + isTrue, + ); + expect( + properties.any((prop) => prop.name == "width" && prop.value == 200), + isTrue, + ); + expect( + properties.any((prop) => prop.name == "aspectRatio" && prop.value == 2), + isTrue, + ); + }); + + testWidgets("updates delegate when options change", (tester) async { + final defaultShader = WavedFlagShaderDelegate(vsync: tester); + final customShader = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(waveAmplitude: 0.05), + ); + await tester.pumpWidget( + MaterialApp( + home: FlagShaderSurface(const CountryUsa(), shader: defaultShader), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: FlagShaderSurface(const CountryUsa(), shader: customShader), + ), + ); + expect(find.byType(FlagShaderSurface), findsOneWidget); + defaultShader.dispose(); + customShader.dispose(); + }); + + testWidgets("uses external shader when provided", (tester) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + await tester.pumpWidget( + MaterialApp(home: FlagShaderSurface(const CountryUsa(), shader: shader)), + ); + expect(find.byType(FlagShaderSurface), findsOneWidget); + shader.dispose(); + }); + + testWidgets("reuses painter when delegate unchanged", (tester) async { + await tester.pumpWidget( + const MaterialApp(home: FlagShaderSurface(CountryUsa())), + ); + await tester.pump(); + final firstPaint = tester.widget( + find + .descendant( + of: find.byType(FlagShaderSurface), + matching: find.byType(CustomPaint), + ) + .first, + ); + await tester.pumpWidget( + const MaterialApp(home: FlagShaderSurface(CountryUsa())), + ); + final secondPaint = tester.widget( + find + .descendant( + of: find.byType(FlagShaderSurface), + matching: find.byType(CustomPaint), + ) + .first, + ); + expect( + identical(firstPaint.painter, secondPaint.painter), + isTrue, + reason: "Painter should be reused (same instance)", + ); + }); + + group("golden", () { + for (final iso in const [.svk(), .kor(), .mkd(), .shn()]) { + // ignore: missing-test-assertion, flagGolden does the job. + testWidgets( + "${iso.internationalName} Waved Flag", + (tester) => tester.flagGolden(iso, FlagType.waved), + ); + } + }); +}); diff --git a/packages/world_flags/test/src/ui/effects/waved_flag_shader_delegate_test.dart b/packages/world_flags/test/src/ui/effects/waved_flag_shader_delegate_test.dart new file mode 100644 index 000000000..6cc9db18b --- /dev/null +++ b/packages/world_flags/test/src/ui/effects/waved_flag_shader_delegate_test.dart @@ -0,0 +1,246 @@ +import "dart:ui"; + +import "package:flutter/material.dart"; +import "package:flutter/scheduler.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:world_flags/src/ui/effects/flag_shader_options.dart"; +import "package:world_flags/src/ui/effects/waved_flag_shader_delegate.dart"; + +void main() => group("$WavedFlagShaderDelegate", () { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets("shouldClipContent follows options", (tester) async { + // Use a mock ticker provider to avoid shader loading issues. + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate( + vsync: tickerProvider, + options: const FlagShaderOptions(clipContent: true), + ); + await tester.pump(); + expect(delegate.shouldClipContent, isTrue); + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("contentScale is 1 when clipContent is true", (tester) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate( + vsync: tickerProvider, + options: const FlagShaderOptions(clipContent: true), + ); + + await tester.pump(); + expect(delegate.contentScale, 1); + + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("contentScale uses overflowScale when clipContent is false", ( + tester, + ) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate( + vsync: tickerProvider, + options: const FlagShaderOptions(overflowScale: 0.85), + ); + await tester.pump(); + expect(delegate.contentScale, 0.85); + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("paintWithShader returns false when size is empty", ( + tester, // ignore: unnecessary-trailing-comma, Dart 3.8 formatting. + ) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate(vsync: tickerProvider); + + await tester.pump(); + final recorder = PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 100, 100), + Paint()..color = Colors.red, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(100, 100); + final dummyRecorder = PictureRecorder(); + final dummyCanvas = Canvas(dummyRecorder); + + final result = delegate.paintWithShader( + dummyCanvas, + Size.zero, + image: image, + ); + expect(result, isFalse); + image.dispose(); + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("dispose releases resources", (tester) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate(vsync: tickerProvider); + await tester.pump(); + expect(delegate.dispose, returnsNormally); + tickerProvider.dispose(); + }); + + testWidgets("handles custom error callback", (tester) async { + Object? capturedError; + StackTrace? capturedStackTrace; + + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate( + vsync: tickerProvider, + onError: (error, stackTrace) { + capturedError = error; + capturedStackTrace = stackTrace; + }, + ); + + await tester.pump(); + final recorder = PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 10, 10), + Paint()..color = Colors.green, + ); + final picture = recorder.endRecording(); + final testImage = await picture.toImage(10, 10); + + final canvasRecorder = PictureRecorder(); + final testCanvas = Canvas(canvasRecorder); + delegate.paintWithShader(testCanvas, const Size(50, 25), image: testImage); + expect(capturedError, isNull, reason: "No error should occur on 1st paint"); + expect(capturedStackTrace, isNull); + testImage.dispose(); + delegate.paintWithShader(testCanvas, const Size(50, 25), image: testImage); + + expect(capturedError, isNotNull); + expect(capturedStackTrace, isNotNull); + + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("implements ChangeNotifier", (tester) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate(vsync: tickerProvider); + + await tester.pump(); + + expect(delegate, isA()); + + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("paintWithShader returns true with shader program", ( + tester, // Dart 3.8 formatting. + ) async { + const tickerProvider = _WavedFlagShaderDelegateTest(); + final delegate = WavedFlagShaderDelegate(vsync: tickerProvider); + + await tester.pump(); + + final recorder = PictureRecorder(); + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 100, 100), + Paint()..color = Colors.blue, + ); + final picture = recorder.endRecording(); + final testImage = await picture.toImage(100, 100); + + final canvasRecorder = PictureRecorder(); + final testCanvas = Canvas(canvasRecorder); + + // With shader loaded, paintWithShader should succeed. + final paintResult = delegate.paintWithShader( + testCanvas, + const Size(200, 100), + image: testImage, + ); + + expect(paintResult, isTrue); + + testImage.dispose(); + delegate.dispose(); + tickerProvider.dispose(); + }); + + testWidgets("animation advances time when animate is true", (tester) async { + final delegate = WavedFlagShaderDelegate(vsync: tester); + await tester.pump(const Duration(milliseconds: 16)); + expect( + delegate, + isA(), + reason: "Delegate should function with animation.", + ); + + delegate.dispose(); + }); + + testWidgets("stops animation when animate is false", (tester) async { + final delegate = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(animate: false), + ); + await tester.pump(); + expect(delegate, isA()); + delegate.dispose(); + }); + + testWidgets("uses default error handler when none provided", (tester) async { + // Create delegate without custom onError - uses _debugPrintError default. + final delegate = WavedFlagShaderDelegate(vsync: tester); + await tester.pump(); + final recorder = PictureRecorder(); // Create a test image. + Canvas(recorder).drawRect( + const Rect.fromLTWH(0, 0, 10, 10), + Paint()..color = Colors.green, + ); + final picture = recorder.endRecording(); + final testImage = await picture.toImage(10, 10); + final canvasRecorder = PictureRecorder(); + final testCanvas = Canvas(canvasRecorder); + delegate.paintWithShader(testCanvas, const Size(50, 25), image: testImage); + testImage.dispose(); // Dispose image to cause error. + final result = delegate.paintWithShader( + testCanvas, + const Size(50, 25), + image: testImage, + ); + expect(result, isFalse, reason: "Should return false when error occurs."); + + delegate.dispose(); + }); + + testWidgets("time wraps around after exceeding 10000", (tester) async { + final delegate = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(animationSpeed: 10000), + ); + + await tester.pump(); // Pump enough frames to exceed the 1e4 threshold. + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 1100)); + expect( + delegate, + isA(), + reason: "Delegate should still function correctly after time wraps.", + ); + + delegate.dispose(); + }); +}); + +/// Test ticker provider that creates tickers without requiring a widget tree. +class _WavedFlagShaderDelegateTest implements TickerProvider { + const _WavedFlagShaderDelegateTest(); + + @override + Ticker createTicker(TickerCallback onTick) => Ticker(onTick); + + // ignore: no-empty-block, no-op: tickers are managed by the delegate itself. + void dispose() {} // Simplified dispose that doesn't track tickers for tests. +} diff --git a/packages/world_flags/test/src/ui/painters/basic/shader_stripes_painter_test.dart b/packages/world_flags/test/src/ui/painters/basic/shader_stripes_painter_test.dart new file mode 100644 index 000000000..012d4d7f5 --- /dev/null +++ b/packages/world_flags/test/src/ui/painters/basic/shader_stripes_painter_test.dart @@ -0,0 +1,251 @@ +// ignore_for_file: no-empty-block, unnecessary-trailing-comma + +import "dart:ui" as ui; + +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:world_flags/src/model/colors_properties.dart"; +import "package:world_flags/src/model/flag_properties.dart"; +import "package:world_flags/src/ui/effects/flag_shader_delegate.dart"; +import "package:world_flags/src/ui/effects/flag_shader_options.dart"; +import "package:world_flags/src/ui/effects/waved_flag_shader_delegate.dart"; +import "package:world_flags/src/ui/painters/basic/shader_stripes_painter.dart"; + +void main() => group("$ShaderStripesPainter", () { + TestWidgetsFlutterBinding.ensureInitialized(); + + const properties = FlagProperties([ + ColorsProperties(Color(0xFFFF0000)), + ColorsProperties(Color(0xFFFFFFFF)), + ColorsProperties(Color(0xFF0000FF)), + ]); + + testWidgets("creates instance with required parameters", (tester) async { + FlagShaderDelegate? shader; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (_) { + shader = WavedFlagShaderDelegate(vsync: tester); + + return const SizedBox(); + }, + ), + ), + ); + + final createdShader = shader; + if (createdShader == null) fail("Shader was not created"); + final painter = ShaderStripesPainter( + properties, + null, + shader: createdShader, + ); + + expect(painter, isA()); + expect(painter.properties, equals(properties)); + expect(painter.shader, equals(createdShader)); + expect(painter.elementsPainter, isNull); + + painter.dispose(); + createdShader.dispose(); + }); + + testWidgets("paints with shader delegate", (tester) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + final painter = ShaderStripesPainter(properties, null, shader: shader); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + + await tester.pump(); + expect(painter.shader, equals(shader)); + shader.dispose(); + }); + + testWidgets("shouldRepaint returns true when shader changes", (tester) async { + final oldShader = WavedFlagShaderDelegate(vsync: tester); + final newShader = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(waveAmplitude: 0.05), + ); + await tester.pump(); + final oldPainter = ShaderStripesPainter( + properties, + null, + shader: oldShader, + ); + final newPainter = ShaderStripesPainter( + properties, + null, + shader: newShader, + ); + expect(newPainter.shouldRepaint(oldPainter), isTrue); + oldPainter.dispose(); + newPainter.dispose(); + oldShader.dispose(); + newShader.dispose(); + }); + + testWidgets("shouldRepaint returns true when properties change", ( + tester, + ) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + await tester.pump(); + const newProperties = FlagProperties([ColorsProperties(Color(0xFF00FF00))]); + final oldPainter = ShaderStripesPainter(properties, null, shader: shader); + final newPainter = ShaderStripesPainter( + newProperties, + null, + shader: shader, + ); + expect(newPainter.shouldRepaint(oldPainter), isTrue); + oldPainter.dispose(); + newPainter.dispose(); + shader.dispose(); + }); + + testWidgets("shouldRepaint returns false when nothing changes", ( + tester, + ) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + await tester.pump(); + final oldPainter = ShaderStripesPainter(properties, null, shader: shader); + final newPainter = ShaderStripesPainter(properties, null, shader: shader); + expect(newPainter.shouldRepaint(oldPainter), isFalse); + oldPainter.dispose(); + newPainter.dispose(); + shader.dispose(); + }); + + testWidgets("dispose clears cached resources", (tester) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + final painter = ShaderStripesPainter(properties, null, shader: shader); + await tester.pump(); // Give shader time to initialize asynchronously. + expect(painter.dispose, returnsNormally); + shader.dispose(); + }); + + testWidgets("rebuilds cache when size changes", (tester) async { + final shader = WavedFlagShaderDelegate(vsync: tester); + final painter = ShaderStripesPainter(properties, null, shader: shader); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(400, 300)), + ), + ), + ); + await tester.pump(); + expect(painter.properties, equals(properties)); + shader.dispose(); + }); + + testWidgets("applies clipping when shouldClipContent is true", ( + tester, + ) async { + final shader = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(clipContent: true), + ); + final painter = ShaderStripesPainter(properties, null, shader: shader); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + await tester.pump(); + expect(shader.shouldClipContent, isTrue); + shader.dispose(); + }); + + testWidgets("contentScale affects painting", (tester) async { + final shader = WavedFlagShaderDelegate( + vsync: tester, + options: const FlagShaderOptions(overflowScale: 0.8), + ); + final painter = ShaderStripesPainter(properties, null, shader: shader); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + await tester.pump(); + expect(shader.contentScale, equals(0.8)); + shader.dispose(); + }); + + testWidgets("paints without shader when image is null", (tester) async { + const shader = _ShaderStripesPainterTest(); + final painter = ShaderStripesPainter(properties, null, shader: shader); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + await tester.pump(); + expect(painter.shader, equals(shader)); + }); + + testWidgets("draws cached image with clipping when needed", (tester) async { + const shader = _ShaderStripesPainterTest(shouldClipContent: true); + final painter = ShaderStripesPainter(properties, null, shader: shader); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomPaint(painter: painter, size: const Size(300, 200)), + ), + ), + ); + await tester.pump(); + // Since paintWithShader returns false, it falls back to _drawCachedImage. + // With clipContent: true, this tests the clipping path in _drawCachedImage. + expect(shader.shouldClipContent, isTrue); + }); +}); + +class _ShaderStripesPainterTest implements FlagShaderDelegate { + const _ShaderStripesPainterTest({this.shouldClipContent = false}); + + @override + final bool shouldClipContent; + + @override + double get contentScale => 1; + + @override + bool paintWithShader( + ui.Canvas destination, + Size size, { + required ui.Image image, + }) => false; + + @override + void dispose() {} + + @override + void addListener(VoidCallback listener) {} + + @override + void removeListener(VoidCallback listener) {} +}