kasy-cli 1.25.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/new.js +63 -3
- package/lib/utils/i18n/messages-en.js +4 -0
- package/lib/utils/i18n/messages-es.js +4 -0
- package/lib/utils/i18n/messages-pt.js +4 -0
- package/package.json +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +12 -6
- package/templates/firebase/lib/components/kasy_avatar.dart +17 -10
- package/templates/firebase/lib/components/kasy_checkbox.dart +24 -15
- package/templates/firebase/lib/components/kasy_date_picker.dart +27 -20
- package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +19 -14
- package/templates/firebase/lib/components/kasy_tabs.dart +75 -65
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +98 -2
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +45 -2
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +9 -4
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -0
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +2 -0
- package/templates/firebase/lib/features/home/home_feed.dart +21 -5
- package/templates/firebase/lib/features/home/home_image_grid.dart +83 -58
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +124 -101
- package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +1 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +85 -85
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +67 -62
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +37 -23
- package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +14 -6
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +9 -3
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +24 -16
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +29 -22
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +19 -12
- package/templates/firebase/lib/i18n/en.i18n.json +2 -1
- package/templates/firebase/lib/i18n/es.i18n.json +2 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +2 -1
|
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
6
6
|
import 'package:kasy_kit/components/kasy_sidebar.dart';
|
|
7
7
|
import 'package:kasy_kit/core/bottom_menu/bottom_router.dart';
|
|
8
8
|
import 'package:kasy_kit/core/bottom_menu/kasy_bottom_bar_factory.dart';
|
|
9
|
+
import 'package:kasy_kit/core/bottom_menu/web_content_wrapper.dart';
|
|
9
10
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
10
11
|
import 'package:kasy_kit/core/states/logout_action.dart';
|
|
11
12
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
@@ -220,17 +221,112 @@ class _FocusableSidebarState extends State<_FocusableSidebar> {
|
|
|
220
221
|
super.dispose();
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
// "Skip to content" moves the primary focus into the routed content, so the
|
|
225
|
+
// next Tab steps to the first real control there instead of the sidebar.
|
|
226
|
+
void _skipToContent() => kasyContentFocusTarget?.requestFocus();
|
|
227
|
+
|
|
223
228
|
@override
|
|
224
229
|
Widget build(BuildContext context) {
|
|
230
|
+
// Reading-order group (NOT an ordered policy): this is the exact structure
|
|
231
|
+
// that made the anchor hold the initial focus. The anchor sits at (0,0) and
|
|
232
|
+
// is skipped by Tab; the skip link is positioned at the very top, so reading
|
|
233
|
+
// order makes it the FIRST Tab stop, then the sidebar items, then (via the
|
|
234
|
+
// scaffold) the header and content. Swapping in an OrderedTraversalPolicy
|
|
235
|
+
// here broke the anchor, so we keep reading order and rely on position.
|
|
225
236
|
return FocusTraversalGroup(
|
|
226
|
-
// The anchor is a zero-size sibling so it never affects the sidebar's
|
|
227
|
-
// layout; it exists only to hold the initial keyboard focus (see above).
|
|
228
237
|
child: Stack(
|
|
238
|
+
clipBehavior: Clip.none,
|
|
229
239
|
children: [
|
|
230
240
|
widget.child,
|
|
241
|
+
// Zero-size sibling; only holds the initial keyboard focus.
|
|
231
242
|
Focus(focusNode: _anchor, child: const SizedBox.shrink()),
|
|
243
|
+
// Topmost on screen, so reading order makes it the first Tab stop.
|
|
244
|
+
Positioned(
|
|
245
|
+
top: KasySpacing.sm,
|
|
246
|
+
left: KasySpacing.sm,
|
|
247
|
+
child: _SkipToContentLink(onSkip: _skipToContent),
|
|
248
|
+
),
|
|
232
249
|
],
|
|
233
250
|
),
|
|
234
251
|
);
|
|
235
252
|
}
|
|
236
253
|
}
|
|
254
|
+
|
|
255
|
+
/// The "skip to content" link (WCAG 2.4.1 "Bypass Blocks"). It is the first Tab
|
|
256
|
+
/// stop on every screen: pressing Tab once reveals it above the sidebar, Enter
|
|
257
|
+
/// jumps focus into the content, and pressing Tab again moves on to the sidebar.
|
|
258
|
+
/// It only paints while focused via the keyboard, so pointer/touch users never
|
|
259
|
+
/// see it. Mirrors the pattern used by Stripe, GitHub, etc.
|
|
260
|
+
class _SkipToContentLink extends StatefulWidget {
|
|
261
|
+
final VoidCallback onSkip;
|
|
262
|
+
|
|
263
|
+
const _SkipToContentLink({required this.onSkip});
|
|
264
|
+
|
|
265
|
+
@override
|
|
266
|
+
State<_SkipToContentLink> createState() => _SkipToContentLinkState();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
class _SkipToContentLinkState extends State<_SkipToContentLink> {
|
|
270
|
+
bool _show = false;
|
|
271
|
+
|
|
272
|
+
static const Map<ShortcutActivator, Intent> _shortcuts =
|
|
273
|
+
<ShortcutActivator, Intent>{
|
|
274
|
+
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
|
|
275
|
+
SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
|
|
276
|
+
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
@override
|
|
280
|
+
Widget build(BuildContext context) {
|
|
281
|
+
final KasyColors c = context.colors;
|
|
282
|
+
return FocusableActionDetector(
|
|
283
|
+
shortcuts: _shortcuts,
|
|
284
|
+
actions: <Type, Action<Intent>>{
|
|
285
|
+
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
286
|
+
onInvoke: (_) {
|
|
287
|
+
widget.onSkip();
|
|
288
|
+
return null;
|
|
289
|
+
},
|
|
290
|
+
),
|
|
291
|
+
},
|
|
292
|
+
onShowFocusHighlight: (bool show) {
|
|
293
|
+
if (mounted && show != _show) setState(() => _show = show);
|
|
294
|
+
},
|
|
295
|
+
child: IgnorePointer(
|
|
296
|
+
ignoring: !_show,
|
|
297
|
+
child: AnimatedOpacity(
|
|
298
|
+
opacity: _show ? 1 : 0,
|
|
299
|
+
duration: const Duration(milliseconds: 120),
|
|
300
|
+
child: GestureDetector(
|
|
301
|
+
onTap: widget.onSkip,
|
|
302
|
+
child: Container(
|
|
303
|
+
padding: const EdgeInsets.symmetric(
|
|
304
|
+
horizontal: KasySpacing.md,
|
|
305
|
+
vertical: KasySpacing.sm,
|
|
306
|
+
),
|
|
307
|
+
decoration: BoxDecoration(
|
|
308
|
+
color: c.surface,
|
|
309
|
+
borderRadius: BorderRadius.circular(KasyRadius.md),
|
|
310
|
+
border: Border.all(color: c.primary, width: 1.5),
|
|
311
|
+
boxShadow: <BoxShadow>[
|
|
312
|
+
BoxShadow(
|
|
313
|
+
color: c.onSurface.withValues(alpha: 0.18),
|
|
314
|
+
blurRadius: 16,
|
|
315
|
+
offset: const Offset(0, 4),
|
|
316
|
+
),
|
|
317
|
+
],
|
|
318
|
+
),
|
|
319
|
+
child: Text(
|
|
320
|
+
context.t.navigation.skip_to_content,
|
|
321
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
322
|
+
color: c.onSurface,
|
|
323
|
+
fontWeight: FontWeight.w600,
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -3,19 +3,57 @@ import 'package:kasy_kit/components/kasy_web_header.dart';
|
|
|
3
3
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
4
4
|
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
5
5
|
|
|
6
|
+
/// Focus target of the currently mounted content, exposed to the "skip to
|
|
7
|
+
/// content" link (which lives in the shell, outside the routed page).
|
|
8
|
+
///
|
|
9
|
+
/// Each [WebContentWrapper] owns its OWN [FocusNode] and publishes it here on
|
|
10
|
+
/// mount, so during a route transition (where the old and new page briefly
|
|
11
|
+
/// coexist) there is never a single node attached to two widgets. The link
|
|
12
|
+
/// always jumps to the most recently mounted page.
|
|
13
|
+
FocusNode? kasyContentFocusTarget;
|
|
14
|
+
|
|
6
15
|
/// On desktop (≥ [DeviceType.large]) this puts the [KasyWebHeader] at the top of
|
|
7
16
|
/// the content area, above the routed page. On phone/tablet it is transparent
|
|
8
17
|
/// (returns the page untouched) — there the page keeps its own [KasyAppBar].
|
|
9
18
|
///
|
|
10
19
|
/// Wrap each routed page with this in the bottom router so the header is present
|
|
11
20
|
/// across navigation without touching individual pages.
|
|
12
|
-
class WebContentWrapper extends
|
|
21
|
+
class WebContentWrapper extends StatefulWidget {
|
|
13
22
|
final Widget child;
|
|
14
23
|
|
|
15
24
|
const WebContentWrapper({super.key, required this.child});
|
|
16
25
|
|
|
26
|
+
@override
|
|
27
|
+
State<WebContentWrapper> createState() => _WebContentWrapperState();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class _WebContentWrapperState extends State<WebContentWrapper> {
|
|
31
|
+
// skipTraversal: the "skip to content" link focuses this node directly, but it
|
|
32
|
+
// stays out of the Tab order (like a tabindex="-1" skip target on the web) so
|
|
33
|
+
// the next Tab lands on the first real control inside the content.
|
|
34
|
+
final FocusNode _contentFocus = FocusNode(
|
|
35
|
+
debugLabel: 'skipToContentTarget',
|
|
36
|
+
skipTraversal: true,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
@override
|
|
40
|
+
void initState() {
|
|
41
|
+
super.initState();
|
|
42
|
+
kasyContentFocusTarget = _contentFocus;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@override
|
|
46
|
+
void dispose() {
|
|
47
|
+
if (kasyContentFocusTarget == _contentFocus) {
|
|
48
|
+
kasyContentFocusTarget = null;
|
|
49
|
+
}
|
|
50
|
+
_contentFocus.dispose();
|
|
51
|
+
super.dispose();
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
@override
|
|
18
55
|
Widget build(BuildContext context) {
|
|
56
|
+
final Widget child = widget.child;
|
|
19
57
|
final bool isDesktop =
|
|
20
58
|
MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint;
|
|
21
59
|
if (!isDesktop) return child;
|
|
@@ -44,7 +82,12 @@ class WebContentWrapper extends StatelessWidget {
|
|
|
44
82
|
Expanded(
|
|
45
83
|
child: FocusTraversalOrder(
|
|
46
84
|
order: const NumericFocusOrder(3),
|
|
47
|
-
child: FocusTraversalGroup(
|
|
85
|
+
child: FocusTraversalGroup(
|
|
86
|
+
// Focus target for "skip to content": focusing it moves the
|
|
87
|
+
// primary focus into the content; the next Tab then steps to the
|
|
88
|
+
// first real control here.
|
|
89
|
+
child: Focus(focusNode: _contentFocus, child: child),
|
|
90
|
+
),
|
|
48
91
|
),
|
|
49
92
|
),
|
|
50
93
|
],
|
|
@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
|
|
|
5
5
|
import 'package:kasy_kit/core/data/models/user.dart';
|
|
6
6
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
7
7
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
8
|
+
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
8
9
|
import 'package:kasy_kit/features/ai_chat/api/ai_chat_conversation_entity.dart';
|
|
9
10
|
import 'package:kasy_kit/features/ai_chat/ui/widgets/ai_chat_avatars.dart';
|
|
10
11
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
@@ -104,10 +105,14 @@ class _AiConversationTileState extends ConsumerState<AiConversationTile> {
|
|
|
104
105
|
button: true,
|
|
105
106
|
selected: widget.selected,
|
|
106
107
|
label: title,
|
|
107
|
-
child:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
child:
|
|
108
|
+
child: KasyFocusRing(
|
|
109
|
+
onActivate: widget.onTap,
|
|
110
|
+
borderRadius: BorderRadius.circular(16),
|
|
111
|
+
child: GestureDetector(
|
|
112
|
+
behavior: HitTestBehavior.opaque,
|
|
113
|
+
onTap: widget.onTap,
|
|
114
|
+
child: tile,
|
|
115
|
+
),
|
|
111
116
|
),
|
|
112
117
|
);
|
|
113
118
|
|
|
@@ -20,6 +20,7 @@ class AddFeatureButton extends StatelessWidget {
|
|
|
20
20
|
onTap: onPressed,
|
|
21
21
|
borderRadius: KasyRadius.mdBorderRadius,
|
|
22
22
|
pressColor: context.colors.onPrimary,
|
|
23
|
+
focusable: true,
|
|
23
24
|
child: Container(
|
|
24
25
|
padding: const EdgeInsets.symmetric(
|
|
25
26
|
horizontal: KasySpacing.lg,
|
|
@@ -191,6 +191,8 @@ class _VoteCardState extends State<VoteCard>
|
|
|
191
191
|
borderRadius: KasyRadius.smBorderRadius,
|
|
192
192
|
pressColor: widget.textColor,
|
|
193
193
|
hapticEnabled: false,
|
|
194
|
+
focusable: true,
|
|
195
|
+
focusGapColor: context.colors.surface,
|
|
194
196
|
child: AnimatedContainer(
|
|
195
197
|
duration: colorDuration,
|
|
196
198
|
curve: Curves.easeOut,
|
|
@@ -199,22 +199,36 @@ class _FilterRow extends StatelessWidget {
|
|
|
199
199
|
|
|
200
200
|
const _FilterRow({required this.selected, required this.onSelect});
|
|
201
201
|
|
|
202
|
+
// Breathing room around the row so the keyboard focus ring (which paints a
|
|
203
|
+
// few px outside the card) is never clipped by the horizontal list's edges.
|
|
204
|
+
static const double _ringInset = 8;
|
|
205
|
+
|
|
202
206
|
@override
|
|
203
207
|
Widget build(BuildContext context) {
|
|
204
208
|
const List<HomeCategory> all = HomeCategory.values;
|
|
205
209
|
|
|
210
|
+
// Smaller, denser cards on phones; full size on tablet/desktop.
|
|
211
|
+
final bool isMobile = MediaQuery.sizeOf(context).width < 768;
|
|
212
|
+
final double thumbSize = isMobile ? 56 : 79;
|
|
213
|
+
final double cardWidth = isMobile ? 232 : 302;
|
|
214
|
+
final double cardHeight = thumbSize + KasySpacing.md * 2;
|
|
215
|
+
|
|
206
216
|
return SizedBox(
|
|
207
|
-
height:
|
|
217
|
+
height: cardHeight + _ringInset * 2,
|
|
208
218
|
child: ListView.separated(
|
|
209
219
|
scrollDirection: Axis.horizontal,
|
|
210
|
-
padding: const EdgeInsets.symmetric(
|
|
220
|
+
padding: const EdgeInsets.symmetric(
|
|
221
|
+
horizontal: KasySpacing.md,
|
|
222
|
+
vertical: _ringInset,
|
|
223
|
+
),
|
|
211
224
|
itemCount: all.length,
|
|
212
225
|
separatorBuilder: (_, _) => const SizedBox(width: KasySpacing.md),
|
|
213
226
|
itemBuilder: (BuildContext context, int i) => SizedBox(
|
|
214
|
-
width:
|
|
227
|
+
width: cardWidth,
|
|
215
228
|
child: _FilterCard(
|
|
216
229
|
category: all[i],
|
|
217
230
|
selected: all[i] == selected,
|
|
231
|
+
thumbSize: thumbSize,
|
|
218
232
|
onTap: () => onSelect(all[i]),
|
|
219
233
|
),
|
|
220
234
|
),
|
|
@@ -226,11 +240,13 @@ class _FilterRow extends StatelessWidget {
|
|
|
226
240
|
class _FilterCard extends StatelessWidget {
|
|
227
241
|
final HomeCategory category;
|
|
228
242
|
final bool selected;
|
|
243
|
+
final double thumbSize;
|
|
229
244
|
final VoidCallback onTap;
|
|
230
245
|
|
|
231
246
|
const _FilterCard({
|
|
232
247
|
required this.category,
|
|
233
248
|
required this.selected,
|
|
249
|
+
required this.thumbSize,
|
|
234
250
|
required this.onTap,
|
|
235
251
|
});
|
|
236
252
|
|
|
@@ -250,8 +266,8 @@ class _FilterCard extends StatelessWidget {
|
|
|
250
266
|
ClipRRect(
|
|
251
267
|
borderRadius: BorderRadius.circular(KasyRadius.lg),
|
|
252
268
|
child: SizedBox(
|
|
253
|
-
width:
|
|
254
|
-
height:
|
|
269
|
+
width: thumbSize,
|
|
270
|
+
height: thumbSize,
|
|
255
271
|
child: KasyNetworkImage(url: _thumbUrl(data.thumbnailId)),
|
|
256
272
|
),
|
|
257
273
|
),
|
|
@@ -6,6 +6,7 @@ import 'package:kasy_kit/components/kasy_image_viewer.dart';
|
|
|
6
6
|
import 'package:kasy_kit/components/kasy_skeleton.dart';
|
|
7
7
|
import 'package:kasy_kit/core/haptics/kasy_haptics.dart';
|
|
8
8
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
9
|
+
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
9
10
|
|
|
10
11
|
/// A single image shown in the masonry feed.
|
|
11
12
|
class HomePhoto {
|
|
@@ -22,7 +23,11 @@ class HomePhoto {
|
|
|
22
23
|
/// Network image with a synced shimmer skeleton while loading and a graceful
|
|
23
24
|
/// fallback on error. Adapts to light/dark via [KasySkeleton]'s own palette.
|
|
24
25
|
class KasyNetworkImage extends StatelessWidget {
|
|
25
|
-
const KasyNetworkImage({
|
|
26
|
+
const KasyNetworkImage({
|
|
27
|
+
super.key,
|
|
28
|
+
required this.url,
|
|
29
|
+
this.fit = BoxFit.cover,
|
|
30
|
+
});
|
|
26
31
|
|
|
27
32
|
final String url;
|
|
28
33
|
final BoxFit fit;
|
|
@@ -33,24 +38,18 @@ class KasyNetworkImage extends StatelessWidget {
|
|
|
33
38
|
url,
|
|
34
39
|
fit: fit,
|
|
35
40
|
gaplessPlayback: true,
|
|
36
|
-
loadingBuilder:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
) {
|
|
49
|
-
return ColoredBox(
|
|
50
|
-
color: context.colors.surfaceNeutralSoft,
|
|
51
|
-
child: Icon(KasyIcons.gallery, color: context.colors.muted),
|
|
52
|
-
);
|
|
53
|
-
},
|
|
41
|
+
loadingBuilder:
|
|
42
|
+
(BuildContext context, Widget child, ImageChunkEvent? progress) {
|
|
43
|
+
if (progress == null) return child;
|
|
44
|
+
return const _ImageSkeleton();
|
|
45
|
+
},
|
|
46
|
+
errorBuilder:
|
|
47
|
+
(BuildContext context, Object error, StackTrace? stackTrace) {
|
|
48
|
+
return ColoredBox(
|
|
49
|
+
color: context.colors.surfaceNeutralSoft,
|
|
50
|
+
child: Icon(KasyIcons.gallery, color: context.colors.muted),
|
|
51
|
+
);
|
|
52
|
+
},
|
|
54
53
|
);
|
|
55
54
|
}
|
|
56
55
|
}
|
|
@@ -80,7 +79,16 @@ class HomeImageGrid extends StatelessWidget {
|
|
|
80
79
|
|
|
81
80
|
static const double _gap = KasySpacing.smd;
|
|
82
81
|
static const List<double> _ratios = <double>[
|
|
83
|
-
0.72,
|
|
82
|
+
0.72,
|
|
83
|
+
1.0,
|
|
84
|
+
0.8,
|
|
85
|
+
1.32,
|
|
86
|
+
0.75,
|
|
87
|
+
1.0,
|
|
88
|
+
0.7,
|
|
89
|
+
1.25,
|
|
90
|
+
0.85,
|
|
91
|
+
1.4,
|
|
84
92
|
];
|
|
85
93
|
|
|
86
94
|
int _columnsFor(double width) {
|
|
@@ -97,8 +105,10 @@ class HomeImageGrid extends StatelessWidget {
|
|
|
97
105
|
final int columns = _columnsFor(width);
|
|
98
106
|
final double columnWidth = (width - _gap * (columns - 1)) / columns;
|
|
99
107
|
|
|
100
|
-
final List<List<Widget>> buckets =
|
|
101
|
-
|
|
108
|
+
final List<List<Widget>> buckets = List<List<Widget>>.generate(
|
|
109
|
+
columns,
|
|
110
|
+
(_) => <Widget>[],
|
|
111
|
+
);
|
|
102
112
|
final List<double> heights = List<double>.filled(columns, 0);
|
|
103
113
|
|
|
104
114
|
for (int i = 0; i < photos.length; i++) {
|
|
@@ -110,8 +120,9 @@ class HomeImageGrid extends StatelessWidget {
|
|
|
110
120
|
if (buckets[shortest].isNotEmpty) {
|
|
111
121
|
buckets[shortest].add(const SizedBox(height: _gap));
|
|
112
122
|
}
|
|
113
|
-
buckets[shortest]
|
|
114
|
-
|
|
123
|
+
buckets[shortest].add(
|
|
124
|
+
_PhotoTile(photo: photos[i], aspectRatio: ratio),
|
|
125
|
+
);
|
|
115
126
|
heights[shortest] += columnWidth / ratio + _gap;
|
|
116
127
|
}
|
|
117
128
|
|
|
@@ -170,12 +181,16 @@ class _PhotoTileState extends State<_PhotoTile> {
|
|
|
170
181
|
// Tapping the photo opens the full-screen viewer; the Hero gives a
|
|
171
182
|
// smooth zoom into and out of it.
|
|
172
183
|
Positioned.fill(
|
|
173
|
-
child:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
child:
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
child: KasyFocusRing(
|
|
185
|
+
onActivate: _openViewer,
|
|
186
|
+
borderRadius: BorderRadius.circular(KasyRadius.xl),
|
|
187
|
+
child: GestureDetector(
|
|
188
|
+
onTap: _openViewer,
|
|
189
|
+
behavior: HitTestBehavior.opaque,
|
|
190
|
+
child: Hero(
|
|
191
|
+
tag: photo.id,
|
|
192
|
+
child: KasyNetworkImage(url: photo.url),
|
|
193
|
+
),
|
|
179
194
|
),
|
|
180
195
|
),
|
|
181
196
|
),
|
|
@@ -291,13 +306,17 @@ class _LikeButtonState extends State<_LikeButton>
|
|
|
291
306
|
);
|
|
292
307
|
_scale = TweenSequence<double>(<TweenSequenceItem<double>>[
|
|
293
308
|
TweenSequenceItem<double>(
|
|
294
|
-
tween: Tween<double>(
|
|
295
|
-
|
|
309
|
+
tween: Tween<double>(
|
|
310
|
+
begin: 1.0,
|
|
311
|
+
end: 1.35,
|
|
312
|
+
).chain(CurveTween(curve: Curves.easeOut)),
|
|
296
313
|
weight: 40,
|
|
297
314
|
),
|
|
298
315
|
TweenSequenceItem<double>(
|
|
299
|
-
tween: Tween<double>(
|
|
300
|
-
|
|
316
|
+
tween: Tween<double>(
|
|
317
|
+
begin: 1.35,
|
|
318
|
+
end: 1.0,
|
|
319
|
+
).chain(CurveTween(curve: Curves.elasticOut)),
|
|
301
320
|
weight: 60,
|
|
302
321
|
),
|
|
303
322
|
]).animate(_controller);
|
|
@@ -321,30 +340,36 @@ class _LikeButtonState extends State<_LikeButton>
|
|
|
321
340
|
@override
|
|
322
341
|
Widget build(BuildContext context) {
|
|
323
342
|
final bool dark = context.isDark;
|
|
324
|
-
final Color heart =
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
final Color heart = widget.liked
|
|
344
|
+
? context.colors.danger
|
|
345
|
+
: context.colors.primary;
|
|
346
|
+
|
|
347
|
+
return KasyFocusRing(
|
|
348
|
+
onActivate: widget.onTap,
|
|
349
|
+
borderRadius: BorderRadius.circular(999),
|
|
350
|
+
child: GestureDetector(
|
|
351
|
+
onTap: widget.onTap,
|
|
352
|
+
behavior: HitTestBehavior.opaque,
|
|
353
|
+
child: ClipOval(
|
|
354
|
+
child: BackdropFilter(
|
|
355
|
+
filter: ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
|
356
|
+
child: Container(
|
|
357
|
+
width: 36,
|
|
358
|
+
height: 36,
|
|
359
|
+
alignment: Alignment.center,
|
|
360
|
+
decoration: BoxDecoration(
|
|
361
|
+
color: context.colors.surface.withValues(
|
|
362
|
+
alpha: dark ? 0.5 : 0.7,
|
|
363
|
+
),
|
|
364
|
+
shape: BoxShape.circle,
|
|
365
|
+
),
|
|
366
|
+
child: ScaleTransition(
|
|
367
|
+
scale: _scale,
|
|
368
|
+
child: Icon(
|
|
369
|
+
widget.liked ? KasyIcons.favoriteFilled : KasyIcons.favorite,
|
|
370
|
+
size: 18,
|
|
371
|
+
color: heart,
|
|
372
|
+
),
|
|
348
373
|
),
|
|
349
374
|
),
|
|
350
375
|
),
|