1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'package:flutter/material.dart'; |
6 | |
7 | import '../../data/gallery_options.dart'; |
8 | import '../../gallery_localizations.dart'; |
9 | import '../../layout/adaptive.dart'; |
10 | import '../../layout/text_scale.dart'; |
11 | import 'tabs/accounts.dart'; |
12 | import 'tabs/bills.dart'; |
13 | import 'tabs/budgets.dart'; |
14 | import 'tabs/overview.dart'; |
15 | import 'tabs/settings.dart'; |
16 | |
17 | const int tabCount = 5; |
18 | const int turnsToRotateRight = 1; |
19 | const int turnsToRotateLeft = 3; |
20 | |
21 | class HomePage extends StatefulWidget { |
22 | const HomePage({super.key}); |
23 | |
24 | @override |
25 | State<HomePage> createState() => _HomePageState(); |
26 | } |
27 | |
28 | class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin, RestorationMixin { |
29 | late TabController _tabController; |
30 | RestorableInt tabIndex = RestorableInt(0); |
31 | |
32 | @override |
33 | String get restorationId => 'home_page' ; |
34 | |
35 | @override |
36 | void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
37 | registerForRestoration(tabIndex, 'tab_index' ); |
38 | _tabController.index = tabIndex.value; |
39 | } |
40 | |
41 | @override |
42 | void initState() { |
43 | super.initState(); |
44 | _tabController = TabController(length: tabCount, vsync: this)..addListener(() { |
45 | // Set state to make sure that the [_RallyTab] widgets get updated when changing tabs. |
46 | setState(() { |
47 | tabIndex.value = _tabController.index; |
48 | }); |
49 | }); |
50 | } |
51 | |
52 | @override |
53 | void dispose() { |
54 | _tabController.dispose(); |
55 | tabIndex.dispose(); |
56 | super.dispose(); |
57 | } |
58 | |
59 | @override |
60 | Widget build(BuildContext context) { |
61 | final ThemeData theme = Theme.of(context); |
62 | final bool isDesktop = isDisplayDesktop(context); |
63 | Widget tabBarView; |
64 | if (isDesktop) { |
65 | final bool isTextDirectionRtl = |
66 | GalleryOptions.of(context).resolvedTextDirection() == TextDirection.rtl; |
67 | final int verticalRotation = isTextDirectionRtl ? turnsToRotateLeft : turnsToRotateRight; |
68 | final int revertVerticalRotation = |
69 | isTextDirectionRtl ? turnsToRotateRight : turnsToRotateLeft; |
70 | tabBarView = Row( |
71 | children: <Widget>[ |
72 | Container( |
73 | width: 150 + 50 * (cappedTextScale(context) - 1), |
74 | alignment: Alignment.topCenter, |
75 | padding: const EdgeInsets.symmetric(vertical: 32), |
76 | child: Column( |
77 | children: <Widget>[ |
78 | const SizedBox(height: 24), |
79 | ExcludeSemantics( |
80 | child: SizedBox( |
81 | height: 80, |
82 | child: Image.asset('logo.png' , package: 'rally_assets' ), |
83 | ), |
84 | ), |
85 | const SizedBox(height: 24), |
86 | // Rotate the tab bar, so the animation is vertical for desktops. |
87 | RotatedBox( |
88 | quarterTurns: verticalRotation, |
89 | child: _RallyTabBar( |
90 | tabs: |
91 | _buildTabs(context: context, theme: theme, isVertical: true).map(( |
92 | Widget widget, |
93 | ) { |
94 | // Revert the rotation on the tabs. |
95 | return RotatedBox(quarterTurns: revertVerticalRotation, child: widget); |
96 | }).toList(), |
97 | tabController: _tabController, |
98 | ), |
99 | ), |
100 | ], |
101 | ), |
102 | ), |
103 | Expanded( |
104 | // Rotate the tab views so we can swipe up and down. |
105 | child: RotatedBox( |
106 | quarterTurns: verticalRotation, |
107 | child: TabBarView( |
108 | controller: _tabController, |
109 | children: |
110 | _buildTabViews().map((Widget widget) { |
111 | // Revert the rotation on the tab views. |
112 | return RotatedBox(quarterTurns: revertVerticalRotation, child: widget); |
113 | }).toList(), |
114 | ), |
115 | ), |
116 | ), |
117 | ], |
118 | ); |
119 | } else { |
120 | tabBarView = Column( |
121 | children: <Widget>[ |
122 | _RallyTabBar( |
123 | tabs: _buildTabs(context: context, theme: theme), |
124 | tabController: _tabController, |
125 | ), |
126 | Expanded(child: TabBarView(controller: _tabController, children: _buildTabViews())), |
127 | ], |
128 | ); |
129 | } |
130 | return ApplyTextOptions( |
131 | child: Scaffold( |
132 | body: SafeArea( |
133 | // For desktop layout we do not want to have SafeArea at the top and |
134 | // bottom to display 100% height content on the accounts view. |
135 | top: !isDesktop, |
136 | bottom: !isDesktop, |
137 | child: Theme( |
138 | // This theme effectively removes the default visual touch |
139 | // feedback for tapping a tab, which is replaced with a custom |
140 | // animation. |
141 | data: theme.copyWith( |
142 | splashColor: Colors.transparent, |
143 | highlightColor: Colors.transparent, |
144 | ), |
145 | child: FocusTraversalGroup(policy: OrderedTraversalPolicy(), child: tabBarView), |
146 | ), |
147 | ), |
148 | ), |
149 | ); |
150 | } |
151 | |
152 | List<Widget> _buildTabs({ |
153 | required BuildContext context, |
154 | required ThemeData theme, |
155 | bool isVertical = false, |
156 | }) { |
157 | final GalleryLocalizations localizations = GalleryLocalizations.of(context)!; |
158 | return <Widget>[ |
159 | _RallyTab( |
160 | theme: theme, |
161 | iconData: Icons.pie_chart, |
162 | title: localizations.rallyTitleOverview, |
163 | tabIndex: 0, |
164 | tabController: _tabController, |
165 | isVertical: isVertical, |
166 | ), |
167 | _RallyTab( |
168 | theme: theme, |
169 | iconData: Icons.attach_money, |
170 | title: localizations.rallyTitleAccounts, |
171 | tabIndex: 1, |
172 | tabController: _tabController, |
173 | isVertical: isVertical, |
174 | ), |
175 | _RallyTab( |
176 | theme: theme, |
177 | iconData: Icons.money_off, |
178 | title: localizations.rallyTitleBills, |
179 | tabIndex: 2, |
180 | tabController: _tabController, |
181 | isVertical: isVertical, |
182 | ), |
183 | _RallyTab( |
184 | theme: theme, |
185 | iconData: Icons.table_chart, |
186 | title: localizations.rallyTitleBudgets, |
187 | tabIndex: 3, |
188 | tabController: _tabController, |
189 | isVertical: isVertical, |
190 | ), |
191 | _RallyTab( |
192 | theme: theme, |
193 | iconData: Icons.settings, |
194 | title: localizations.rallyTitleSettings, |
195 | tabIndex: 4, |
196 | tabController: _tabController, |
197 | isVertical: isVertical, |
198 | ), |
199 | ]; |
200 | } |
201 | |
202 | List<Widget> _buildTabViews() { |
203 | return const <Widget>[ |
204 | OverviewView(), |
205 | AccountsView(), |
206 | BillsView(), |
207 | BudgetsView(), |
208 | SettingsView(), |
209 | ]; |
210 | } |
211 | } |
212 | |
213 | class _RallyTabBar extends StatelessWidget { |
214 | const _RallyTabBar({required this.tabs, this.tabController}); |
215 | |
216 | final List<Widget> tabs; |
217 | final TabController? tabController; |
218 | |
219 | @override |
220 | Widget build(BuildContext context) { |
221 | return FocusTraversalOrder( |
222 | order: const NumericFocusOrder(0), |
223 | child: TabBar( |
224 | // Setting isScrollable to true prevents the tabs from being |
225 | // wrapped in [Expanded] widgets, which allows for more |
226 | // flexible sizes and size animations among tabs. |
227 | isScrollable: true, |
228 | labelPadding: EdgeInsets.zero, |
229 | tabs: tabs, |
230 | controller: tabController, |
231 | // This hides the tab indicator. |
232 | indicatorColor: Colors.transparent, |
233 | ), |
234 | ); |
235 | } |
236 | } |
237 | |
238 | class _RallyTab extends StatefulWidget { |
239 | _RallyTab({ |
240 | required ThemeData theme, |
241 | IconData? iconData, |
242 | required String title, |
243 | int? tabIndex, |
244 | required TabController tabController, |
245 | required this.isVertical, |
246 | }) : titleText = Text(title, style: theme.textTheme.labelLarge), |
247 | isExpanded = tabController.index == tabIndex, |
248 | icon = Icon(iconData, semanticLabel: title); |
249 | |
250 | final Text titleText; |
251 | final Icon icon; |
252 | final bool isExpanded; |
253 | final bool isVertical; |
254 | |
255 | @override |
256 | _RallyTabState createState() => _RallyTabState(); |
257 | } |
258 | |
259 | class _RallyTabState extends State<_RallyTab> with SingleTickerProviderStateMixin { |
260 | late Animation<double> _titleSizeAnimation; |
261 | late Animation<double> _titleFadeAnimation; |
262 | late Animation<double> _iconFadeAnimation; |
263 | late AnimationController _controller; |
264 | |
265 | @override |
266 | void initState() { |
267 | super.initState(); |
268 | _controller = AnimationController(duration: const Duration(milliseconds: 200), vsync: this); |
269 | _titleSizeAnimation = _controller.view; |
270 | _titleFadeAnimation = _controller.drive(CurveTween(curve: Curves.easeOut)); |
271 | _iconFadeAnimation = _controller.drive(Tween<double>(begin: 0.6, end: 1)); |
272 | if (widget.isExpanded) { |
273 | _controller.value = 1; |
274 | } |
275 | } |
276 | |
277 | @override |
278 | void didUpdateWidget(_RallyTab oldWidget) { |
279 | super.didUpdateWidget(oldWidget); |
280 | if (widget.isExpanded) { |
281 | _controller.forward(); |
282 | } else { |
283 | _controller.reverse(); |
284 | } |
285 | } |
286 | |
287 | @override |
288 | Widget build(BuildContext context) { |
289 | if (widget.isVertical) { |
290 | return Column( |
291 | children: <Widget>[ |
292 | const SizedBox(height: 18), |
293 | FadeTransition(opacity: _iconFadeAnimation, child: widget.icon), |
294 | const SizedBox(height: 12), |
295 | FadeTransition( |
296 | opacity: _titleFadeAnimation, |
297 | child: SizeTransition( |
298 | axisAlignment: -1, |
299 | sizeFactor: _titleSizeAnimation, |
300 | child: Center(child: ExcludeSemantics(child: widget.titleText)), |
301 | ), |
302 | ), |
303 | const SizedBox(height: 18), |
304 | ], |
305 | ); |
306 | } |
307 | |
308 | // Calculate the width of each unexpanded tab by counting the number of |
309 | // units and dividing it into the screen width. Each unexpanded tab is 1 |
310 | // unit, and there is always 1 expanded tab which is 1 unit + any extra |
311 | // space determined by the multiplier. |
312 | final double width = MediaQuery.of(context).size.width; |
313 | const int expandedTitleWidthMultiplier = 2; |
314 | final double unitWidth = width / (tabCount + expandedTitleWidthMultiplier); |
315 | |
316 | return ConstrainedBox( |
317 | constraints: const BoxConstraints(minHeight: 56), |
318 | child: Row( |
319 | children: <Widget>[ |
320 | FadeTransition( |
321 | opacity: _iconFadeAnimation, |
322 | child: SizedBox(width: unitWidth, child: widget.icon), |
323 | ), |
324 | FadeTransition( |
325 | opacity: _titleFadeAnimation, |
326 | child: SizeTransition( |
327 | axis: Axis.horizontal, |
328 | axisAlignment: -1, |
329 | sizeFactor: _titleSizeAnimation, |
330 | child: SizedBox( |
331 | width: unitWidth * expandedTitleWidthMultiplier, |
332 | child: Center(child: ExcludeSemantics(child: widget.titleText)), |
333 | ), |
334 | ), |
335 | ), |
336 | ], |
337 | ), |
338 | ); |
339 | } |
340 | |
341 | @override |
342 | void dispose() { |
343 | _controller.dispose(); |
344 | super.dispose(); |
345 | } |
346 | } |
347 | |