kasy-cli 1.32.0 → 1.34.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/bin/kasy.js +42 -0
- package/lib/commands/apple-web.js +222 -0
- package/lib/commands/configure.js +3 -91
- package/lib/commands/doctor.js +20 -0
- package/lib/commands/facebook.js +189 -0
- package/lib/commands/new.js +50 -2
- package/lib/scaffold/CHANGELOG.json +18 -0
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +164 -0
- package/lib/scaffold/backends/supabase/deploy.js +92 -0
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +10 -0
- package/lib/scaffold/shared/generator-utils.js +18 -6
- package/lib/utils/apple-web.js +147 -0
- package/lib/utils/facebook.js +162 -0
- package/lib/utils/i18n/messages-en.js +62 -0
- package/lib/utils/i18n/messages-es.js +62 -0
- package/lib/utils/i18n/messages-pt.js +62 -0
- package/package.json +2 -2
- package/templates/firebase/AGENTS.md +87 -0
- package/templates/firebase/CLAUDE.md +16 -0
- package/templates/firebase/DESIGN_SYSTEM.md +234 -0
- package/templates/firebase/docs/auth-setup.en.md +2 -2
- package/templates/firebase/docs/auth-setup.es.md +2 -2
- package/templates/firebase/docs/auth-setup.pt.md +2 -2
- package/templates/firebase/lib/components/components.dart +1 -0
- package/templates/firebase/lib/components/kasy_app_bar.dart +4 -1
- package/templates/firebase/lib/components/kasy_screen.dart +114 -0
- package/templates/firebase/lib/components/kasy_toast.dart +39 -70
- package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
- package/templates/firebase/lib/core/config/features.dart +5 -0
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +46 -124
- package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
- package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -7
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +1 -1
- package/templates/firebase/lib/features/authentication/api/authentication_api.dart +61 -0
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +21 -15
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
- package/templates/firebase/lib/features/home/home_components_page.dart +7 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +32 -0
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +165 -209
- package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
- package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
- package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -6
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +77 -126
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
- package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +17 -8
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
- package/templates/firebase/lib/i18n/en.i18n.json +5 -4
- package/templates/firebase/lib/i18n/es.i18n.json +5 -4
- package/templates/firebase/lib/i18n/pt.i18n.json +5 -4
- package/templates/firebase/lib/router.dart +2 -0
- package/templates/firebase/pubspec.yaml +1 -1
- package/templates/firebase/tool/design_check.dart +152 -0
- package/templates/firebase/assets/images/review.png +0 -0
- package/templates/firebase/assets/images/update.png +0 -0
- package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
1
2
|
import 'package:flutter/material.dart';
|
|
2
|
-
import 'package:flutter_animate/flutter_animate.dart';
|
|
3
3
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
4
4
|
import 'package:go_router/go_router.dart';
|
|
5
5
|
import 'package:kasy_kit/components/components.dart';
|
|
@@ -7,17 +7,25 @@ import 'package:kasy_kit/core/data/api/analytics_api.dart';
|
|
|
7
7
|
import 'package:kasy_kit/core/rating/providers/rating_repository.dart';
|
|
8
8
|
import 'package:kasy_kit/core/states/user_state_notifier.dart';
|
|
9
9
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
10
|
-
import 'package:kasy_kit/core/widgets/responsive_layout.dart';
|
|
11
10
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
12
11
|
import 'package:logger/logger.dart';
|
|
13
12
|
|
|
14
13
|
/// Shows the in-app review dialog. Prefer a stable [context] (not a sheet that
|
|
15
14
|
/// will be popped before the async work finishes).
|
|
15
|
+
///
|
|
16
|
+
/// A clean [KasyDialog]: a title, a short message and two stacked actions
|
|
17
|
+
/// (write a review / suggest improvements). Dismissing via the dialog's own
|
|
18
|
+
/// close button just defers the next ask and keeps the user where they are.
|
|
16
19
|
Future<bool> showReviewDialog(
|
|
17
20
|
BuildContext context,
|
|
18
21
|
WidgetRef ref, {
|
|
19
22
|
bool force = false,
|
|
20
23
|
}) async {
|
|
24
|
+
// Store reviews are a native-only concept (App Store / Play Store). On web
|
|
25
|
+
// there is nowhere to send the user, so the prompt never shows there.
|
|
26
|
+
if (kIsWeb) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
21
29
|
if (!context.mounted) {
|
|
22
30
|
return false;
|
|
23
31
|
}
|
|
@@ -41,97 +49,42 @@ Future<bool> showReviewDialog(
|
|
|
41
49
|
barrierDismissible: false,
|
|
42
50
|
builder: (dialogContext) {
|
|
43
51
|
ratingRepository.delay();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
'assets/images/review.png',
|
|
81
|
-
fit: BoxFit.fitWidth,
|
|
82
|
-
width: maxWidth,
|
|
83
|
-
),
|
|
84
|
-
),
|
|
85
|
-
Positioned(
|
|
86
|
-
top: KasySpacing.sm,
|
|
87
|
-
left: KasySpacing.sm,
|
|
88
|
-
child: CloseIcon(
|
|
89
|
-
onExit: () {
|
|
90
|
-
analytics.logEvent('rating_popup_close', {});
|
|
91
|
-
rating.delay().then((_) {
|
|
92
|
-
if (!dialogContext.mounted) return;
|
|
93
|
-
Navigator.of(dialogContext).pop();
|
|
94
|
-
});
|
|
95
|
-
},
|
|
96
|
-
),
|
|
97
|
-
),
|
|
98
|
-
],
|
|
99
|
-
),
|
|
100
|
-
const SizedBox(height: KasySpacing.md),
|
|
101
|
-
Text(
|
|
102
|
-
translations.description,
|
|
103
|
-
textAlign: TextAlign.center,
|
|
104
|
-
style: Theme.of(dialogContext).textTheme.bodyMedium,
|
|
105
|
-
),
|
|
106
|
-
],
|
|
107
|
-
),
|
|
108
|
-
footer: Column(
|
|
109
|
-
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
110
|
-
children: [
|
|
111
|
-
KasyButton(
|
|
112
|
-
label: translations.rate_button,
|
|
113
|
-
expand: true,
|
|
114
|
-
onPressed: () {
|
|
115
|
-
analytics.logEvent('rating_popup_show', {});
|
|
116
|
-
ratingRepository.rate().then((_) => rating.review()).then(
|
|
117
|
-
(_) {
|
|
118
|
-
if (!dialogContext.mounted) return;
|
|
119
|
-
Navigator.of(dialogContext).pop();
|
|
120
|
-
},
|
|
121
|
-
);
|
|
122
|
-
},
|
|
123
|
-
),
|
|
124
|
-
const SizedBox(height: KasySpacing.sm),
|
|
125
|
-
KasyButton(
|
|
126
|
-
label: translations.cancel_button,
|
|
127
|
-
variant: KasyButtonVariant.soft,
|
|
128
|
-
expand: true,
|
|
129
|
-
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
130
|
-
),
|
|
131
|
-
],
|
|
132
|
-
),
|
|
133
|
-
);
|
|
134
|
-
},
|
|
52
|
+
final translations = Translations.of(dialogContext).review_popup;
|
|
53
|
+
return KasyDialog(
|
|
54
|
+
leadingIcon: KasyIcons.star,
|
|
55
|
+
iconTone: KasyDialogIconTone.info,
|
|
56
|
+
title: translations.title,
|
|
57
|
+
titleCentered: true,
|
|
58
|
+
message: translations.description,
|
|
59
|
+
onClose: () {
|
|
60
|
+
analytics.logEvent('rating_popup_close', {});
|
|
61
|
+
rating.delay();
|
|
62
|
+
Navigator.of(dialogContext).pop();
|
|
63
|
+
},
|
|
64
|
+
footer: Column(
|
|
65
|
+
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
66
|
+
children: [
|
|
67
|
+
KasyButton(
|
|
68
|
+
label: translations.rate_button,
|
|
69
|
+
expand: true,
|
|
70
|
+
onPressed: () {
|
|
71
|
+
analytics.logEvent('rating_popup_show', {});
|
|
72
|
+
ratingRepository.rate().then((_) => rating.review()).then(
|
|
73
|
+
(_) {
|
|
74
|
+
if (!dialogContext.mounted) return;
|
|
75
|
+
Navigator.of(dialogContext).pop();
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
),
|
|
80
|
+
const SizedBox(height: KasySpacing.sm),
|
|
81
|
+
KasyButton(
|
|
82
|
+
label: translations.cancel_button,
|
|
83
|
+
variant: KasyButtonVariant.soft,
|
|
84
|
+
expand: true,
|
|
85
|
+
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
86
|
+
),
|
|
87
|
+
],
|
|
135
88
|
),
|
|
136
89
|
);
|
|
137
90
|
},
|
|
@@ -142,34 +95,3 @@ Future<bool> showReviewDialog(
|
|
|
142
95
|
}
|
|
143
96
|
return true;
|
|
144
97
|
}
|
|
145
|
-
|
|
146
|
-
class CloseIcon extends StatelessWidget {
|
|
147
|
-
final VoidCallback onExit;
|
|
148
|
-
|
|
149
|
-
const CloseIcon({super.key, required this.onExit});
|
|
150
|
-
|
|
151
|
-
@override
|
|
152
|
-
Widget build(BuildContext context) {
|
|
153
|
-
return ClipOval(
|
|
154
|
-
child: Material(
|
|
155
|
-
color: Colors.transparent,
|
|
156
|
-
child: InkWell(
|
|
157
|
-
onTap: () => onExit.call(),
|
|
158
|
-
child: Ink(
|
|
159
|
-
width: 32,
|
|
160
|
-
height: 32,
|
|
161
|
-
decoration: BoxDecoration(
|
|
162
|
-
color: context.colors.background,
|
|
163
|
-
shape: BoxShape.circle,
|
|
164
|
-
),
|
|
165
|
-
child: Icon(
|
|
166
|
-
KasyIcons.close,
|
|
167
|
-
color: context.colors.onBackground,
|
|
168
|
-
size: KasyIconSize.lg,
|
|
169
|
-
),
|
|
170
|
-
),
|
|
171
|
-
),
|
|
172
|
-
),
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
@@ -3,17 +3,19 @@ import 'package:kasy_kit/components/components.dart';
|
|
|
3
3
|
import 'package:kasy_kit/core/theme/theme.dart';
|
|
4
4
|
import 'package:kasy_kit/i18n/translations.g.dart';
|
|
5
5
|
|
|
6
|
-
/// Shows
|
|
6
|
+
/// Shows the "what's new" bottom sheet, built on [KasyBottomSheet].
|
|
7
|
+
///
|
|
8
|
+
/// Dismissing it — the Continue button, the drag handle, or tapping the dim
|
|
9
|
+
/// barrier outside — simply closes the sheet and returns the user to the screen
|
|
10
|
+
/// they were on; it never navigates anywhere. The version is already recorded as
|
|
11
|
+
/// seen before this is shown (see `MaybeShowUpdateBottomSheet`), so it won't pop
|
|
12
|
+
/// up again for the same version.
|
|
7
13
|
Future<void> showUpdateBottomSheet({
|
|
8
14
|
required BuildContext context,
|
|
9
15
|
required String version,
|
|
10
|
-
bool useRootNavigator = true,
|
|
11
16
|
}) async {
|
|
12
|
-
await
|
|
17
|
+
await showKasyBottomSheet<void>(
|
|
13
18
|
context: context,
|
|
14
|
-
useSafeArea: true,
|
|
15
|
-
useRootNavigator: useRootNavigator,
|
|
16
|
-
barrierColor: context.colors.background.withValues(alpha: 0.90),
|
|
17
19
|
isScrollControlled: true,
|
|
18
20
|
builder: (context) => _UpdateBottomSheet(version: version),
|
|
19
21
|
);
|
|
@@ -28,130 +30,31 @@ class _UpdateBottomSheet extends StatelessWidget {
|
|
|
28
30
|
Widget build(BuildContext context) {
|
|
29
31
|
final translations = Translations.of(context).update_bottom_sheet;
|
|
30
32
|
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
return KasyBottomSheet(
|
|
34
|
+
icon: KasyIcons.star,
|
|
35
|
+
title: translations.title,
|
|
36
|
+
message: 'Version $version',
|
|
37
|
+
body: ConstrainedBox(
|
|
38
|
+
constraints: BoxConstraints(
|
|
39
|
+
maxHeight: MediaQuery.sizeOf(context).height * 0.5,
|
|
37
40
|
),
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
child: ListView.separated(
|
|
42
|
+
shrinkWrap: true,
|
|
43
|
+
padding: EdgeInsets.zero,
|
|
44
|
+
itemBuilder: (context, index) =>
|
|
45
|
+
_UpdateHighlightTile(highlight: translations.highlights[index]),
|
|
46
|
+
separatorBuilder: (context, index) =>
|
|
47
|
+
const SizedBox(height: KasySpacing.sm),
|
|
48
|
+
itemCount: translations.highlights.length,
|
|
42
49
|
),
|
|
43
50
|
),
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
children: [
|
|
50
|
-
Flexible(
|
|
51
|
-
flex: 0,
|
|
52
|
-
child: Align(
|
|
53
|
-
child: ClipRRect(
|
|
54
|
-
borderRadius: const BorderRadius.only(
|
|
55
|
-
topLeft: Radius.circular(KasyRadius.xl),
|
|
56
|
-
topRight: Radius.circular(KasyRadius.xl),
|
|
57
|
-
),
|
|
58
|
-
child: Image.asset(
|
|
59
|
-
'assets/images/update.png',
|
|
60
|
-
fit: BoxFit.cover,
|
|
61
|
-
),
|
|
62
|
-
),
|
|
63
|
-
),
|
|
64
|
-
),
|
|
65
|
-
Padding(
|
|
66
|
-
padding: const EdgeInsets.fromLTRB(
|
|
67
|
-
KasySpacing.lg,
|
|
68
|
-
KasySpacing.lg,
|
|
69
|
-
KasySpacing.md,
|
|
70
|
-
0,
|
|
71
|
-
),
|
|
72
|
-
child: Row(
|
|
73
|
-
children: [
|
|
74
|
-
Container(
|
|
75
|
-
width: 48,
|
|
76
|
-
height: 48,
|
|
77
|
-
decoration: BoxDecoration(
|
|
78
|
-
color: context.colors.primary.withValues(alpha: 0.1),
|
|
79
|
-
borderRadius: KasyRadius.mdBorderRadius,
|
|
80
|
-
),
|
|
81
|
-
child: Icon(
|
|
82
|
-
KasyIcons.star,
|
|
83
|
-
color: context.colors.primary,
|
|
84
|
-
size: KasyIconSize.xl,
|
|
85
|
-
),
|
|
86
|
-
),
|
|
87
|
-
const SizedBox(width: KasySpacing.md),
|
|
88
|
-
Expanded(
|
|
89
|
-
child: Column(
|
|
90
|
-
crossAxisAlignment: CrossAxisAlignment.start,
|
|
91
|
-
children: [
|
|
92
|
-
Text(
|
|
93
|
-
translations.title,
|
|
94
|
-
style: context.textTheme.headlineSmall?.copyWith(
|
|
95
|
-
color: context.colors.onSurface,
|
|
96
|
-
fontWeight: FontWeight.w700,
|
|
97
|
-
),
|
|
98
|
-
),
|
|
99
|
-
Text(
|
|
100
|
-
"Version $version",
|
|
101
|
-
style: context.textTheme.bodyMedium?.copyWith(
|
|
102
|
-
color: context.colors.muted,
|
|
103
|
-
fontWeight: FontWeight.w500,
|
|
104
|
-
),
|
|
105
|
-
),
|
|
106
|
-
],
|
|
107
|
-
),
|
|
108
|
-
),
|
|
109
|
-
KasyButton.iconOnly(
|
|
110
|
-
icon: KasyIcons.close,
|
|
111
|
-
variant: KasyButtonVariant.ghost,
|
|
112
|
-
foregroundColor: context.colors.muted,
|
|
113
|
-
onPressed: () => Navigator.of(context).pop(),
|
|
114
|
-
semanticLabel: translations.title,
|
|
115
|
-
),
|
|
116
|
-
],
|
|
117
|
-
),
|
|
118
|
-
),
|
|
119
|
-
const SizedBox(height: KasySpacing.lg),
|
|
120
|
-
Flexible(
|
|
121
|
-
child: ConstrainedBox(
|
|
122
|
-
constraints: BoxConstraints(
|
|
123
|
-
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
|
124
|
-
),
|
|
125
|
-
child: ListView.separated(
|
|
126
|
-
shrinkWrap: true,
|
|
127
|
-
padding: const EdgeInsets.symmetric(
|
|
128
|
-
horizontal: KasySpacing.lg,
|
|
129
|
-
),
|
|
130
|
-
itemBuilder: (context, index) {
|
|
131
|
-
final highlight = translations.highlights[index];
|
|
132
|
-
return _UpdateHighlightTile(highlight: highlight);
|
|
133
|
-
},
|
|
134
|
-
separatorBuilder: (context, index) =>
|
|
135
|
-
const SizedBox(height: KasySpacing.sm),
|
|
136
|
-
itemCount: translations.highlights.length,
|
|
137
|
-
),
|
|
138
|
-
),
|
|
139
|
-
),
|
|
140
|
-
const SizedBox(height: KasySpacing.xl),
|
|
141
|
-
Padding(
|
|
142
|
-
padding: const EdgeInsets.symmetric(horizontal: KasySpacing.lg),
|
|
143
|
-
child: SizedBox(
|
|
144
|
-
width: double.infinity,
|
|
145
|
-
child: KasyButton(
|
|
146
|
-
label: translations.continue_button,
|
|
147
|
-
expand: true,
|
|
148
|
-
onPressed: () => Navigator.of(context).pop(),
|
|
149
|
-
),
|
|
150
|
-
),
|
|
151
|
-
),
|
|
152
|
-
],
|
|
51
|
+
actions: [
|
|
52
|
+
KasyButton(
|
|
53
|
+
label: translations.continue_button,
|
|
54
|
+
expand: true,
|
|
55
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
153
56
|
),
|
|
154
|
-
|
|
57
|
+
],
|
|
155
58
|
);
|
|
156
59
|
}
|
|
157
60
|
}
|
|
@@ -115,13 +115,8 @@ class _DetailContainer extends StatelessWidget {
|
|
|
115
115
|
|
|
116
116
|
@override
|
|
117
117
|
Widget build(BuildContext context) {
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
decoration: BoxDecoration(
|
|
121
|
-
color: context.colors.surface,
|
|
122
|
-
borderRadius: BorderRadius.circular(24),
|
|
123
|
-
border: Border.all(color: context.colors.border, width: 0.5),
|
|
124
|
-
),
|
|
118
|
+
return KasyCard(
|
|
119
|
+
borderRadius: BorderRadius.circular(KasyRadius.xl),
|
|
125
120
|
child: child,
|
|
126
121
|
);
|
|
127
122
|
}
|
|
@@ -143,6 +138,15 @@ class _DetailPlaceholder extends StatelessWidget {
|
|
|
143
138
|
showShadow: true,
|
|
144
139
|
),
|
|
145
140
|
const SizedBox(height: KasySpacing.md),
|
|
141
|
+
Text(
|
|
142
|
+
t.home.cards.assistant_title,
|
|
143
|
+
textAlign: TextAlign.center,
|
|
144
|
+
style: context.textTheme.titleMedium?.copyWith(
|
|
145
|
+
fontWeight: FontWeight.w600,
|
|
146
|
+
color: context.colors.onBackground,
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
const SizedBox(height: KasySpacing.xs),
|
|
146
150
|
Text(
|
|
147
151
|
t.ai_chat.no_conversation_selected,
|
|
148
152
|
textAlign: TextAlign.center,
|
|
@@ -30,6 +30,12 @@ class AiChatComposer extends StatefulWidget {
|
|
|
30
30
|
class _AiChatComposerState extends State<AiChatComposer> {
|
|
31
31
|
bool _canSend = false;
|
|
32
32
|
|
|
33
|
+
// Owns the field focus so we can intercept hardware Enter: plain Enter sends,
|
|
34
|
+
// Shift+Enter inserts a newline (the standard chat-composer behaviour). On
|
|
35
|
+
// touch keyboards (no hardware key event) the return key keeps inserting a
|
|
36
|
+
// newline and the send orb is used, so mobile is unchanged.
|
|
37
|
+
late final FocusNode _focusNode = FocusNode(onKeyEvent: _onKeyEvent);
|
|
38
|
+
|
|
33
39
|
@override
|
|
34
40
|
void initState() {
|
|
35
41
|
super.initState();
|
|
@@ -40,9 +46,23 @@ class _AiChatComposerState extends State<AiChatComposer> {
|
|
|
40
46
|
@override
|
|
41
47
|
void dispose() {
|
|
42
48
|
widget.controller.removeListener(_onTextChanged);
|
|
49
|
+
_focusNode.dispose();
|
|
43
50
|
super.dispose();
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
|
|
54
|
+
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
55
|
+
final bool isEnter = event.logicalKey == LogicalKeyboardKey.enter ||
|
|
56
|
+
event.logicalKey == LogicalKeyboardKey.numpadEnter;
|
|
57
|
+
if (!isEnter) return KeyEventResult.ignored;
|
|
58
|
+
// Shift+Enter falls through to the field and inserts a newline.
|
|
59
|
+
if (HardwareKeyboard.instance.isShiftPressed) {
|
|
60
|
+
return KeyEventResult.ignored;
|
|
61
|
+
}
|
|
62
|
+
_handleSend();
|
|
63
|
+
return KeyEventResult.handled;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
bool _hasSendableText(String value) {
|
|
47
67
|
final String trimmed = value.trim();
|
|
48
68
|
return trimmed.isNotEmpty && trimmed.length <= kAiChatMaxMessageLength;
|
|
@@ -88,6 +108,7 @@ class _AiChatComposerState extends State<AiChatComposer> {
|
|
|
88
108
|
Expanded(
|
|
89
109
|
child: KasyTextField(
|
|
90
110
|
controller: widget.controller,
|
|
111
|
+
focusNode: _focusNode,
|
|
91
112
|
enabled: enabled,
|
|
92
113
|
variant: KasyTextFieldVariant.embedded,
|
|
93
114
|
hint: t.ai_chat.hint,
|
|
@@ -208,7 +208,7 @@ class _Header extends StatelessWidget {
|
|
|
208
208
|
maxLines: 1,
|
|
209
209
|
overflow: TextOverflow.ellipsis,
|
|
210
210
|
style: context.textTheme.titleLarge?.copyWith(
|
|
211
|
-
fontWeight: FontWeight.
|
|
211
|
+
fontWeight: FontWeight.w700,
|
|
212
212
|
color: context.colors.onBackground,
|
|
213
213
|
),
|
|
214
214
|
),
|
|
@@ -392,6 +392,46 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
|
|
|
392
392
|
/// Falls back to regular sign-in if the Facebook account already exists.
|
|
393
393
|
@override
|
|
394
394
|
Future<Credentials> signupFromAnonymousWithFacebook() async {
|
|
395
|
+
// Web: use the Firebase popup (link to the anonymous user when present), same
|
|
396
|
+
// pattern as Apple/Google on web.
|
|
397
|
+
if (kIsWeb) {
|
|
398
|
+
final facebookProvider = FacebookAuthProvider();
|
|
399
|
+
facebookProvider.addScope('email');
|
|
400
|
+
final currentUser = _auth.currentUser;
|
|
401
|
+
if (currentUser == null) {
|
|
402
|
+
try {
|
|
403
|
+
final result = await _popupOrCancel(
|
|
404
|
+
() => _auth.signInWithPopup(facebookProvider),
|
|
405
|
+
);
|
|
406
|
+
return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
|
|
407
|
+
} on FirebaseAuthException catch (e) {
|
|
408
|
+
if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
|
|
409
|
+
rethrow;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
final result = await _popupOrCancel(() => currentUser.linkWithPopup(facebookProvider));
|
|
414
|
+
return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
|
|
415
|
+
} on FirebaseAuthException catch (e) {
|
|
416
|
+
if (_isUserCancelledPopup(e.code)) throw const UserCancelledSignInException();
|
|
417
|
+
if (e.code == 'credential-already-in-use') {
|
|
418
|
+
final anonymousUser = _auth.currentUser;
|
|
419
|
+
if (anonymousUser != null && anonymousUser.isAnonymous) {
|
|
420
|
+
await anonymousUser.delete();
|
|
421
|
+
}
|
|
422
|
+
final cred = e.credential;
|
|
423
|
+
if (cred != null) {
|
|
424
|
+
final result = await _auth.signInWithCredential(cred);
|
|
425
|
+
return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
|
|
426
|
+
}
|
|
427
|
+
final result = await _popupOrCancel(
|
|
428
|
+
() => _auth.signInWithPopup(facebookProvider),
|
|
429
|
+
);
|
|
430
|
+
return Credentials(id: result.user!.uid, token: result.credential?.token.toString() ?? '');
|
|
431
|
+
}
|
|
432
|
+
rethrow;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
395
435
|
final loginResult = await FacebookAuth.instance.login();
|
|
396
436
|
if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
|
|
397
437
|
if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
|
|
@@ -429,6 +469,27 @@ class FirebaseAuthenticationApi implements AuthenticationApi {
|
|
|
429
469
|
|
|
430
470
|
@override
|
|
431
471
|
Future<Credentials> signinWithFacebook() async {
|
|
472
|
+
// Web has no native Facebook SDK flow: use the Firebase popup, same as Google
|
|
473
|
+
// and Apple on web. Requires the Facebook provider enabled in Firebase
|
|
474
|
+
// (kasy facebook) and the redirect URI registered on Meta.
|
|
475
|
+
if (kIsWeb) {
|
|
476
|
+
final facebookProvider = FacebookAuthProvider();
|
|
477
|
+
facebookProvider.addScope('email');
|
|
478
|
+
try {
|
|
479
|
+
final value = await _popupOrCancel(
|
|
480
|
+
() => _auth.signInWithPopup(facebookProvider),
|
|
481
|
+
);
|
|
482
|
+
return Credentials(
|
|
483
|
+
id: value.user!.uid,
|
|
484
|
+
token: value.credential?.token.toString() ?? '',
|
|
485
|
+
);
|
|
486
|
+
} on FirebaseAuthException catch (e) {
|
|
487
|
+
if (_isUserCancelledPopup(e.code)) {
|
|
488
|
+
throw const UserCancelledSignInException();
|
|
489
|
+
}
|
|
490
|
+
rethrow;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
432
493
|
final LoginResult loginResult = await FacebookAuth.instance.login();
|
|
433
494
|
if (loginResult.status == LoginStatus.cancelled) throw const UserCancelledSignInException();
|
|
434
495
|
if (loginResult.status != LoginStatus.success || loginResult.accessToken == null) {
|
|
@@ -49,7 +49,7 @@ class SigninPage extends ConsumerWidget {
|
|
|
49
49
|
const TextStyle())
|
|
50
50
|
.copyWith(
|
|
51
51
|
color: context.colors.muted,
|
|
52
|
-
fontSize: 13,
|
|
52
|
+
fontSize: 13, // design-check: ignore — small "forgot password" link
|
|
53
53
|
fontWeight: FontWeight.w500,
|
|
54
54
|
);
|
|
55
55
|
return PopScope(
|
|
@@ -237,6 +237,10 @@ class _SocialSigninRow extends ConsumerWidget {
|
|
|
237
237
|
? withAppleWebSignin
|
|
238
238
|
: (defaultTargetPlatform == TargetPlatform.iOS ||
|
|
239
239
|
defaultTargetPlatform == TargetPlatform.macOS);
|
|
240
|
+
// Facebook on web works on the Firebase backend (signInWithPopup); on Supabase
|
|
241
|
+
// the web flow isn't wired yet (roadmap), so withFacebookWebSignin ships false
|
|
242
|
+
// there. Native (iOS/Android) always shows it.
|
|
243
|
+
const bool showFacebook = !kIsWeb || withFacebookWebSignin;
|
|
240
244
|
return Row(
|
|
241
245
|
children: [
|
|
242
246
|
Expanded(
|
|
@@ -263,22 +267,24 @@ class _SocialSigninRow extends ConsumerWidget {
|
|
|
263
267
|
),
|
|
264
268
|
),
|
|
265
269
|
],
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
if (showFacebook) ...[
|
|
271
|
+
const SizedBox(width: KasySpacing.sm),
|
|
272
|
+
Expanded(
|
|
273
|
+
child: _SocialSigninTile(
|
|
274
|
+
label: t.auth.signin.facebook,
|
|
275
|
+
icon: Image.asset(
|
|
276
|
+
'assets/icons/facebook.png',
|
|
277
|
+
width: 20,
|
|
278
|
+
height: 20,
|
|
279
|
+
),
|
|
280
|
+
onPressed: isSending
|
|
281
|
+
? null
|
|
282
|
+
: () => ref
|
|
283
|
+
.read(signinStateProvider.notifier)
|
|
284
|
+
.signinWithFacebook(),
|
|
274
285
|
),
|
|
275
|
-
onPressed: isSending
|
|
276
|
-
? null
|
|
277
|
-
: () => ref
|
|
278
|
-
.read(signinStateProvider.notifier)
|
|
279
|
-
.signinWithFacebook(),
|
|
280
286
|
),
|
|
281
|
-
|
|
287
|
+
],
|
|
282
288
|
],
|
|
283
289
|
);
|
|
284
290
|
}
|
|
@@ -216,6 +216,10 @@ class _SocialSignupRow extends ConsumerWidget {
|
|
|
216
216
|
? withAppleWebSignin
|
|
217
217
|
: (defaultTargetPlatform == TargetPlatform.iOS ||
|
|
218
218
|
defaultTargetPlatform == TargetPlatform.macOS);
|
|
219
|
+
// Facebook on web works on the Firebase backend (signInWithPopup); on Supabase
|
|
220
|
+
// the web flow isn't wired yet (roadmap), so withFacebookWebSignin ships false
|
|
221
|
+
// there. Native (iOS/Android) always shows it.
|
|
222
|
+
const bool showFacebook = !kIsWeb || withFacebookWebSignin;
|
|
219
223
|
return Row(
|
|
220
224
|
children: [
|
|
221
225
|
Expanded(
|
|
@@ -242,22 +246,24 @@ class _SocialSignupRow extends ConsumerWidget {
|
|
|
242
246
|
),
|
|
243
247
|
),
|
|
244
248
|
],
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
if (showFacebook) ...[
|
|
250
|
+
const SizedBox(width: KasySpacing.sm),
|
|
251
|
+
Expanded(
|
|
252
|
+
child: _SocialSignupTile(
|
|
253
|
+
label: t.auth.signin.facebook,
|
|
254
|
+
icon: Image.asset(
|
|
255
|
+
'assets/icons/facebook.png',
|
|
256
|
+
width: 20,
|
|
257
|
+
height: 20,
|
|
258
|
+
),
|
|
259
|
+
onPressed: isSending
|
|
260
|
+
? null
|
|
261
|
+
: () => ref
|
|
262
|
+
.read(signinStateProvider.notifier)
|
|
263
|
+
.signinWithFacebook(),
|
|
253
264
|
),
|
|
254
|
-
onPressed: isSending
|
|
255
|
-
? null
|
|
256
|
-
: () => ref
|
|
257
|
-
.read(signinStateProvider.notifier)
|
|
258
|
-
.signinWithFacebook(),
|
|
259
265
|
),
|
|
260
|
-
|
|
266
|
+
],
|
|
261
267
|
],
|
|
262
268
|
);
|
|
263
269
|
}
|
|
@@ -60,7 +60,6 @@ class _FeatureCardState extends State<FeatureCard> {
|
|
|
60
60
|
Text(
|
|
61
61
|
widget.title,
|
|
62
62
|
style: context.textTheme.titleMedium?.copyWith(
|
|
63
|
-
fontSize: 16,
|
|
64
63
|
fontWeight: FontWeight.w700,
|
|
65
64
|
),
|
|
66
65
|
),
|
|
@@ -251,7 +250,7 @@ class _VoteCardState extends State<VoteCard>
|
|
|
251
250
|
key: ValueKey('votes-${widget.id}-${widget.votes}'),
|
|
252
251
|
widget.votes.toString(),
|
|
253
252
|
style: context.textTheme.labelLarge?.copyWith(
|
|
254
|
-
fontSize: 15,
|
|
253
|
+
fontSize: 15, // design-check: ignore — vote-count badge
|
|
255
254
|
height: 1,
|
|
256
255
|
fontWeight: FontWeight.w800,
|
|
257
256
|
color: widget.textColor,
|