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) {}
+}