kasy-cli 1.24.0 → 1.26.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 +53 -3
- package/lib/utils/flutter-install.js +26 -1
- package/lib/utils/i18n/messages-en.js +3 -0
- package/lib/utils/i18n/messages-es.js +3 -0
- package/lib/utils/i18n/messages-pt.js +3 -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_card.dart +4 -0
- 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_sidebar.dart +6 -0
- package/templates/firebase/lib/components/kasy_tabs.dart +76 -65
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +103 -3
- package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +45 -2
- package/templates/firebase/lib/core/widgets/kasy_pressable_depth.dart +37 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +9 -4
- package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +1 -0
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +10 -5
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +1 -0
- 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/settings_page.dart +17 -1
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +37 -23
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +63 -48
- package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +14 -6
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +2 -0
- 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,116 @@ 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
|
+
// Ordered so the first Tab reveals the "skip to content" link (order 0),
|
|
231
|
+
// then the sidebar (order 1). The header (2) and content (3) come after,
|
|
232
|
+
// ordered by the scaffold's OrderedTraversalPolicy. The invisible anchor
|
|
233
|
+
// holds the initial focus so that very first Tab lands on the skip link.
|
|
225
234
|
return FocusTraversalGroup(
|
|
226
|
-
|
|
227
|
-
// layout; it exists only to hold the initial keyboard focus (see above).
|
|
235
|
+
policy: OrderedTraversalPolicy(),
|
|
228
236
|
child: Stack(
|
|
237
|
+
clipBehavior: Clip.none,
|
|
229
238
|
children: [
|
|
230
|
-
|
|
239
|
+
FocusTraversalOrder(
|
|
240
|
+
order: const NumericFocusOrder(1),
|
|
241
|
+
child: FocusTraversalGroup(child: widget.child),
|
|
242
|
+
),
|
|
243
|
+
// Zero-size sibling; only holds the initial keyboard focus.
|
|
231
244
|
Focus(focusNode: _anchor, child: const SizedBox.shrink()),
|
|
245
|
+
Positioned(
|
|
246
|
+
top: KasySpacing.sm,
|
|
247
|
+
left: KasySpacing.sm,
|
|
248
|
+
child: FocusTraversalOrder(
|
|
249
|
+
order: const NumericFocusOrder(0),
|
|
250
|
+
child: _SkipToContentLink(onSkip: _skipToContent),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
232
253
|
],
|
|
233
254
|
),
|
|
234
255
|
);
|
|
235
256
|
}
|
|
236
257
|
}
|
|
258
|
+
|
|
259
|
+
/// The "skip to content" link (WCAG 2.4.1 "Bypass Blocks"). It is the first Tab
|
|
260
|
+
/// stop on every screen: pressing Tab once reveals it above the sidebar, Enter
|
|
261
|
+
/// jumps focus into the content, and pressing Tab again moves on to the sidebar.
|
|
262
|
+
/// It only paints while focused via the keyboard, so pointer/touch users never
|
|
263
|
+
/// see it. Mirrors the pattern used by Stripe, GitHub, etc.
|
|
264
|
+
class _SkipToContentLink extends StatefulWidget {
|
|
265
|
+
final VoidCallback onSkip;
|
|
266
|
+
|
|
267
|
+
const _SkipToContentLink({required this.onSkip});
|
|
268
|
+
|
|
269
|
+
@override
|
|
270
|
+
State<_SkipToContentLink> createState() => _SkipToContentLinkState();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
class _SkipToContentLinkState extends State<_SkipToContentLink> {
|
|
274
|
+
bool _show = false;
|
|
275
|
+
|
|
276
|
+
static const Map<ShortcutActivator, Intent> _shortcuts =
|
|
277
|
+
<ShortcutActivator, Intent>{
|
|
278
|
+
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
|
|
279
|
+
SingleActivator(LogicalKeyboardKey.numpadEnter): ActivateIntent(),
|
|
280
|
+
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
@override
|
|
284
|
+
Widget build(BuildContext context) {
|
|
285
|
+
final KasyColors c = context.colors;
|
|
286
|
+
return FocusableActionDetector(
|
|
287
|
+
shortcuts: _shortcuts,
|
|
288
|
+
actions: <Type, Action<Intent>>{
|
|
289
|
+
ActivateIntent: CallbackAction<ActivateIntent>(
|
|
290
|
+
onInvoke: (_) {
|
|
291
|
+
widget.onSkip();
|
|
292
|
+
return null;
|
|
293
|
+
},
|
|
294
|
+
),
|
|
295
|
+
},
|
|
296
|
+
onShowFocusHighlight: (bool show) {
|
|
297
|
+
if (mounted && show != _show) setState(() => _show = show);
|
|
298
|
+
},
|
|
299
|
+
child: IgnorePointer(
|
|
300
|
+
ignoring: !_show,
|
|
301
|
+
child: AnimatedOpacity(
|
|
302
|
+
opacity: _show ? 1 : 0,
|
|
303
|
+
duration: const Duration(milliseconds: 120),
|
|
304
|
+
child: GestureDetector(
|
|
305
|
+
onTap: widget.onSkip,
|
|
306
|
+
child: Container(
|
|
307
|
+
padding: const EdgeInsets.symmetric(
|
|
308
|
+
horizontal: KasySpacing.md,
|
|
309
|
+
vertical: KasySpacing.sm,
|
|
310
|
+
),
|
|
311
|
+
decoration: BoxDecoration(
|
|
312
|
+
color: c.surface,
|
|
313
|
+
borderRadius: BorderRadius.circular(KasyRadius.md),
|
|
314
|
+
border: Border.all(color: c.primary, width: 1.5),
|
|
315
|
+
boxShadow: <BoxShadow>[
|
|
316
|
+
BoxShadow(
|
|
317
|
+
color: c.onSurface.withValues(alpha: 0.18),
|
|
318
|
+
blurRadius: 16,
|
|
319
|
+
offset: const Offset(0, 4),
|
|
320
|
+
),
|
|
321
|
+
],
|
|
322
|
+
),
|
|
323
|
+
child: Text(
|
|
324
|
+
context.t.navigation.skip_to_content,
|
|
325
|
+
style: context.textTheme.bodyMedium?.copyWith(
|
|
326
|
+
color: c.onSurface,
|
|
327
|
+
fontWeight: FontWeight.w600,
|
|
328
|
+
),
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
),
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -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
|
],
|
|
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
|
|
|
6
6
|
import 'package:flutter/services.dart';
|
|
7
7
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
8
8
|
import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
|
|
9
|
+
import 'package:kasy_kit/core/theme/theme.dart';
|
|
10
|
+
import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
|
|
9
11
|
|
|
10
12
|
/// Press-in with slight overshoot-back (same tactility as [KasyAlert] actions).
|
|
11
13
|
///
|
|
@@ -26,6 +28,22 @@ class KasyPressableDepth extends ConsumerStatefulWidget {
|
|
|
26
28
|
/// If false, does not call [HapticFeedback.lightImpact] on tap.
|
|
27
29
|
final bool hapticFeedbackEnabled;
|
|
28
30
|
|
|
31
|
+
/// When true, the control becomes a keyboard tab-stop: it is wrapped in a
|
|
32
|
+
/// [KasyFocusRing] so Tab shows the focus outline and Enter/Space activate it.
|
|
33
|
+
/// Defaults to false because [KasyButton] already wraps its own ring around
|
|
34
|
+
/// the child — turning this on there would paint a second ring. Use it for
|
|
35
|
+
/// direct, standalone uses (e.g. a text link) that need keyboard access.
|
|
36
|
+
final bool focusable;
|
|
37
|
+
|
|
38
|
+
/// Corner radius for the focus ring (only used when [focusable]). Defaults to
|
|
39
|
+
/// [clipBorderRadius] when set, otherwise a small radius hugging the visual.
|
|
40
|
+
final BorderRadius? focusBorderRadius;
|
|
41
|
+
|
|
42
|
+
/// Gap colour for the focus ring (only used when [focusable]). Pass the
|
|
43
|
+
/// surface the control sits on so the ring's hair-line gap blends in instead
|
|
44
|
+
/// of showing a halo (notably in dark mode). Null falls back to `background`.
|
|
45
|
+
final Color? focusGapColor;
|
|
46
|
+
|
|
29
47
|
const KasyPressableDepth({
|
|
30
48
|
super.key,
|
|
31
49
|
required this.child,
|
|
@@ -34,6 +52,9 @@ class KasyPressableDepth extends ConsumerStatefulWidget {
|
|
|
34
52
|
this.pressOverlayColor,
|
|
35
53
|
this.clipBorderRadius,
|
|
36
54
|
this.hapticFeedbackEnabled = true,
|
|
55
|
+
this.focusable = false,
|
|
56
|
+
this.focusBorderRadius,
|
|
57
|
+
this.focusGapColor,
|
|
37
58
|
});
|
|
38
59
|
|
|
39
60
|
@override
|
|
@@ -142,6 +163,21 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
|
|
|
142
163
|
|
|
143
164
|
@override
|
|
144
165
|
Widget build(BuildContext context) {
|
|
166
|
+
// The ring wraps the scaled visual (not the 44px tap target) so it hugs the
|
|
167
|
+
// control's shape, and sits outside Transform.scale so it doesn't pulse with
|
|
168
|
+
// the press. Keyboard activation is wired to the same handler as the tap.
|
|
169
|
+
Widget visual = _wrapScaledChild(widget.child);
|
|
170
|
+
if (widget.focusable) {
|
|
171
|
+
visual = KasyFocusRing(
|
|
172
|
+
onActivate: _handleTap,
|
|
173
|
+
borderRadius: widget.focusBorderRadius ??
|
|
174
|
+
widget.clipBorderRadius ??
|
|
175
|
+
BorderRadius.circular(KasyRadius.sm),
|
|
176
|
+
gapColor: widget.focusGapColor,
|
|
177
|
+
child: visual,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
145
181
|
final Widget inner = Semantics(
|
|
146
182
|
button: true,
|
|
147
183
|
enabled: true,
|
|
@@ -153,7 +189,7 @@ class _KasyPressableDepthState extends ConsumerState<KasyPressableDepth>
|
|
|
153
189
|
onTap: _handleTap,
|
|
154
190
|
child: ConstrainedBox(
|
|
155
191
|
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
|
156
|
-
child: Center(child:
|
|
192
|
+
child: Center(child: visual),
|
|
157
193
|
),
|
|
158
194
|
),
|
|
159
195
|
);
|
|
@@ -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
|
|
|
@@ -98,11 +98,15 @@ class SigninPage extends ConsumerWidget {
|
|
|
98
98
|
FocusScope.of(context).unfocus();
|
|
99
99
|
ref.read(signinStateProvider.notifier).signin();
|
|
100
100
|
},
|
|
101
|
-
labelTrailing:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
labelTrailing: KasyFocusRing(
|
|
102
|
+
onActivate: () => context.push('/recover_password'),
|
|
103
|
+
borderRadius: BorderRadius.circular(KasyRadius.sm),
|
|
104
|
+
child: GestureDetector(
|
|
105
|
+
onTap: () => context.push('/recover_password'),
|
|
106
|
+
child: Text(
|
|
107
|
+
t.auth.signin.forgot_password,
|
|
108
|
+
style: forgotPasswordStyle,
|
|
109
|
+
),
|
|
106
110
|
),
|
|
107
111
|
),
|
|
108
112
|
validator: (value) {
|
|
@@ -191,6 +195,7 @@ class _SignupPrompt extends StatelessWidget {
|
|
|
191
195
|
KasyPressableDepth(
|
|
192
196
|
semanticLabel: t.auth.signin.signup_link,
|
|
193
197
|
onPressed: () => context.pushReplacement('/signup'),
|
|
198
|
+
focusable: true,
|
|
194
199
|
child: Text(
|
|
195
200
|
t.auth.signin.signup_link,
|
|
196
201
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
@@ -180,6 +180,7 @@ class _SigninPrompt extends StatelessWidget {
|
|
|
180
180
|
KasyPressableDepth(
|
|
181
181
|
semanticLabel: t.auth.signup.signin_link,
|
|
182
182
|
onPressed: () => context.pushReplacement('/signin'),
|
|
183
|
+
focusable: true,
|
|
183
184
|
child: Text(
|
|
184
185
|
t.auth.signup.signin_link,
|
|
185
186
|
style: context.textTheme.bodyMedium?.copyWith(
|
|
@@ -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
|
),
|