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);