diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bdfda89..1dab89c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -202,6 +202,7 @@ "share": "Share", "noComments": "No comments yet.", "writeComment": "Write a comment", + "commentLoginHint": "Sign in to comment", "postComment": "Post", "loadMore": "Load more", "commentsLoadFailed": "Comments could not be loaded.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index eba5ffb..4b28acb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -176,6 +176,7 @@ "share": "Bagikan", "noComments": "Belum ada komentar.", "writeComment": "Tulis komentar", + "commentLoginHint": "Masuk untuk berkomentar", "postComment": "Kirim", "loadMore": "Muat lebih banyak", "commentsLoadFailed": "Komentar tidak dapat dimuat.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a646edf..a66503a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -922,6 +922,12 @@ abstract class AppLocalizations { /// **'Write a comment'** String get writeComment; + /// No description provided for @commentLoginHint. + /// + /// In en, this message translates to: + /// **'Sign in to comment'** + String get commentLoginHint; + /// No description provided for @postComment. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f7bca93..cc597d8 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -448,6 +448,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get writeComment => 'Write a comment'; + @override + String get commentLoginHint => 'Sign in to comment'; + @override String get postComment => 'Post'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 08a7bbb..fb002d5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -449,6 +449,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get writeComment => 'Tulis komentar'; + @override + String get commentLoginHint => 'Masuk untuk berkomentar'; + @override String get postComment => 'Kirim'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f29c0bd..6856d4c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -443,6 +443,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get writeComment => '写评论'; + @override + String get commentLoginHint => '登录后参与评论'; + @override String get postComment => '发布'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 00541ed..10e0a0d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -176,6 +176,7 @@ "share": "分享", "noComments": "还没有评论。", "writeComment": "写评论", + "commentLoginHint": "登录后参与评论", "postComment": "发布", "loadMore": "加载更多", "commentsLoadFailed": "评论加载失败,请重试。", diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 6576a35..c3b4c34 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -10,6 +10,7 @@ import '../../../core/data/models/drama.dart'; import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; +import '../../../core/data/services/auth_controller.dart'; import '../../../core/formatting/error_text.dart'; import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; @@ -64,8 +65,8 @@ class _DramaCommentsSheetState extends State { Future _post() async { if (_controller.submitting) return; - if (_input.text.trim().isEmpty) return; if (!await ensureSignedIn(context) || !mounted) return; + if (_input.text.trim().isEmpty) return; unawaited(context.read().lightImpact()); final result = await _controller.publish(_input.text, replyTo: _replyTo); if (!mounted) return; @@ -90,6 +91,13 @@ class _DramaCommentsSheetState extends State { setState(() => _replyTo = null); } + Future _authenticateForComment() async { + _inputFocus.unfocus(); + await ensureSignedIn(context); + if (!mounted || !context.read().isSignedIn) return; + _inputFocus.requestFocus(); + } + Future _like(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; unawaited(context.read().lightImpact()); @@ -351,44 +359,52 @@ class _DramaCommentsSheetState extends State { ); } - Widget _composer(AppLocalizations l10n) => Row( - children: [ - Expanded( - child: SizedBox( - height: _composerHeight, - child: TextField( - key: const Key('comment-input'), - controller: _input, - focusNode: _inputFocus, - maxLength: _maxCommentLength, - maxLines: 1, - textAlignVertical: TextAlignVertical.center, - style: const TextStyle(color: AppColors.cream, fontSize: 14), - cursorColor: AppColors.blue, - decoration: _composerDecoration(l10n.writeComment), - onSubmitted: (_) => _post(), + Widget _composer(AppLocalizations l10n) { + final signedIn = context.watch().isSignedIn; + return Row( + children: [ + Expanded( + child: SizedBox( + height: _composerHeight, + child: TextField( + key: const Key('comment-input'), + controller: _input, + focusNode: _inputFocus, + readOnly: !signedIn, + canRequestFocus: signedIn, + maxLength: _maxCommentLength, + maxLines: 1, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: AppColors.cream, fontSize: 14), + cursorColor: AppColors.blue, + decoration: _composerDecoration( + signedIn ? l10n.writeComment : l10n.commentLoginHint, + ), + onTap: signedIn ? null : _authenticateForComment, + onSubmitted: (_) => _post(), + ), ), ), - ), - const SizedBox(width: 8), - SizedBox.square( - dimension: _sendButtonSize, - child: IconButton.filled( - key: const Key('comment-post'), - tooltip: l10n.postComment, - padding: EdgeInsets.zero, - iconSize: 18, - onPressed: _controller.submitting ? null : _post, - icon: _controller.submitting - ? const SizedBox.square( - dimension: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send_rounded), + const SizedBox(width: 8), + SizedBox.square( + dimension: _sendButtonSize, + child: IconButton.filled( + key: const Key('comment-post'), + tooltip: l10n.postComment, + padding: EdgeInsets.zero, + iconSize: 18, + onPressed: _controller.submitting ? null : _post, + icon: _controller.submitting + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send_rounded), + ), ), - ), - ], - ); + ], + ); + } } class _CommentThread extends StatelessWidget { diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index f23ad4a..9e8cff8 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/comment.dart'; @@ -41,6 +43,71 @@ void main() { }); }); + testWidgets('should_gate_guest_comment_input_without_focus', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + final input = find.byKey(const Key('comment-input')); + final field = tester.widget(input); + + expect(field.decoration?.hintText, 'Sign in to comment'); + expect(field.focusNode?.hasFocus, isFalse); + await tester.tap(input); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + await tester.tap(find.text('Not now')); + await tester.pumpAndSettle(); + expect(tester.widget(input).focusNode?.hasFocus, isFalse); + }); + + testWidgets('should_focus_comment_input_after_successful_login', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + final auth = await _pumpSheet(tester, repository: repository); + + await tester.tap(find.byKey(const Key('comment-input'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Sign In')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + (auth as _TestAuthController).setSignedIn(true); + await tester.pump(); + Navigator.of(tester.element(find.byKey(const Key('auth-landing')))).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + tester + .widget(find.byKey(const Key('comment-input'))) + .focusNode + ?.hasFocus, + isTrue, + ); + }); + + testWidgets('should_gate_guest_post_without_creating_comment', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final repository = FakeCommentRepository(pages: [const CommentPage()]); + + await _pumpSheet(tester, repository: repository); + await tester.tap(find.byKey(const Key('comment-post'))); + await tester.pumpAndSettle(); + + expect(find.text('Sign in to continue'), findsOneWidget); + expect(repository.publishCalls, isEmpty); + }); + testWidgets('should_show_server_comments_and_append_next_cursor_page', ( tester, ) async { @@ -392,7 +459,7 @@ void main() { }); } -Future _pumpSheet( +Future _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeInteractionRepository? interactions, @@ -421,16 +488,51 @@ Future _pumpSheet( ), ); await tester.pumpAndSettle(); + return auth; } Future _auth() async { final client = ApiClient( baseUrl: 'http://127.0.0.1:8081', - adapter: FakeHttpAdapter( - (_) async => FakeHttpResponse('{"code":0,"message":"ok","data":null}'), - ), + adapter: FakeHttpAdapter((options) async { + if (options.uri.path == '/app/auth/login') { + return FakeHttpResponse( + jsonEncode({ + 'code': 0, + 'message': 'ok', + 'data': { + 'accessToken': 'access', + 'refreshToken': 'refresh', + 'expiresIn': 7200, + 'user': {'uid': '9', 'nickname': 'Viewer', 'avatarUrl': ''}, + }, + }), + ); + } + return FakeHttpResponse('{"code":0,"message":"ok","data":null}'); + }), + ); + final store = AuthSessionStore(); + final session = await store.read(); + final auth = _TestAuthController( + AuthApi(client), + store, + signedIn: session != null, ); - final auth = AuthController(AuthApi(client), AuthSessionStore()); await auth.load(); return auth; } + +class _TestAuthController extends AuthController { + _TestAuthController(super.api, super.store, {required this._signedIn}); + + bool _signedIn; + + @override + bool get isSignedIn => _signedIn; + + void setSignedIn(bool value) { + _signedIn = value; + notifyListeners(); + } +}