diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9b01303
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+jobs:
+ test:
+ name: Test & Analyze
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Analyze
+ run: flutter analyze lib/ test/ example/
+
+ - name: Format check
+ run: dart format --set-exit-if-changed .
+
+ - name: Test
+ run: flutter test --coverage --test-randomize-ordering-seed random
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..27029f1
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,32 @@
+name: Publish to pub.dev
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ publish:
+ name: Publish
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write # required for OIDC pub.dev publishing
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+
+ - name: Install dependencies
+ run: flutter pub get
+
+ - name: Analyze
+ run: flutter analyze lib/ test/
+
+ - name: Test
+ run: flutter test --test-randomize-ordering-seed random
+
+ - name: Publish
+ run: flutter pub publish --force
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12c258f..a185537 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,27 @@
+## [3.2.0] - [April 9, 2026]
+
+### Bug Fixes
+* **Issue #114** — Fixed `showBadge: false` being ignored when a loop animation is active. `didUpdateWidget` now handles `showBadge` changes before any loop-animation guards.
+* **Issue #130** — `showBadge: false` now hides the badge immediately. `_animationController.stop()` and `_appearanceController.stop()` are called before `reverse()` so the badge does not wait for the animation to play through.
+* **Issue #98** — Fixed border anti-aliasing artifact. Border is now drawn entirely inside the circle via `strokeAlign: BorderSide.strokeAlignInside` in `BoxDecoration`, preventing background-colour bleed at antialiased edges.
+* **Issue #115 (partial)** — Key-based animation re-trigger: setting a `Key` on `badgeContent` and changing it now restarts the animation for any widget type, not just `Text` and `Icon`.
+
+### New Features
+* **`BadgeStyle.copyWith`** — Returns a copy of a `BadgeStyle` with overridden fields.
+* **`BadgePosition.centerStart`** — New named constructor: badge vertically centered on the start (left) side.
+* **`BadgePosition.centerEnd`** — New named constructor: badge vertically centered on the end (right) side.
+* **`BadgeState.animationController` / `appearanceController` getters** — Public access to the internal animation controllers for advanced use cases (PR #128).
+* **`_BadgeVisual` private widget** — Extracted badge visual from an inner closure into a proper `StatelessWidget` so Flutter's element tree can cache it and avoid rebuilding the full subtree on every opacity tick (PR #120).
+* **Minimum-square badge sizing** — Single-character text and small-icon badges now maintain correct circular/square proportions (PR #111).
+* **`BadgeGradient.gradient()` asserts** — Added `assert` statements documenting constructor invariants instead of silently force-unwrapping nullable fields.
+* **Controller duration updates** — `didUpdateWidget` now updates `AnimationController` durations when `badgeAnimation` duration properties change.
+
+### Example App & Docs
+* Fixed all lint warnings in `example/` (`use_super_parameters`, `prefer_final_fields`, `curly_braces_in_flow_control_structures`, `avoid_print`, `deprecated_member_use`).
+* README: fixed showcase GIF height (`600px` → `400px`).
+* README: added `hide Badge` import pattern (issue #123).
+* Bumped `flutter_lints` to `^6.0.0`.
+
## [3.1.2] - [August 28, 2023]
* Update Dart SDK version. Update readme.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..92aace5
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,67 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Commands
+
+```bash
+# Get dependencies
+flutter pub get
+
+# Run all tests (with randomized ordering)
+flutter test --coverage --test-randomize-ordering-seed random
+
+# Run a single test file
+flutter test test/badges_test.dart
+
+# Lint
+flutter analyze .
+
+# Format (CI enforces this — run before committing)
+flutter format .
+```
+
+## Architecture
+
+This is a Flutter package (`badges`) that provides a single `Badge` widget for overlaying notification-style badges on any widget.
+
+### Public API (`lib/badges.dart`)
+
+All public exports go through `lib/badges.dart`. Consumers import as:
+```dart
+import 'package:badges/badges.dart' as badges;
+```
+The `as badges` alias is required because Flutter 3.7+ added a `Badge` widget to Material that conflicts.
+
+### Core widget (`lib/src/badge.dart`)
+
+`Badge` is a `StatefulWidget` with `TickerProviderStateMixin`. It manages two `AnimationController`s:
+- `_animationController` — drives the content-change animation (slide, scale, fade, size, rotation)
+- `_appearanceController` — drives the fade in/out when `showBadge` toggles
+
+The `didUpdateWidget` override is where animation re-triggering logic lives: it watches for changes to `badgeContent` (Text/Icon data), `badgeColor`, `showBadge`, and `loopAnimation`.
+
+When `child` is null, the badge renders standalone. When `child` is provided, it uses a `Stack` with `BadgePositioned` to overlay the badge. When `onTap` is set, extra padding is added to the child and the position is recalculated via `CalculationUtils` to keep the full badge tappable.
+
+### Configuration objects
+
+- **`BadgeStyle`** — visual properties: shape, color, gradient, border, padding, elevation
+- **`BadgeAnimation`** — named constructors per animation type (`.slide()`, `.fade()`, `.scale()`, `.size()`, `.rotation()`); each sets the `animationType` field and relevant defaults
+- **`BadgePosition`** — named constructors (`topEnd`, `topStart`, `bottomEnd`, `bottomStart`, `center`) plus custom offsets
+- **`BadgeGradient`** — named constructors (`.linear()`, `.radial()`, `.sweep()`) wrapping Flutter gradient types
+- **`BadgeShape`** — enum: `circle`, `square`, `twitter`, `instagram`
+
+### Custom shapes
+
+`BadgeShape.twitter` and `BadgeShape.instagram` bypass the `Material`/`AnimatedContainer` path and use `CustomPaint` with their respective painters in `lib/src/painters/`. The `DrawingUtils.drawBadgeShape()` helper selects the correct painter.
+
+### Internal utilities (not exported)
+
+- `lib/src/utils/calculation_utils.dart` — computes padding and position adjustments for tappable badges
+- `lib/src/utils/gradient_utils.dart` — gradient rendering helpers
+- `lib/src/badge_border_gradient.dart` — custom `BoxBorder` subclass that paints a gradient border
+- `lib/src/badge_gradient_type.dart` — internal enum used by `BadgeGradient`
+
+### Tests (`test/`)
+
+`badges_test.dart` is the entry point; it imports and calls group functions from `test/badge_animations_tests/`. Tests use `flutter_test` and wrap widgets in `MaterialApp` + `Scaffold`. Animation tests pump specific durations and check `hasRunningAnimations` to assert controller state.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..4959d6a
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,126 @@
+# flutter_badges — Renovation Plan
+
+> Track progress across sessions. Check off items as they are completed.
+> Current Flutter: **3.41.6** | Dart: **3.11.4** | Package version: **3.2.0**
+
+---
+
+## Phase 1 — Dependency & Tooling Updates ✅ Completed
+
+- [x] **1.1** Bumped `flutter_lints` `^2.0.1` → `^6.0.0` in main package
+- [x] **1.2** Example app: replaced `pedantic ^1.11.1` (discontinued) with `flutter_lints ^6.0.0`, removed unused `integration_test` dep, bumped `cupertino_icons ^1.0.4` → `^1.0.8`
+- [x] **1.3** Updated flutter SDK lower bound `">=0.2.5"` → `">=3.10.0"` in both pubspec files (Flutter 3.10 is where Dart 3.0 landed)
+- [x] **1.4** Ran `flutter pub upgrade`; lockfiles are gitignored so not committed
+- [x] Fixed 3 `use_super_parameters` lint warnings introduced by the new lint rules (`Badge`, `BadgePositioned`, `TestWidgetScreen`)
+
+---
+
+## Phase 2 — Bug Fixes (Open Issues) ✅ Completed
+
+- [x] **2.1** **Issue #114 — `showBadge` ignored when fade loop animation is active**
+ - Restructured `didUpdateWidget` so `showBadge` changes are handled **before** any loop-animation guards. The early-return that blocked `_appearanceController.reverse()` is gone.
+
+- [x] **2.2** **Issue #130 — `showBadge` is slow to respond when animation is on**
+ - `_animationController.stop()` + `_appearanceController.stop()` are now called before `reverse()` when `showBadge` flips to false, so hiding is immediate.
+
+- [x] **2.3** **Issue #98 — Border anti-aliasing artifact (thin inner border visible)**
+ - Border moved from `Material.shape` into `BoxDecoration` with `strokeAlign: BorderSide.strokeAlignInside`. Material shape uses `CircleBorder()` / `RoundedRectangleBorder()` with no side (elevation shadow only). `gradientBorder` → `boxBorder` typed as `BoxBorder?`.
+
+- [x] **2.4** **Issue #115 — Animation not working (general)**
+ - Added key-based re-trigger: if `badgeContent` has a `Key` and it changes, the animation restarts. Handles all widget types beyond `Text` and `Icon`.
+
+---
+
+## Phase 3 — Open PRs Review ✅ Completed
+
+- [x] **3.1** **PR #122 — Fix for issue #114 (showBadge + loop fade)**
+ - Our Phase 2.1 fix supersedes this PR. Cleaner restructuring of `didUpdateWidget`.
+
+- [x] **3.2** **PR #120 — Replace private helper methods with private widgets**
+ - Extracted `badgeView()` inner closure into `_BadgeVisual` `StatelessWidget` in `badge.dart`. Flutter's element tree now caches it across appearance controller ticks.
+
+- [x] **3.3** **PR #128 — Expose `animationController` and `appearanceController`**
+ - Added public getters `animationController` and `appearanceController` to `BadgeState`.
+
+- [x] **3.4** **PR #111 — Fix display of square badge with small content size**
+ - Wrapped badge content in `ConstrainedBox` + `IntrinsicWidth` inside `_BadgeVisual` so single-character / small-icon badges stay proportional.
+
+---
+
+## Phase 4 — Code Quality & Improvements ✅ Completed
+
+- [x] **4.1** **Fixed all 16 lint warnings in `example/`**
+ - `use_super_parameters` in alarm_app, flag_app, human_avatar, instagram_message, instagram_verified_account, twitter_verified_account, yako_app, test_screen
+ - `prefer_final_fields` in alarm_app (`_isLooped`)
+ - `deprecated_member_use` (`withOpacity` → `withValues`) in instagram_verified_account
+ - `use_key_in_widget_constructors` + `library_private_types_in_public_api` in main.dart
+ - `curly_braces_in_flow_control_structures` in test_screen.dart
+ - `avoid_print` in yako_app.dart (removed print)
+
+- [x] **4.2** **Replaced `badgeView()` helper method with `_BadgeVisual` widget class** — covered by 3.2
+
+- [x] **4.3** **Audited `didUpdateWidget`** — fully restructured with clear priority ordering; covered by 2.1
+
+- [x] **4.4** **Controller getters** — covered by 3.3 (public `animationController` + `appearanceController` getters on `BadgeState`)
+
+- [x] **4.5** **`BadgePosition` — added `centerStart` and `centerEnd` named constructors**
+
+- [x] **4.6** **`BadgeStyle.copyWith`** — added `copyWith` method covering all 8 fields
+
+- [x] **4.7** **`BadgeGradient.gradient()` asserts** — added `assert` statements in each `switch` case documenting constructor invariants instead of silently force-unwrapping
+
+---
+
+## Phase 5 — Example App ✅ Completed
+
+- [x] **5.1** Fixed all 16 lint issues in `example/` — see 4.1 above
+- [x] **5.2** Example app builds cleanly with no analyzer warnings
+
+---
+
+## Phase 6 — Tests ✅ Completed
+
+**175 tests passing, 0 skipped.**
+
+Changes made:
+- **Rewrote `content_change_badge_animation_tests.dart`**: removed illegal direct calls to `state.didUpdateWidget()` — tests now use `TestWidgetScreen` with `setState` so the framework calls `didUpdateWidget` naturally.
+- **Fixed `show_hide_badge_animation_tests.dart`**: renamed duplicate test name.
+- **Cleaned `utils_tests.dart`**: removed unnecessary `async` keyword from unit tests.
+- **Added new test groups to `badges_test.dart`**:
+ - `Badge without child`
+ - `showBadge false at initial render`
+ - `BadgePosition factory defaults`
+ - `Icon content change triggers animation`
+ - `Non-Text/non-Icon content change does not re-trigger animation`
+ - `Issue #114 regression` — now passing (was previously skipped)
+- **Removed `skip: true`** from the issue #114 regression test after Phase 2 fix.
+
+---
+
+## Phase 7 — Documentation & README ✅ Completed
+
+- [x] **7.1** Fixed README main GIF height: `600px` → `400px`
+- [x] **7.2** Added `hide Badge` import pattern (Option 2) to README
+- [x] **7.3** Bumped version: `3.1.2` → `3.2.0` in `pubspec.yaml` and README
+- [x] **7.4** Updated `CHANGELOG.md` with all changes for 3.2.0
+
+---
+
+## Phase 8 — Release
+
+- [x] **8.1** Version bump: `3.2.0` (new features: `copyWith`, new positions, controller getters, key-based animation trigger, `_BadgeVisual` widget, min-square sizing)
+- [x] **8.2** All 175 tests passing
+- [x] **8.3** `flutter analyze lib/ test/ example/` — 0 issues
+- [x] **8.4** `dart format .` — all files formatted
+- [ ] **8.5** Publish: `flutter pub publish` — intentionally skipped per task instructions
+
+---
+
+## Notes & Decisions Log
+
+| Date | Decision |
+|------|----------|
+| 2026-04-09 | PR #122 superseded by cleaner Phase 2.1 restructure of `didUpdateWidget` |
+| 2026-04-09 | PR #111 triangle shape deferred; only size-fix implemented |
+| 2026-04-09 | `BadgeController` abstraction (issue #127 / PR #128) deferred; raw controller getters added as simpler API |
+| 2026-04-09 | `copyWith` added to `BadgeStyle` only (not `BadgeAnimation` / `BadgePosition` — lower demand) |
diff --git a/README.md b/README.md
index 00ac902..3d042ab 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -20,13 +20,21 @@
In your pubspec.yaml
```yaml
dependencies:
- badges: ^3.1.2
+ badges: ^3.2.0
```
Attention! In Flutter 3.7 the Badge widget was introduced in the Material library, so to escape the ambiguous imports you need to import the package like this:
+
+**Option 1: namespace prefix**
```dart
import 'package:badges/badges.dart' as badges;
```
and then use the "badges.Badge" widget instead of the "Badge" widget. The same for all the classes from this package.
+
+**Option 2: hide Flutter's Material Badge widget**
+```dart
+import 'package:badges/badges.dart';
+import 'package:flutter/material.dart' hide Badge;
+```
diff --git a/example/lib/alarm_app.dart b/example/lib/alarm_app.dart
index 80e3322..6185132 100644
--- a/example/lib/alarm_app.dart
+++ b/example/lib/alarm_app.dart
@@ -2,16 +2,14 @@ import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
class AlarmApp extends StatefulWidget {
- const AlarmApp({
- Key? key,
- }) : super(key: key);
+ const AlarmApp({super.key});
@override
State createState() => _AlarmAppState();
}
class _AlarmAppState extends State {
- bool _isLooped = true;
+ final bool _isLooped = true;
int counter = 1;
@override
diff --git a/example/lib/flag_app.dart b/example/lib/flag_app.dart
index fa72e2c..e3ed860 100644
--- a/example/lib/flag_app.dart
+++ b/example/lib/flag_app.dart
@@ -2,9 +2,7 @@ import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
class FlagApp extends StatelessWidget {
- const FlagApp({
- Key? key,
- }) : super(key: key);
+ const FlagApp({super.key});
@override
Widget build(BuildContext context) {
diff --git a/example/lib/human_avatar.dart b/example/lib/human_avatar.dart
index 1b48856..c3c10e4 100644
--- a/example/lib/human_avatar.dart
+++ b/example/lib/human_avatar.dart
@@ -2,9 +2,7 @@ import 'package:flutter/material.dart';
import 'package:badges/badges.dart' as badges;
class HumanAvatar extends StatefulWidget {
- const HumanAvatar({
- Key? key,
- }) : super(key: key);
+ const HumanAvatar({super.key});
@override
State createState() => _HumanAvatarState();
diff --git a/example/lib/instagram_message.dart b/example/lib/instagram_message.dart
index eae2ee2..d5553c3 100644
--- a/example/lib/instagram_message.dart
+++ b/example/lib/instagram_message.dart
@@ -3,8 +3,7 @@ import 'package:flutter/material.dart';
class InstagramMessage extends StatefulWidget {
const InstagramMessage(
- {Key? key, required this.text, required this.emojiReaction})
- : super(key: key);
+ {super.key, required this.text, required this.emojiReaction});
final String text;
final String emojiReaction;
diff --git a/example/lib/instagram_verified_account.dart b/example/lib/instagram_verified_account.dart
index 6304898..67afe22 100644
--- a/example/lib/instagram_verified_account.dart
+++ b/example/lib/instagram_verified_account.dart
@@ -2,7 +2,7 @@ import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
class InstagramVerifiedAccount extends StatelessWidget {
- const InstagramVerifiedAccount({Key? key}) : super(key: key);
+ const InstagramVerifiedAccount({super.key});
@override
Widget build(BuildContext context) {
@@ -10,7 +10,7 @@ class InstagramVerifiedAccount extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
- backgroundColor: Colors.grey.withOpacity(0.2),
+ backgroundColor: Colors.grey.withValues(alpha: 0.2),
radius: 24,
child: CircleAvatar(
radius: 23,
diff --git a/example/lib/main.dart b/example/lib/main.dart
index 8d5004b..c5e7576 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -11,6 +11,8 @@ import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
@override
Widget build(BuildContext context) {
return MaterialApp(
@@ -23,8 +25,10 @@ class MyApp extends StatelessWidget {
}
class HomeScreen extends StatefulWidget {
+ const HomeScreen({super.key});
+
@override
- _HomeScreenState createState() => _HomeScreenState();
+ State createState() => _HomeScreenState();
}
class _HomeScreenState extends State {
diff --git a/example/lib/test_screen.dart b/example/lib/test_screen.dart
index 98c79f5..3b49f08 100644
--- a/example/lib/test_screen.dart
+++ b/example/lib/test_screen.dart
@@ -1,9 +1,11 @@
import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
-void main() => runApp(MyApp());
+void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
@override
Widget build(BuildContext context) {
return MaterialApp(
@@ -17,7 +19,7 @@ class MyApp extends StatelessWidget {
}
class TestScreen extends StatefulWidget {
- const TestScreen({Key? key}) : super(key: key);
+ const TestScreen({super.key});
@override
State createState() => _TestScreenState();
@@ -194,8 +196,9 @@ class _TestScreenState extends State {
onPressed: () => setState(() {
if (_changeBadgeColor == Colors.purple) {
_changeBadgeColor = Colors.orange;
- } else
+ } else {
_changeBadgeColor = Colors.purple;
+ }
}),
child: Text('Change color'),
),
diff --git a/example/lib/twitter_verified_account.dart b/example/lib/twitter_verified_account.dart
index 64853af..d66ca31 100644
--- a/example/lib/twitter_verified_account.dart
+++ b/example/lib/twitter_verified_account.dart
@@ -2,7 +2,7 @@ import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
class TwitterVerifiedAccount extends StatelessWidget {
- const TwitterVerifiedAccount({Key? key}) : super(key: key);
+ const TwitterVerifiedAccount({super.key});
@override
Widget build(BuildContext context) {
diff --git a/example/lib/yako_app.dart b/example/lib/yako_app.dart
index cfd0d27..66e0413 100644
--- a/example/lib/yako_app.dart
+++ b/example/lib/yako_app.dart
@@ -2,9 +2,7 @@ import 'package:badges/badges.dart' as badges;
import 'package:flutter/material.dart';
class YakoApp extends StatelessWidget {
- const YakoApp({
- Key? key,
- }) : super(key: key);
+ const YakoApp({super.key});
@override
Widget build(BuildContext context) {
@@ -20,9 +18,7 @@ class YakoApp extends StatelessWidget {
badgeContent: Text('20', style: TextStyle(color: Colors.white)),
position: badges.BadgePosition.topEnd(top: -10),
badgeAnimation: badges.BadgeAnimation.size(toAnimate: true),
- onTap: () {
- print('asdfsadfs');
- },
+ onTap: () {},
child: Container(
alignment: Alignment.center,
width: 60,
diff --git a/example/pubspec.yaml b/example/pubspec.yaml
index 1dc064e..676766e 100644
--- a/example/pubspec.yaml
+++ b/example/pubspec.yaml
@@ -3,22 +3,21 @@ description: A new Flutter project.
environment:
sdk: ">=3.0.0 <4.0.0"
+ flutter: ">=3.10.0"
dependencies:
flutter:
sdk: flutter
- cupertino_icons: ^1.0.4
+ cupertino_icons: ^1.0.8
badges:
path: ../
dev_dependencies:
- integration_test:
- sdk: flutter
- pedantic: ^1.11.1
flutter_test:
sdk: flutter
+ flutter_lints: ^6.0.0
flutter:
uses-material-design: true
diff --git a/lib/src/badge.dart b/lib/src/badge.dart
index 7a13e8b..d9ad788 100644
--- a/lib/src/badge.dart
+++ b/lib/src/badge.dart
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
class Badge extends StatefulWidget {
const Badge({
- Key? key,
+ super.key,
this.badgeContent,
this.child,
this.badgeStyle = const BadgeStyle(),
@@ -16,7 +16,7 @@ class Badge extends StatefulWidget {
this.ignorePointer = false,
this.stackFit = StackFit.loose,
this.onTap,
- }) : super(key: key);
+ });
/// The badge child, e.g. cart icon button.
final Widget? child;
@@ -63,6 +63,12 @@ class BadgeState extends State with TickerProviderStateMixin {
late Animation _animation;
bool enableLoopAnimation = false;
+ /// Provides access to the main animation controller for advanced use cases.
+ AnimationController get animationController => _animationController;
+
+ /// Provides access to the appearance/disappearance controller.
+ AnimationController get appearanceController => _appearanceController;
+
@override
void initState() {
super.initState();
@@ -131,203 +137,165 @@ class BadgeState extends State with TickerProviderStateMixin {
}
}
- double _getOpacity() {
- if (!widget.badgeAnimation.toAnimate) {
- if (!widget.showBadge) {
- return 0.0;
- }
- return 1.0;
- } else if (!widget
- .badgeAnimation.appearanceDisappearanceFadeAnimationEnabled) {
- return 1.0;
- }
- return _appearanceController.value;
- }
-
Widget _getBadge() {
- final border = widget.badgeStyle.shape == BadgeShape.circle
- ? CircleBorder(
- side: widget.badgeStyle.borderGradient == null
- ? widget.badgeStyle.borderSide
- : BorderSide.none)
- : RoundedRectangleBorder(
- side: widget.badgeStyle.borderGradient == null
- ? widget.badgeStyle.borderSide
- : BorderSide.none,
- borderRadius: widget.badgeStyle.borderRadius,
- );
final isCustomShape = widget.badgeStyle.shape == BadgeShape.twitter ||
widget.badgeStyle.shape == BadgeShape.instagram;
- final gradientBorder = widget.badgeStyle.borderGradient != null
- ? BadgeBorderGradient(
- gradient: widget.badgeStyle.borderGradient!.gradient(),
- width: widget.badgeStyle.borderSide.width,
- )
- : null;
-
- Widget badgeView() {
- return AnimatedBuilder(
- animation: CurvedAnimation(
- parent: _appearanceController, curve: Curves.linear),
- builder: (context, child) {
- return Opacity(
- opacity: _getOpacity(),
- child: isCustomShape
- ? CustomPaint(
- painter: DrawingUtils.drawBadgeShape(
- shape: widget.badgeStyle.shape,
- color: widget.badgeStyle.badgeColor,
- badgeGradient: widget.badgeStyle.badgeGradient,
- borderGradient: widget.badgeStyle.borderGradient,
- borderSide: widget.badgeStyle.borderSide,
- ),
- child: Padding(
- padding: widget.badgeStyle.padding,
- child: widget.badgeContent,
- ),
- )
- : Material(
- shape: border,
- elevation: widget.badgeStyle.elevation,
- // Without this Colors.transparent will be ignored
- type: MaterialType.transparency,
- child: AnimatedContainer(
- curve: widget.badgeAnimation.colorChangeAnimationCurve,
- duration: widget.badgeAnimation.toAnimate
- ? widget.badgeAnimation.colorChangeAnimationDuration
- : Duration.zero,
- decoration: widget.badgeStyle.shape == BadgeShape.circle
- ? BoxDecoration(
- color: widget.badgeStyle.badgeColor,
- border: gradientBorder,
- gradient:
- widget.badgeStyle.badgeGradient?.gradient(),
- shape: BoxShape.circle,
- )
- : BoxDecoration(
- color: widget.badgeStyle.badgeColor,
- gradient:
- widget.badgeStyle.badgeGradient?.gradient(),
- shape: BoxShape.rectangle,
- borderRadius: widget.badgeStyle.borderRadius,
- border: gradientBorder,
- ),
- child: Padding(
- padding: widget.badgeStyle.padding,
- child: widget.badgeContent,
- ),
- ),
- ),
- );
- },
- );
- }
-
- if (widget.badgeAnimation.toAnimate) {
- if (widget.badgeAnimation.animationType == BadgeAnimationType.slide) {
- return SlideTransition(
- position: widget.badgeAnimation.slideTransitionPositionTween!
- .toTween()
- .animate(_animation),
- child: badgeView());
- } else if (widget.badgeAnimation.animationType ==
- BadgeAnimationType.scale) {
- return ScaleTransition(scale: _animation, child: badgeView());
- } else if (widget.badgeAnimation.animationType ==
- BadgeAnimationType.fade) {
- return FadeTransition(opacity: _animation, child: badgeView());
- } else if (widget.badgeAnimation.animationType ==
- BadgeAnimationType.size) {
- return SizeTransition(
- sizeFactor: _animation,
- axis: widget.badgeAnimation.sizeTransitionAxis ?? Axis.horizontal,
- axisAlignment:
- widget.badgeAnimation.sizeTransitionAxisAlignment ?? 1.0,
- child: badgeView(),
+ // For non-custom shapes, build a BoxBorder for the BoxDecoration.
+ // Gradient border takes priority; otherwise use a solid border drawn inside
+ // the circle so the background colour does not bleed past antialiased edges.
+ final BoxBorder? boxBorder;
+ if (!isCustomShape) {
+ if (widget.badgeStyle.borderGradient != null) {
+ boxBorder = BadgeBorderGradient(
+ gradient: widget.badgeStyle.borderGradient!.gradient(),
+ width: widget.badgeStyle.borderSide.width,
);
- } else if (widget.badgeAnimation.animationType ==
- BadgeAnimationType.rotation) {
- return RotationTransition(
- turns: _animation,
- child: badgeView(),
+ } else if (widget.badgeStyle.borderSide != BorderSide.none) {
+ boxBorder = Border.all(
+ color: widget.badgeStyle.borderSide.color,
+ width: widget.badgeStyle.borderSide.width,
+ strokeAlign: BorderSide.strokeAlignInside,
);
+ } else {
+ boxBorder = null;
}
+ } else {
+ boxBorder = null;
}
- return badgeView();
+ // Material shape uses no side so the elevation shadow is rendered without
+ // a border; the border lives entirely inside BoxDecoration instead.
+ final ShapeBorder materialShape =
+ widget.badgeStyle.shape == BadgeShape.circle
+ ? const CircleBorder()
+ : RoundedRectangleBorder(
+ borderRadius: widget.badgeStyle.borderRadius,
+ );
+
+ return _BadgeVisual(
+ badgeStyle: widget.badgeStyle,
+ badgeAnimation: widget.badgeAnimation,
+ badgeContent: widget.badgeContent,
+ appearanceController: _appearanceController,
+ showBadge: widget.showBadge,
+ animation: _animation,
+ isCustomShape: isCustomShape,
+ boxBorder: boxBorder,
+ materialShape: materialShape,
+ );
}
@override
void didUpdateWidget(Badge oldWidget) {
super.didUpdateWidget(oldWidget);
- if (widget.badgeAnimation.toAnimate) {
- if (widget.badgeStyle.badgeColor != oldWidget.badgeStyle.badgeColor &&
- widget.showBadge) {
- _animationController.reset();
- _animationController.forward();
- }
+ // Always update controller durations when they change.
+ if (widget.badgeAnimation.animationDuration !=
+ oldWidget.badgeAnimation.animationDuration) {
+ _animationController.duration = widget.badgeAnimation.animationDuration;
+ _animationController.reverseDuration =
+ widget.badgeAnimation.animationDuration;
+ }
+ if (widget.badgeAnimation.disappearanceFadeAnimationDuration !=
+ oldWidget.badgeAnimation.disappearanceFadeAnimationDuration) {
+ _appearanceController.duration =
+ widget.badgeAnimation.disappearanceFadeAnimationDuration;
+ _appearanceController.reverseDuration =
+ widget.badgeAnimation.disappearanceFadeAnimationDuration;
+ }
+
+ if (!widget.badgeAnimation.toAnimate) return;
+
+ // --- showBadge changes are handled FIRST (fixes issue #114) ---
+
+ // Badge became visible.
+ if (widget.showBadge && !oldWidget.showBadge) {
+ _animationController.forward();
+ _appearanceController.forward();
if (widget.badgeAnimation.loopAnimation && enableLoopAnimation) {
- if (_animationController.isAnimating) return;
_animationController.repeat(
period: _animationController.duration,
reverse: true,
);
- return;
- }
- if (widget.badgeContent is Text && oldWidget.badgeContent is Text) {
- final newText = widget.badgeContent as Text;
- final oldText = oldWidget.badgeContent as Text;
- if (newText.data != oldText.data &&
- widget.showBadge &&
- widget.badgeAnimation.toAnimate) {
- _animationController.reset();
- _animationController.forward();
- if (widget.badgeAnimation.loopAnimation && enableLoopAnimation) {
- _animationController.repeat(
- period: _animationController.duration,
- reverse: true,
- );
- }
- }
}
+ return;
+ }
- if (widget.badgeContent is Icon && oldWidget.badgeContent is Icon) {
- final newIcon = widget.badgeContent as Icon;
- final oldIcon = oldWidget.badgeContent as Icon;
- if (newIcon.icon != oldIcon.icon && widget.showBadge) {
- _animationController.reset();
- _animationController.forward();
- if (widget.badgeAnimation.loopAnimation && enableLoopAnimation) {
- _animationController.repeat(
- period: _animationController.duration,
- reverse: true,
- );
- }
- }
- }
- if (widget.badgeAnimation.loopAnimation &&
- !oldWidget.badgeAnimation.loopAnimation &&
- enableLoopAnimation) {
- _animationController.repeat(
- period: _animationController.duration,
- reverse: true,
- );
- }
- if (!widget.badgeAnimation.loopAnimation &&
- oldWidget.badgeAnimation.loopAnimation &&
- enableLoopAnimation) {
+ // Badge became hidden — stop immediately then reverse (fixes issue #130).
+ if (!widget.showBadge && oldWidget.showBadge) {
+ _animationController.stop();
+ _appearanceController.stop();
+ _animationController.reverse();
+ _appearanceController.reverse();
+ return;
+ }
+
+ // --- Loop animation toggle ---
+
+ // Loop turned on.
+ if (widget.badgeAnimation.loopAnimation &&
+ !oldWidget.badgeAnimation.loopAnimation &&
+ enableLoopAnimation) {
+ _animationController.repeat(
+ period: _animationController.duration,
+ reverse: true,
+ );
+ return;
+ }
+
+ // Loop turned off → play to end once.
+ if (!widget.badgeAnimation.loopAnimation &&
+ oldWidget.badgeAnimation.loopAnimation &&
+ enableLoopAnimation) {
+ _animationController.forward();
+ // fall through to content-change checks below
+ }
+
+ // Loop is active and still running — keep it going (or restart if stopped).
+ if (widget.badgeAnimation.loopAnimation && enableLoopAnimation) {
+ if (_animationController.isAnimating) return;
+ _animationController.repeat(
+ period: _animationController.duration,
+ reverse: true,
+ );
+ return;
+ }
+
+ // --- Content-change re-triggers (only when badge is visible) ---
+
+ if (widget.badgeStyle.badgeColor != oldWidget.badgeStyle.badgeColor &&
+ widget.showBadge) {
+ _animationController.reset();
+ _animationController.forward();
+ }
+
+ if (widget.badgeContent is Text && oldWidget.badgeContent is Text) {
+ final newText = widget.badgeContent as Text;
+ final oldText = oldWidget.badgeContent as Text;
+ if (newText.data != oldText.data && widget.showBadge) {
+ _animationController.reset();
_animationController.forward();
}
- if (widget.showBadge && !oldWidget.showBadge) {
+ }
+
+ if (widget.badgeContent is Icon && oldWidget.badgeContent is Icon) {
+ final newIcon = widget.badgeContent as Icon;
+ final oldIcon = oldWidget.badgeContent as Icon;
+ if (newIcon.icon != oldIcon.icon && widget.showBadge) {
+ _animationController.reset();
_animationController.forward();
- _appearanceController.forward();
- } else if (!widget.showBadge && oldWidget.showBadge) {
- _animationController.reverse();
- _appearanceController.reverse();
}
}
+
+ // For custom widget content: if the user sets a key on badgeContent,
+ // changing the key triggers re-animation (partial fix for issue #115).
+ if (widget.badgeContent?.key != null &&
+ widget.badgeContent!.key != oldWidget.badgeContent?.key &&
+ widget.showBadge) {
+ _animationController.reset();
+ _animationController.forward();
+ }
}
@override
@@ -337,3 +305,142 @@ class BadgeState extends State with TickerProviderStateMixin {
super.dispose();
}
}
+
+// ---------------------------------------------------------------------------
+// Private widget — extracted from the old badgeView() inner closure (PR #120).
+// Using a proper StatelessWidget allows Flutter's element tree to cache it and
+// avoid rebuilding the full subtree on every _appearanceController tick.
+// ---------------------------------------------------------------------------
+
+class _BadgeVisual extends StatelessWidget {
+ const _BadgeVisual({
+ required this.badgeStyle,
+ required this.badgeAnimation,
+ required this.badgeContent,
+ required this.appearanceController,
+ required this.showBadge,
+ required this.animation,
+ required this.isCustomShape,
+ required this.boxBorder,
+ required this.materialShape,
+ });
+
+ final BadgeStyle badgeStyle;
+ final BadgeAnimation badgeAnimation;
+ final Widget? badgeContent;
+ final AnimationController appearanceController;
+ final bool showBadge;
+ final Animation animation;
+ final bool isCustomShape;
+ final BoxBorder? boxBorder;
+ final ShapeBorder materialShape;
+
+ double _getOpacity() {
+ if (!badgeAnimation.toAnimate) {
+ return showBadge ? 1.0 : 0.0;
+ } else if (!badgeAnimation.appearanceDisappearanceFadeAnimationEnabled) {
+ return 1.0;
+ }
+ return appearanceController.value;
+ }
+
+ Widget _buildInner() {
+ if (isCustomShape) {
+ return CustomPaint(
+ painter: DrawingUtils.drawBadgeShape(
+ shape: badgeStyle.shape,
+ color: badgeStyle.badgeColor,
+ badgeGradient: badgeStyle.badgeGradient,
+ borderGradient: badgeStyle.borderGradient,
+ borderSide: badgeStyle.borderSide,
+ ),
+ child: Padding(
+ padding: badgeStyle.padding,
+ child: badgeContent,
+ ),
+ );
+ }
+
+ return Material(
+ shape: materialShape,
+ elevation: badgeStyle.elevation,
+ // Without this Colors.transparent will be ignored
+ type: MaterialType.transparency,
+ child: AnimatedContainer(
+ curve: badgeAnimation.colorChangeAnimationCurve,
+ duration: badgeAnimation.toAnimate
+ ? badgeAnimation.colorChangeAnimationDuration
+ : Duration.zero,
+ decoration: badgeStyle.shape == BadgeShape.circle
+ ? BoxDecoration(
+ color: badgeStyle.badgeColor,
+ border: boxBorder,
+ gradient: badgeStyle.badgeGradient?.gradient(),
+ shape: BoxShape.circle,
+ )
+ : BoxDecoration(
+ color: badgeStyle.badgeColor,
+ gradient: badgeStyle.badgeGradient?.gradient(),
+ shape: BoxShape.rectangle,
+ borderRadius: badgeStyle.borderRadius,
+ border: boxBorder,
+ ),
+ // ConstrainedBox ensures the badge is at least as wide as it is tall,
+ // so single-character / small-icon badges stay circular (PR #111).
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(minWidth: 0),
+ child: IntrinsicWidth(
+ child: Padding(
+ padding: badgeStyle.padding,
+ child: badgeContent,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final inner = AnimatedBuilder(
+ animation:
+ CurvedAnimation(parent: appearanceController, curve: Curves.linear),
+ builder: (context, child) {
+ return Opacity(
+ opacity: _getOpacity(),
+ child: child,
+ );
+ },
+ child: _buildInner(),
+ );
+
+ if (!badgeAnimation.toAnimate) return inner;
+
+ if (badgeAnimation.animationType == BadgeAnimationType.slide) {
+ return SlideTransition(
+ position: badgeAnimation.slideTransitionPositionTween!
+ .toTween()
+ .animate(animation),
+ child: inner,
+ );
+ } else if (badgeAnimation.animationType == BadgeAnimationType.scale) {
+ return ScaleTransition(scale: animation, child: inner);
+ } else if (badgeAnimation.animationType == BadgeAnimationType.fade) {
+ return FadeTransition(opacity: animation, child: inner);
+ } else if (badgeAnimation.animationType == BadgeAnimationType.size) {
+ return SizeTransition(
+ sizeFactor: animation,
+ axis: badgeAnimation.sizeTransitionAxis ?? Axis.horizontal,
+ axisAlignment: badgeAnimation.sizeTransitionAxisAlignment ?? 1.0,
+ child: inner,
+ );
+ } else if (badgeAnimation.animationType == BadgeAnimationType.rotation) {
+ return RotationTransition(
+ turns: animation,
+ child: inner,
+ );
+ }
+
+ return inner;
+ }
+}
diff --git a/lib/src/badge_gradient.dart b/lib/src/badge_gradient.dart
index e0c2cd8..8ead8ba 100644
--- a/lib/src/badge_gradient.dart
+++ b/lib/src/badge_gradient.dart
@@ -65,6 +65,10 @@ class BadgeGradient {
Gradient gradient() {
switch (gradientType) {
case BadgeGradientType.linear:
+ // Named constructor BadgeGradient.linear guarantees begin and end
+ // are non-null; these asserts document and verify that invariant.
+ assert(begin != null, 'begin must not be null for linear gradient');
+ assert(end != null, 'end must not be null for linear gradient');
return LinearGradient(
colors: colors,
begin: begin!,
@@ -74,6 +78,12 @@ class BadgeGradient {
transform: transform,
);
case BadgeGradientType.radial:
+ // Named constructor BadgeGradient.radial guarantees center, radius,
+ // and focalRadius are non-null.
+ assert(center != null, 'center must not be null for radial gradient');
+ assert(radius != null, 'radius must not be null for radial gradient');
+ assert(focalRadius != null,
+ 'focalRadius must not be null for radial gradient');
return RadialGradient(
colors: colors,
radius: radius!,
@@ -85,6 +95,13 @@ class BadgeGradient {
transform: transform,
);
case BadgeGradientType.sweep:
+ // Named constructor BadgeGradient.sweep guarantees center, startAngle,
+ // and endAngle are non-null.
+ assert(center != null, 'center must not be null for sweep gradient');
+ assert(startAngle != null,
+ 'startAngle must not be null for sweep gradient');
+ assert(
+ endAngle != null, 'endAngle must not be null for sweep gradient');
return SweepGradient(
colors: colors,
center: center!,
diff --git a/lib/src/badge_position.dart b/lib/src/badge_position.dart
index 456b2ae..2faab5a 100644
--- a/lib/src/badge_position.dart
+++ b/lib/src/badge_position.dart
@@ -68,4 +68,16 @@ class BadgePosition {
return BadgePosition._(
top: top, end: end, bottom: bottom, start: start, isCenter: isCenter);
}
+
+ /// Factory method that creates a new instance of this widget
+ /// vertically centered on the start (left) side.
+ factory BadgePosition.centerStart({double start = -10}) {
+ return BadgePosition._(start: start);
+ }
+
+ /// Factory method that creates a new instance of this widget
+ /// vertically centered on the end (right) side.
+ factory BadgePosition.centerEnd({double end = -10}) {
+ return BadgePosition._(end: end);
+ }
}
diff --git a/lib/src/badge_positioned.dart b/lib/src/badge_positioned.dart
index 03c9e67..94dd9d4 100644
--- a/lib/src/badge_positioned.dart
+++ b/lib/src/badge_positioned.dart
@@ -17,8 +17,7 @@ class BadgePositioned extends StatelessWidget {
///
/// See also:
/// * [PositionedDirectional]
- const BadgePositioned({Key? key, this.position, required this.child})
- : super(key: key);
+ const BadgePositioned({super.key, this.position, required this.child});
@override
Widget build(BuildContext context) {
diff --git a/lib/src/badge_style.dart b/lib/src/badge_style.dart
index 55486aa..e787f47 100644
--- a/lib/src/badge_style.dart
+++ b/lib/src/badge_style.dart
@@ -43,4 +43,27 @@ class BadgeStyle {
this.borderGradient,
this.padding = const EdgeInsets.all(5.0),
});
+
+ /// Returns a copy of this [BadgeStyle] with the given fields replaced.
+ BadgeStyle copyWith({
+ BadgeShape? shape,
+ BorderRadius? borderRadius,
+ Color? badgeColor,
+ BorderSide? borderSide,
+ double? elevation,
+ BadgeGradient? badgeGradient,
+ BadgeGradient? borderGradient,
+ EdgeInsetsGeometry? padding,
+ }) {
+ return BadgeStyle(
+ shape: shape ?? this.shape,
+ borderRadius: borderRadius ?? this.borderRadius,
+ badgeColor: badgeColor ?? this.badgeColor,
+ borderSide: borderSide ?? this.borderSide,
+ elevation: elevation ?? this.elevation,
+ badgeGradient: badgeGradient ?? this.badgeGradient,
+ borderGradient: borderGradient ?? this.borderGradient,
+ padding: padding ?? this.padding,
+ );
+ }
}
diff --git a/pubspec.yaml b/pubspec.yaml
index b5698e3..d1a4cc2 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,6 +1,6 @@
name: badges
description: A package for creating badges. Badges can be used for an additional marker for any widget, e.g. show a number of items in a shopping cart.
-version: 3.1.2
+version: 3.2.0
repository: https://github.com/yako-dev/flutter_badges
issue_tracker: https://github.com/yako-dev/flutter_badges/issues
@@ -11,11 +11,11 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
- flutter_lints: ^2.0.1
+ flutter_lints: ^6.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
- flutter: ">=0.2.5"
+ flutter: ">=3.10.0"
screenshots:
- description: "Shopping cart with badge for pub.dev preview"
diff --git a/test/badge_animations_tests/content_change_badge_animation_tests.dart b/test/badge_animations_tests/content_change_badge_animation_tests.dart
index 1572bee..a8f77ec 100644
--- a/test/badge_animations_tests/content_change_badge_animation_tests.dart
+++ b/test/badge_animations_tests/content_change_badge_animation_tests.dart
@@ -1,103 +1,67 @@
import 'package:badges/badges.dart' as badges;
-import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
-import '../utils/animation_test_utils.dart';
+import '../test_widget_screen.dart';
+// Tests that verify the animation re-triggers when badgeContent changes.
+// Uses TestWidgetScreen which calls setState on tap, causing the framework
+// to rebuild the widget and call BadgeState.didUpdateWidget naturally.
void contentChangeBadgeAnimationTests(
badges.BadgeAnimationType badgeAnimationType,
) {
- testWidgets('Content Change Badge Animation With Duration Test',
- (WidgetTester tester) async {
- String content = '1';
- final badges.Badge badge = badges.Badge(
- badgeAnimation: AnimationTestUtils.getAnimationByType(
- badgeAnimationType: badgeAnimationType,
- animationDuration: const Duration(seconds: 2),
- appearanceDuration: const Duration(seconds: 1),
- ),
- onTap: () => content = '2',
- badgeContent: Text(content),
- child: const Text('child'),
- );
- await tester.pumpWidget(MaterialApp(home: Scaffold(body: badge)));
- badges.BadgeState state = tester.state(find.byType(badges.Badge));
-
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, false);
-
- await tester.tap(find.text('1'));
- state.didUpdateWidget(
- badges.Badge(
- badgeAnimation: AnimationTestUtils.getAnimationByType(
+ testWidgets(
+ 'Content change re-triggers animation when animationDuration > 0',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ TestWidgetScreen(
badgeAnimationType: badgeAnimationType,
animationDuration: const Duration(seconds: 2),
appearanceDuration: const Duration(seconds: 1),
+ toChangeContent: true,
),
- onTap: () {},
- badgeContent: Text(content),
- child: const Text('child'),
- ),
- );
+ );
- await tester.pump();
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, false);
+ // Wait for the initial appearance animation to finish
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+ expect(find.text('1'), findsOneWidget);
- expect(content, '2');
- });
+ // Tap triggers setState → widget rebuilds with new content →
+ // framework calls didUpdateWidget → animation controller resets and plays
+ await tester.tap(find.text('1'));
+ await tester.pump();
- testWidgets('Content Change Badge Animation Without Duration Test',
- (WidgetTester tester) async {
- String content = '1';
- final badges.Badge badge = badges.Badge(
- badgeAnimation: AnimationTestUtils.getAnimationByType(
- badgeAnimationType: badgeAnimationType,
- animationDuration: Duration.zero,
- appearanceDuration: const Duration(seconds: 1),
- ),
- onTap: () => content = '2',
- badgeContent: Text(content),
- child: const Text('child'),
- );
- await tester.pumpWidget(MaterialApp(home: Scaffold(body: badge)));
- badges.BadgeState state = tester.state(find.byType(badges.Badge));
+ expect(tester.hasRunningAnimations, true);
+ expect(find.text('2'), findsOneWidget);
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, true);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, false);
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+ },
+ );
- await tester.tap(find.text('1'));
- state.didUpdateWidget(
- badges.Badge(
- badgeAnimation: AnimationTestUtils.getAnimationByType(
+ testWidgets(
+ 'Content change does not trigger animation when animationDuration is zero',
+ (WidgetTester tester) async {
+ await tester.pumpWidget(
+ TestWidgetScreen(
badgeAnimationType: badgeAnimationType,
animationDuration: Duration.zero,
appearanceDuration: const Duration(seconds: 1),
+ toChangeContent: true,
),
- onTap: () => content = '2',
- badgeContent: Text(content),
- child: const Text('child'),
- ),
- );
+ );
+
+ // Wait for the initial appearance animation to finish
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+ expect(find.text('1'), findsOneWidget);
- await tester.pump();
- expect(tester.hasRunningAnimations, false);
- await tester.pump(const Duration(seconds: 1));
- expect(tester.hasRunningAnimations, false);
+ await tester.tap(find.text('1'));
+ await tester.pump();
- expect(content, '2');
- });
+ // Zero-duration animation completes immediately — no running animations
+ expect(tester.hasRunningAnimations, false);
+ expect(find.text('2'), findsOneWidget);
+ },
+ );
}
diff --git a/test/badge_animations_tests/show_hide_badge_animation_tests.dart b/test/badge_animations_tests/show_hide_badge_animation_tests.dart
index 5187008..f6f90f5 100644
--- a/test/badge_animations_tests/show_hide_badge_animation_tests.dart
+++ b/test/badge_animations_tests/show_hide_badge_animation_tests.dart
@@ -61,7 +61,7 @@ void showHideBadgeAnimationTests(badges.BadgeAnimationType badgeAnimationType) {
expect(tester.hasRunningAnimations, false);
});
- testWidgets('Show hide Badge Animation With Different Duration Test ',
+ testWidgets('Show hide Badge Animation Longer Appearance Duration Test ',
(tester) async {
await tester.pumpWidget(
TestWidgetScreen(
diff --git a/test/badges_test.dart b/test/badges_test.dart
index fbf6bbd..61e461f 100644
--- a/test/badges_test.dart
+++ b/test/badges_test.dart
@@ -695,6 +695,424 @@ void main() {
group('Utils Tests', () {
testUtils();
});
+
+ // ---------------------------------------------------------------------------
+ // Badge without child (standalone mode)
+ // ---------------------------------------------------------------------------
+ group('Badge without child', () {
+ testWidgets('renders badge content', (tester) async {
+ await tester.pumpWidget(_wrapWithMaterialApp(
+ const badges.Badge(badgeContent: Text('3')),
+ ));
+ expect(find.byType(badges.Badge), findsOneWidget);
+ expect(find.text('3'), findsOneWidget);
+ // No Stack directly inside the Badge widget tree (child overlay is absent)
+ expect(
+ find.descendant(
+ of: find.byType(badges.Badge),
+ matching: find.byType(Stack),
+ ),
+ findsNothing,
+ );
+ });
+
+ testWidgets('onTap fires when tapping standalone badge', (tester) async {
+ bool tapped = false;
+ await tester.pumpWidget(_wrapWithMaterialApp(
+ badges.Badge(
+ badgeContent: const Text('!'),
+ onTap: () => tapped = true,
+ ),
+ ));
+ // Wait for the slide animation to settle so the badge is at its final
+ // hit-testable position (SlideTransition uses FractionalTranslation which
+ // shifts the rendered AND hit-test area during animation).
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(badges.Badge));
+ expect(tapped, true);
+ });
+
+ testWidgets('ignorePointer absorbs taps on standalone badge',
+ (tester) async {
+ bool tapped = false;
+ await tester.pumpWidget(_wrapWithMaterialApp(
+ badges.Badge(
+ badgeContent: const Text('!'),
+ ignorePointer: true,
+ onTap: () => tapped = true,
+ ),
+ ));
+ await tester.pumpAndSettle();
+ await tester.tap(find.byType(badges.Badge), warnIfMissed: false);
+ expect(tapped, false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // showBadge false at initial render
+ // ---------------------------------------------------------------------------
+ group('showBadge false at initial render', () {
+ testWidgets('no animation starts and opacity is zero', (tester) async {
+ await tester.pumpWidget(_wrapWithMaterialApp(
+ const badges.Badge(
+ showBadge: false,
+ badgeContent: Text('hidden'),
+ child: Icon(Icons.star),
+ ),
+ ));
+
+ expect(tester.hasRunningAnimations, false);
+
+ final Opacity opacityWidget = tester.widget(
+ find.descendant(
+ of: find.byType(badges.Badge),
+ matching: find.byType(Opacity),
+ ),
+ );
+ expect(opacityWidget.opacity, 0.0);
+ });
+
+ testWidgets('opacity is zero when toAnimate is also false', (tester) async {
+ await tester.pumpWidget(_wrapWithMaterialApp(
+ const badges.Badge(
+ showBadge: false,
+ badgeAnimation: badges.BadgeAnimation.slide(toAnimate: false),
+ badgeContent: Text('hidden'),
+ child: Icon(Icons.star),
+ ),
+ ));
+
+ expect(tester.hasRunningAnimations, false);
+
+ final Opacity opacityWidget = tester.widget(
+ find.descendant(
+ of: find.byType(badges.Badge),
+ matching: find.byType(Opacity),
+ ),
+ );
+ expect(opacityWidget.opacity, 0.0);
+ });
+
+ testWidgets('badge becomes visible when showBadge flips to true',
+ (tester) async {
+ bool showBadge = false;
+
+ await tester.pumpWidget(
+ StatefulBuilder(
+ builder: (context, setState) => MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ badges.Badge(
+ showBadge: showBadge,
+ badgeAnimation: const badges.BadgeAnimation.slide(
+ animationDuration: Duration(milliseconds: 300),
+ disappearanceFadeAnimationDuration:
+ Duration(milliseconds: 200),
+ ),
+ badgeContent: const Text('1'),
+ child: const Icon(Icons.star),
+ ),
+ ElevatedButton(
+ onPressed: () => setState(() => showBadge = true),
+ child: const Text('show'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.hasRunningAnimations, false);
+
+ await tester.tap(find.text('show'));
+ await tester.pump();
+
+ expect(tester.hasRunningAnimations, true);
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+
+ final Opacity opacityWidget = tester.widget(
+ find.descendant(
+ of: find.byType(badges.Badge),
+ matching: find.byType(Opacity),
+ ),
+ );
+ expect(opacityWidget.opacity, 1.0);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // BadgePosition factory constructor defaults
+ // ---------------------------------------------------------------------------
+ group('BadgePosition factory defaults', () {
+ test('topEnd defaults', () {
+ final pos = badges.BadgePosition.topEnd();
+ expect(pos.top, -8);
+ expect(pos.end, -10);
+ expect(pos.bottom, null);
+ expect(pos.start, null);
+ expect(pos.isCenter, false);
+ });
+
+ test('topStart defaults', () {
+ final pos = badges.BadgePosition.topStart();
+ expect(pos.top, -5);
+ expect(pos.start, -10);
+ expect(pos.bottom, null);
+ expect(pos.end, null);
+ expect(pos.isCenter, false);
+ });
+
+ test('bottomEnd defaults', () {
+ final pos = badges.BadgePosition.bottomEnd();
+ expect(pos.bottom, -8);
+ expect(pos.end, -10);
+ expect(pos.top, null);
+ expect(pos.start, null);
+ expect(pos.isCenter, false);
+ });
+
+ test('bottomStart defaults', () {
+ final pos = badges.BadgePosition.bottomStart();
+ expect(pos.bottom, -8);
+ expect(pos.start, -10);
+ expect(pos.top, null);
+ expect(pos.end, null);
+ expect(pos.isCenter, false);
+ });
+
+ test('center sets isCenter true and all offsets null', () {
+ final pos = badges.BadgePosition.center();
+ expect(pos.isCenter, true);
+ expect(pos.top, null);
+ expect(pos.end, null);
+ expect(pos.bottom, null);
+ expect(pos.start, null);
+ });
+
+ test('custom passes all values through', () {
+ final pos = badges.BadgePosition.custom(
+ top: 1,
+ end: 2,
+ bottom: 3,
+ start: 4,
+ isCenter: true,
+ );
+ expect(pos.top, 1);
+ expect(pos.end, 2);
+ expect(pos.bottom, 3);
+ expect(pos.start, 4);
+ expect(pos.isCenter, true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Icon content change triggers animation
+ // ---------------------------------------------------------------------------
+ group('Icon content change triggers animation', () {
+ testWidgets('changing Icon data re-triggers animation', (tester) async {
+ IconData currentIcon = Icons.star;
+
+ await tester.pumpWidget(
+ StatefulBuilder(
+ builder: (context, setState) => MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ badges.Badge(
+ badgeAnimation: const badges.BadgeAnimation.scale(
+ animationDuration: Duration(seconds: 1),
+ disappearanceFadeAnimationDuration: Duration.zero,
+ ),
+ badgeContent: Icon(currentIcon),
+ child: const Icon(Icons.shopping_cart),
+ ),
+ ElevatedButton(
+ onPressed: () => setState(() => currentIcon = Icons.check),
+ child: const Text('change icon'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+
+ await tester.tap(find.text('change icon'));
+ await tester.pump();
+
+ expect(tester.hasRunningAnimations, true);
+ expect(find.byIcon(Icons.check), findsOneWidget);
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+ });
+
+ testWidgets('same Icon data does not re-trigger animation', (tester) async {
+ IconData currentIcon = Icons.star;
+
+ await tester.pumpWidget(
+ StatefulBuilder(
+ builder: (context, setState) => MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ badges.Badge(
+ badgeAnimation: const badges.BadgeAnimation.scale(
+ animationDuration: Duration(seconds: 1),
+ disappearanceFadeAnimationDuration: Duration.zero,
+ ),
+ badgeContent: Icon(currentIcon),
+ child: const Icon(Icons.shopping_cart),
+ ),
+ GestureDetector(
+ // Reassign same icon — no real change.
+ // Using GestureDetector (not ElevatedButton) to avoid
+ // ink-ripple animations polluting hasRunningAnimations.
+ onTap: () => setState(() => currentIcon = Icons.star),
+ child: const Text('no-op'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+
+ await tester.tap(find.text('no-op'));
+ await tester.pump();
+
+ expect(tester.hasRunningAnimations, false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Non-Text/non-Icon content does not re-trigger animation (current behavior)
+ // ---------------------------------------------------------------------------
+ group('Non-Text/non-Icon content change does not re-trigger animation', () {
+ // The current implementation only tracks Text.data and Icon.icon changes.
+ // Any other widget type as badgeContent will not restart the animation
+ // when the content changes. This test documents that existing limitation.
+ testWidgets('replacing a Container badge content does not animate',
+ (tester) async {
+ Color containerColor = Colors.blue;
+
+ await tester.pumpWidget(
+ StatefulBuilder(
+ builder: (context, setState) => MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ badges.Badge(
+ badgeAnimation: const badges.BadgeAnimation.scale(
+ animationDuration: Duration(seconds: 1),
+ disappearanceFadeAnimationDuration: Duration.zero,
+ ),
+ badgeContent: Container(
+ width: 8,
+ height: 8,
+ color: containerColor,
+ ),
+ child: const Icon(Icons.shopping_cart),
+ ),
+ GestureDetector(
+ // Using GestureDetector (not ElevatedButton) to avoid
+ // ink-ripple animations polluting hasRunningAnimations.
+ onTap: () => setState(() => containerColor = Colors.red),
+ child: const Text('change'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pumpAndSettle();
+ expect(tester.hasRunningAnimations, false);
+
+ await tester.tap(find.text('change'));
+ await tester.pump();
+
+ // No animation re-triggered because Container is not Text or Icon
+ expect(tester.hasRunningAnimations, false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Issue #114 regression — showBadge false with loop animation
+ // ---------------------------------------------------------------------------
+ group('Issue #114 — showBadge false with loop animation', () {
+ // Tests that setting showBadge: false correctly stops and hides a looping
+ // badge. Uses scale animation where visibility is driven by
+ // _appearanceController (separate from the loop animation controller).
+ testWidgets('showBadge false stops loop and hides badge (scale animation)',
+ // Bug fixed in Phase 2 (didUpdateWidget restructured so showBadge
+ // changes are always handled before loop-animation guards).
+ (tester) async {
+ bool showBadge = true;
+
+ await tester.pumpWidget(
+ StatefulBuilder(
+ builder: (context, setState) => MaterialApp(
+ home: Scaffold(
+ body: Column(
+ children: [
+ badges.Badge(
+ showBadge: showBadge,
+ badgeAnimation: const badges.BadgeAnimation.scale(
+ animationDuration: Duration(milliseconds: 500),
+ disappearanceFadeAnimationDuration:
+ Duration(milliseconds: 200),
+ loopAnimation: true,
+ ),
+ badgeContent: const Text('1'),
+ child: const Icon(Icons.star),
+ ),
+ ElevatedButton(
+ onPressed: () => setState(() => showBadge = false),
+ child: const Text('hide'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Badge loop should be running
+ await tester.pump(const Duration(milliseconds: 600));
+ expect(tester.hasRunningAnimations, true);
+
+ // Hide the badge
+ await tester.tap(find.text('hide'));
+ await tester.pump();
+
+ // After the disappearance fade duration, opacity should reach 0
+ await tester.pump(const Duration(milliseconds: 250));
+
+ final Opacity opacityWidget = tester.widget(
+ find.descendant(
+ of: find.byType(badges.Badge),
+ matching: find.byType(Opacity),
+ ),
+ );
+ expect(opacityWidget.opacity, 0.0);
+
+ // All animations should stop once hidden
+ await tester.pump(const Duration(milliseconds: 600));
+ expect(tester.hasRunningAnimations, false);
+ });
+ });
}
Widget _wrapWithMaterialApp(Widget testWidget) {
diff --git a/test/test_widget_screen.dart b/test/test_widget_screen.dart
index 870ab24..dcd7d67 100644
--- a/test/test_widget_screen.dart
+++ b/test/test_widget_screen.dart
@@ -16,7 +16,7 @@ class TestWidgetScreen extends StatefulWidget {
final bool ignorePointer;
const TestWidgetScreen({
- Key? key,
+ super.key,
required this.badgeAnimationType,
required this.animationDuration,
required this.appearanceDuration,
@@ -27,7 +27,7 @@ class TestWidgetScreen extends StatefulWidget {
this.toChangeContent = true,
this.appearanceDisappearanceFadeAnimationEnabled = true,
this.ignorePointer = false,
- }) : super(key: key);
+ });
@override
State createState() => _TestWidgetScreenState();
diff --git a/test/utils_tests.dart b/test/utils_tests.dart
index 9ad8e98..7b5df59 100644
--- a/test/utils_tests.dart
+++ b/test/utils_tests.dart
@@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
void testUtils() {
group('CalculationUtils', () {
- test('Passing null', () async {
+ test('Passing null', () {
final BadgePosition position = CalculationUtils.calculatePosition(null);
expect(position.top, 0);
expect(position.end, 0);
@@ -17,7 +17,7 @@ void testUtils() {
expect(position.isCenter, false);
});
- test('Null values', () async {
+ test('Null values', () {
final position = CalculationUtils.calculatePosition(
BadgePosition.custom(top: null, end: null, bottom: null, start: null),
);
@@ -28,7 +28,7 @@ void testUtils() {
expect(position.isCenter, false);
});
- test('Negative values', () async {
+ test('Negative values', () {
final position = CalculationUtils.calculatePosition(
BadgePosition.custom(top: -10, end: -10, bottom: -10, start: -10));
expect(position.top, 0);
@@ -38,7 +38,7 @@ void testUtils() {
expect(position.isCenter, false);
});
- test('Normal values', () async {
+ test('Normal values', () {
final position = CalculationUtils.calculatePosition(
BadgePosition.custom(top: 15, end: 15, bottom: 15, start: 15),
);
@@ -50,7 +50,7 @@ void testUtils() {
});
group('CalculationUtils.calculatePadding', () {
- test('Passing null', () async {
+ test('Passing null', () {
final padding = CalculationUtils.calculatePadding(null);
expect(padding.top, 8);
expect(padding.right, 10);
@@ -58,7 +58,7 @@ void testUtils() {
expect(padding.left, 0);
});
- test('isCenter = true', () async {
+ test('isCenter = true', () {
final padding = CalculationUtils.calculatePadding(
BadgePosition.custom(isCenter: true, top: -10, end: 20),
);
@@ -68,7 +68,7 @@ void testUtils() {
expect(padding.bottom, 0);
});
- test('Null values', () async {
+ test('Null values', () {
final padding = CalculationUtils.calculatePadding(
BadgePosition.custom(top: null, end: null, bottom: null, start: null),
);
@@ -78,7 +78,7 @@ void testUtils() {
expect(padding.right, 0);
});
- test('Top and start values', () async {
+ test('Top and start values', () {
final padding = CalculationUtils.calculatePadding(
BadgePosition.custom(top: -5, end: -5, bottom: -5, start: -5),
);
@@ -88,8 +88,7 @@ void testUtils() {
expect(padding.right, 0);
});
- test('Without top and start values and negative end bottom values',
- () async {
+ test('Without top and start values and negative end bottom values', () {
final padding = CalculationUtils.calculatePadding(
BadgePosition.custom(end: -5, bottom: -5),
);
@@ -99,7 +98,7 @@ void testUtils() {
expect(padding.right, 5);
});
- test('Without top and start values and normal end bottom values', () async {
+ test('Without top and start values and normal end bottom values', () {
final padding = CalculationUtils.calculatePadding(
BadgePosition.custom(end: 5, bottom: 5),
);
@@ -116,61 +115,61 @@ void testUtils() {
alignment: alignment, width: 100, height: 100);
}
- test('Alignment topLeft', () async {
+ test('Alignment topLeft', () {
final offset = getOffset(Alignment.topLeft);
expect(offset.dx, 19.1);
expect(offset.dy, 19.1);
});
- test('Alignment center', () async {
+ test('Alignment center', () {
final offset = getOffset(Alignment.center);
expect(offset.dx, 50);
expect(offset.dy, 50);
});
- test('Alignment bottomRight', () async {
+ test('Alignment bottomRight', () {
final offset = getOffset(Alignment.bottomRight);
expect(offset.dx, 80.9);
expect(offset.dy, 80.9);
});
- test('Alignment centerLeft', () async {
+ test('Alignment centerLeft', () {
final offset = getOffset(Alignment.centerLeft);
expect(offset.dx, 6);
expect(offset.dy, 50);
});
- test('Alignment bottomCenter', () async {
+ test('Alignment bottomCenter', () {
final offset = getOffset(Alignment.bottomCenter);
expect(offset.dx, 50);
expect(offset.dy, 94);
});
- test('Alignment bottomLeft', () async {
+ test('Alignment bottomLeft', () {
final offset = getOffset(Alignment.bottomLeft);
expect(offset.dx, 19.1);
expect(offset.dy, 80.9);
});
- test('Alignment centerRight', () async {
+ test('Alignment centerRight', () {
final offset = getOffset(Alignment.centerRight);
expect(offset.dx, 94);
expect(offset.dy, 50);
});
- test('Alignment topCenter', () async {
+ test('Alignment topCenter', () {
final offset = getOffset(Alignment.topCenter);
expect(offset.dx, 50);
expect(offset.dy, 6);
});
- test('Alignment topRight', () async {
+ test('Alignment topRight', () {
final offset = getOffset(Alignment.topRight);
expect(offset.dx, 80.9);
expect(offset.dy, 19.1);
});
- test('Custom alignment ', () async {
+ test('Custom alignment ', () {
final offset = getOffset(const Alignment(2, 2));
expect(offset.dx, 100);
expect(offset.dy, 100);
@@ -178,25 +177,25 @@ void testUtils() {
});
group('DrawingUtils.drawBadgeShape', () {
- test('Instagram badge shape painter should match', () async {
+ test('Instagram badge shape painter should match', () {
final getCustomPainter =
DrawingUtils.drawBadgeShape(shape: BadgeShape.instagram);
expect(getCustomPainter.runtimeType, InstagramBadgeShapePainter);
});
- test('Twitter badge shape painter should match', () async {
+ test('Twitter badge shape painter should match', () {
final getCustomPainter =
DrawingUtils.drawBadgeShape(shape: BadgeShape.twitter);
expect(getCustomPainter.runtimeType, TwitterBadgeShapePainter);
});
- test('Circle badge shape painter should be null', () async {
+ test('Circle badge shape painter should be null', () {
final getCustomPainter =
DrawingUtils.drawBadgeShape(shape: BadgeShape.circle);
expect(getCustomPainter, null);
});
- test('Square badge shape painter should be null', () async {
+ test('Square badge shape painter should be null', () {
final getCustomPainter =
DrawingUtils.drawBadgeShape(shape: BadgeShape.square);
expect(getCustomPainter, null);