@@ -6,7 +6,6 @@ import 'package:bb_mobile/features/announcements/ui/announcement_navigation.dart
66import 'package:bb_mobile/features/announcements/ui/widgets/announcement_card.dart' ;
77import 'package:bb_mobile/features/announcements/ui/widgets/announcement_dismiss_dialog.dart' ;
88import 'package:bull_ui/bull_ui.dart' ;
9- import 'package:flutter/widgets.dart' show MediaQuery;
109import 'package:flutter_bloc/flutter_bloc.dart' ;
1110import 'package:go_router/go_router.dart' ;
1211
@@ -67,33 +66,78 @@ class _CarouselBody extends StatefulWidget {
6766 State <_CarouselBody > createState () => _CarouselBodyState ();
6867}
6968
70- class _CarouselBodyState extends State <_CarouselBody > {
71- /// Base height (at textScale 1.0) that fits a two-line title+description
72- /// card. Scales with the user's text size so larger accessibility settings
73- /// never overflow.
74- static const double _baseCardHeight = 90 ;
75- static const double _longCardHeight = 170 ;
76-
69+ class _CarouselBodyState extends State <_CarouselBody >
70+ with WidgetsBindingObserver {
7771 /// Extra height reserved for the page-indicator dots strip, only when more
7872 /// than one announcement is shown — reserving it for a single card renders
7973 /// as dead space between the card and the content below it.
80- static const double _dotsStripHeight = 22 ;
74+ static const double _dotsStripHeight = 26 ;
8175
82- late final PageController _controller;
76+ late final ScrollController _controller;
8377 int _page = 0 ;
78+ double ? _lastPageWidth;
8479
8580 @override
8681 void initState () {
8782 super .initState ();
88- _controller = PageController ();
83+ _controller = ScrollController ()..addListener (_onScroll);
84+ WidgetsBinding .instance.addObserver (this );
85+ // Seed the baseline once the controller has a position, so the first
86+ // real width change afterwards has something to compare against.
87+ WidgetsBinding .instance.addPostFrameCallback ((_) {
88+ if (! mounted || ! _controller.hasClients) return ;
89+ _lastPageWidth = _controller.position.viewportDimension;
90+ });
8991 }
9092
9193 @override
9294 void dispose () {
95+ WidgetsBinding .instance.removeObserver (this );
9396 _controller.dispose ();
9497 super .dispose ();
9598 }
9699
100+ /// Keeps the same card on screen when the window resizes (split-screen,
101+ /// a foldable unfolding, DeX).
102+ ///
103+ /// The offset is stored in pixels, not in pages: after a width change it
104+ /// still points at the old pixel, which the page physics then snap to
105+ /// whichever page is now nearest — usually a different card. Re-align on the
106+ /// page we were showing, captured before the new layout runs.
107+ ///
108+ /// `didChangeMetrics` fires for any window-metric change, not only width:
109+ /// a keyboard inset appearing on a route above, system-UI insets, a display
110+ /// change. `jumpTo` goes through `goIdle` , cancelling an in-flight drag or
111+ /// snap animation, so we only realign when the viewport width — the thing
112+ /// that actually moves the page boundaries — has changed.
113+ @override
114+ void didChangeMetrics () {
115+ super .didChangeMetrics ();
116+ final targetPage = _page;
117+ WidgetsBinding .instance.addPostFrameCallback ((_) {
118+ if (! mounted || ! _controller.hasClients) return ;
119+ final pageWidth = _controller.position.viewportDimension;
120+ if (pageWidth <= 0 ) return ;
121+ if (pageWidth == _lastPageWidth) return ;
122+ _lastPageWidth = pageWidth;
123+ _controller.jumpTo (
124+ (targetPage * pageWidth).clamp (0 , _controller.position.maxScrollExtent),
125+ );
126+ });
127+ }
128+
129+ /// Derives the active page from the scroll offset
130+ void _onScroll () {
131+ if (! _controller.hasClients) return ;
132+ final pageWidth = _controller.position.viewportDimension;
133+ if (pageWidth <= 0 ) return ;
134+ final page = (_controller.offset / pageWidth).round ().clamp (
135+ 0 ,
136+ widget.announcements.length - 1 ,
137+ );
138+ if (page != _page) setState (() => _page = page);
139+ }
140+
97141 void _onTap (Announcement announcement) {
98142 switch (announcement.action) {
99143 case NavigateAction ():
@@ -125,53 +169,60 @@ class _CarouselBodyState extends State<_CarouselBody> {
125169 final activePage = _page.clamp (0 , announcements.length - 1 );
126170 final showDots = announcements.length > 1 ;
127171
128- // Adapt to the user's text-scale setting so the card grows with larger
129- // accessibility font sizes instead of overflowing.
130- final textScale = MediaQuery .textScalerOf (context).scale (1 );
131- final baseHeight =
132- announcements.any (
133- (announcement) => announcement.id == AnnouncementId .appUpdateRequired,
134- )
135- ? _longCardHeight
136- : _baseCardHeight;
137- final cardHeight =
138- (baseHeight + (showDots ? _dotsStripHeight : 0 )) * textScale;
139-
140- return SizedBox (
141- height: cardHeight,
142- child: Stack (
143- children: [
144- PageView .builder (
145- controller: _controller,
146- itemCount: announcements.length,
147- onPageChanged: (i) => setState (() => _page = i),
148- itemBuilder: (context, index) {
149- final announcement = announcements[index];
150- // Reserve the dots strip at the bottom so the card's centered
151- // content never collides with the indicator.
152- return Padding (
153- padding: EdgeInsets .only (
154- bottom: showDots ? _dotsStripHeight : 0 ,
155- ),
156- child: AnnouncementCard (
157- announcement: announcement,
158- onTap: () => _onTap (announcement),
159- onDismiss: () => _onDismiss (announcement),
172+ // No fixed height: a horizontally paged scroll view takes its height from
173+ // its child row, which in turn takes the height of the tallest card at
174+ // this width and text scale.
175+ return LayoutBuilder (
176+ builder: (context, constraints) {
177+ return Stack (
178+ children: [
179+ SingleChildScrollView (
180+ controller: _controller,
181+ scrollDirection: Axis .horizontal,
182+ physics: const PageScrollPhysics (),
183+ // The row is only as tall as its tallest card, and every card
184+ // fills that height, so the indicator stays visually attached to
185+ // whichever card is on screen instead of floating below a short
186+ // one. `stretch` needs a bounded height, which the intrinsic
187+ // pass supplies — inside a sliver the row would otherwise be
188+ // laid out against an infinite height.
189+ child: IntrinsicHeight (
190+ child: Row (
191+ crossAxisAlignment: CrossAxisAlignment .stretch,
192+ children: [
193+ for (final announcement in announcements)
194+ SizedBox (
195+ width: constraints.maxWidth,
196+ // Reserve the dots strip at the bottom so the card
197+ // content never collides with the indicator.
198+ child: Padding (
199+ padding: EdgeInsets .only (
200+ bottom: showDots ? _dotsStripHeight : 0 ,
201+ ),
202+ child: AnnouncementCard (
203+ announcement: announcement,
204+ onTap: () => _onTap (announcement),
205+ onDismiss: () => _onDismiss (announcement),
206+ ),
207+ ),
208+ ),
209+ ],
160210 ),
161- );
162- },
163- ),
164- // Dots sit inside the card, bottom-centered, so they clearly belong
165- // to the carousel rather than floating below it.
166- if (showDots)
167- Positioned (
168- left: 0 ,
169- right: 0 ,
170- bottom: 8 ,
171- child: _Dots (count: announcements.length, active: activePage),
211+ ),
172212 ),
173- ],
174- ),
213+ // Dots sit in the strip reserved below the cards, bottom-centered,
214+ // so they clearly belong to the carousel rather than floating
215+ // below it.
216+ if (showDots)
217+ Positioned (
218+ left: 0 ,
219+ right: 0 ,
220+ bottom: 4 ,
221+ child: _Dots (count: announcements.length, active: activePage),
222+ ),
223+ ],
224+ );
225+ },
175226 );
176227 }
177228}
0 commit comments