diff --git a/lib/pages/home_album_section.dart b/lib/pages/home_album_section.dart index 0db9826..de5c803 100644 --- a/lib/pages/home_album_section.dart +++ b/lib/pages/home_album_section.dart @@ -177,101 +177,93 @@ class _HomeAlbumCard extends StatelessWidget { '${_count(stats!.subscribeCount!)} 订阅', if (stats?.finished == true) '完结', ]; - return Tooltip( - message: album.title, - child: Material( - color: ranking ? colors.surfaceContainerLow : Colors.transparent, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - hoverColor: colors.primary.withValues(alpha: .045), - focusColor: colors.primary.withValues(alpha: .09), - highlightColor: colors.primary.withValues(alpha: .07), - child: Padding( - padding: EdgeInsets.all(ranking ? 8 : 4), - child: ranking - ? Row( - children: [ - SizedBox( - width: MediaQuery.textScalerOf(context).scale(22) * 1.5, - child: Text( - '$rank'.padLeft(2, '0'), - style: theme.textTheme.titleLarge?.copyWith( - fontSize: 22, - fontWeight: FontWeight.w600, - color: rank! <= 3 - ? colors.primary - : colors.onSurfaceVariant.withValues( - alpha: .65, - ), - ), - ), - ), - if (showCover) ...[ - CoverImage(uri: album.coverUrl, size: coverSize), - const SizedBox(width: 8), - ], - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - title, - const SizedBox(height: 4), - Row( - children: [ - Expanded(child: artist), - if (stats?.score != null) ...[ - const SizedBox(width: 8), - _score(context), - ], - ], - ), - ], + return Material( + color: ranking ? colors.surfaceContainerLow : Colors.transparent, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + hoverColor: colors.primary.withValues(alpha: .045), + focusColor: colors.primary.withValues(alpha: .09), + highlightColor: colors.primary.withValues(alpha: .07), + child: Padding( + padding: EdgeInsets.all(ranking ? 8 : 4), + child: ranking + ? Row( + children: [ + SizedBox( + width: MediaQuery.textScalerOf(context).scale(22) * 1.5, + child: Text( + '$rank'.padLeft(2, '0'), + style: theme.textTheme.titleLarge?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w600, + color: rank! <= 3 + ? colors.primary + : colors.onSurfaceVariant.withValues(alpha: .65), ), ), + ), + if (showCover) ...[ + CoverImage(uri: album.coverUrl, size: coverSize), + const SizedBox(width: 8), ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showCover) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CoverImage( - uri: album.coverUrl, - size: coverSize, - ), - ), - const SizedBox(height: 6), - ], - SizedBox(height: titleHeight, child: title), - const SizedBox(height: 4), - artist, - const SizedBox(height: 4), - Row( + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - details.join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - height: 1.4, - color: colors.onSurfaceVariant, - ), - ), + title, + const SizedBox(height: 4), + Row( + children: [ + Expanded(child: artist), + if (stats?.score != null) ...[ + const SizedBox(width: 8), + _score(context), + ], + ], ), - if (stats?.score != null) ...[ - const SizedBox(width: 8), - _score(context), - ], ], ), + ), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showCover) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CoverImage(uri: album.coverUrl, size: coverSize), + ), + const SizedBox(height: 6), ], - ), - ), + SizedBox(height: titleHeight, child: title), + const SizedBox(height: 4), + artist, + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + details.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + height: 1.4, + color: colors.onSurfaceVariant, + ), + ), + ), + if (stats?.score != null) ...[ + const SizedBox(width: 8), + _score(context), + ], + ], + ), + ], + ), ), ), ); diff --git a/lib/pages/track_comments_panel.dart b/lib/pages/track_comments_panel.dart index f4f77fd..2bb1a78 100644 --- a/lib/pages/track_comments_panel.dart +++ b/lib/pages/track_comments_panel.dart @@ -82,6 +82,8 @@ class _TrackCommentsPanelState extends State { final _scroll = ScrollController(); final _compose = TextEditingController(); bool _sending = false; + // Switching away and back can reuse the same client while a send is pending. + int _sendRequest = 0; String? _composeError; late _CommentFeed _feed; String? _expandedComment; @@ -115,6 +117,9 @@ class _TrackCommentsPanelState extends State { if (identical(_feed.client, widget.runtime.client)) return; _feed.dispose(); _expandedComment = null; + _sendRequest++; + _sending = false; + _composeError = null; _openFeed(); } @@ -182,6 +187,7 @@ class _TrackCommentsPanelState extends State { if (_sending) return; final client = widget.runtime.client, trackId = widget.track.id; final content = _compose.text; + final request = ++_sendRequest; setState(() { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_TrackCommentsPanelState._send.closure#1", @@ -196,6 +202,7 @@ class _TrackCommentsPanelState extends State { } await XmlyComments(client).create(trackId, content); if (!mounted || + request != _sendRequest || widget.track.id != trackId || !identical(client, widget.runtime.client)) return; @@ -205,6 +212,7 @@ class _TrackCommentsPanelState extends State { } catch (error, stack) { _recordCommentsError('Creating comment failed', error, stack); if (mounted && + request == _sendRequest && widget.track.id == trackId && identical(client, widget.runtime.client)) { setState(() { @@ -216,7 +224,7 @@ class _TrackCommentsPanelState extends State { }); } } finally { - if (mounted) + if (mounted && request == _sendRequest) setState(() { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_TrackCommentsPanelState._send.closure#3", @@ -242,6 +250,8 @@ class _TrackCommentsPanelState extends State { oldWidget.track.id != widget.track.id) { _feed.dispose(); _expandedComment = null; + _sendRequest++; + _sending = false; _compose.clear(); _composeError = null; if (_scroll.hasClients) _scroll.jumpTo(0); @@ -353,6 +363,7 @@ class _TrackCommentsPanelState extends State { comment: comment, runtime: widget.runtime, trackId: widget.track.id, + onLiked: _feed.setLiked, onChanged: () { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_TrackCommentsPanelState._buildPanel.closure#1.closure#1", @@ -525,6 +536,7 @@ class _CommentContent extends StatefulWidget { required this.comment, required this.runtime, required this.trackId, + required this.onLiked, required this.onChanged, this.action, this.compact = false, @@ -532,6 +544,7 @@ class _CommentContent extends StatefulWidget { final TrackComment comment; final AppRuntime runtime; final String trackId; + final void Function(String commentId, bool liked) onLiked; final Future Function() onChanged; final Widget? action; final bool compact; @@ -547,25 +560,16 @@ class _CommentContent extends StatefulWidget { class _CommentContentState extends State<_CommentContent> { bool _busy = false; - TrackComment? _updated; String? _error; - @override - void didUpdateWidget(covariant _CommentContent oldWidget) { - AppLog.instance.trace( - 'lib/pages/track_comments_panel.dart::_CommentContentState.didUpdateWidget', - ); - super.didUpdateWidget(oldWidget); - if (!identical(oldWidget.comment, widget.comment)) _updated = null; - } - Future _mutate({bool delete = false}) async { AppLog.instance.trace( 'lib/pages/track_comments_panel.dart::_CommentContentState._mutate', ); if (_busy) return; final runtime = widget.runtime, client = widget.runtime.client; - final comment = _updated ?? widget.comment; + final comment = widget.comment; + final onLiked = widget.onLiked; setState(() { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#1", @@ -626,25 +630,20 @@ class _CommentContentState extends State<_CommentContent> { final api = XmlyComments(client); if (delete) { await api.delete(widget.trackId, comment); + if (mounted && identical(client, runtime.client)) + await widget.onChanged(); } else { await api.setLiked(widget.trackId, comment, liked: !comment.liked); - if (mounted) - setState(() { - AppLog.instance.trace( - "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#3", - ); - - _updated = comment.withLike(!comment.liked); - }); + // The feed owns the result even if this row scrolled out of view. + if (identical(client, runtime.client)) + onLiked(comment.id, !comment.liked); } - if (mounted && identical(client, runtime.client)) - await widget.onChanged(); } catch (error, stack) { _recordCommentsError('Comment mutation failed', error, stack); if (mounted) setState(() { AppLog.instance.trace( - "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#4", + "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#3", ); _error = error is XmlyException ? error.message : '评论操作失败,请重试'; @@ -653,7 +652,7 @@ class _CommentContentState extends State<_CommentContent> { if (mounted) setState(() { AppLog.instance.trace( - "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#5", + "lib/pages/track_comments_panel.dart::_CommentContentState._mutate.closure#4", ); _busy = false; @@ -666,7 +665,7 @@ class _CommentContentState extends State<_CommentContent> { AppLog.instance.trace( 'lib/pages/track_comments_panel.dart::_CommentContentState.build', ); - final theme = Theme.of(context), comment = _updated ?? widget.comment; + final theme = Theme.of(context), comment = widget.comment; final own = comment.uid.isNotEmpty && comment.uid == widget.runtime.client.credentials?.uid; @@ -918,6 +917,7 @@ class _InlineRepliesState extends State<_InlineReplies> runtime: widget.runtime, trackId: widget.trackId, compact: true, + onLiked: _feed.setLiked, onChanged: () { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_InlineRepliesState.build.closure#1.closure#1", @@ -1057,6 +1057,21 @@ class _CommentFeed extends ChangeNotifier { Completer? _pendingRefresh; String? error; + void setLiked(String id, bool liked) { + AppLog.instance.trace( + 'lib/pages/track_comments_panel.dart::_CommentFeed.setLiked', + ); + if (_disposed) return; + for (var index = 0; index < comments.length; index++) { + final comment = comments[index]; + if (comment.id != id) continue; + // A refresh may have replaced the row while the like was pending. + comments[index] = comment.withLike(liked); + notifyListeners(); + return; + } + } + Future load({bool reset = false}) { AppLog.instance.trace( "lib/pages/track_comments_panel.dart::_CommentFeed.load", diff --git a/test/track_comments_panel_test.dart b/test/track_comments_panel_test.dart new file mode 100644 index 0000000..176f952 --- /dev/null +++ b/test/track_comments_panel_test.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xmly_flutter/api/xmly_client.dart'; +import 'package:xmly_flutter/models/xmly_models.dart'; +import 'package:xmly_flutter/pages/track_comments_panel.dart'; +import 'package:xmly_flutter/services/account_service.dart'; +import 'package:xmly_flutter/services/app_runtime.dart'; + +class _CommentsClient extends XmlyClient { + _CommentsClient({String uid = '1', int count = 60}) + : rows = List.generate( + count, + (index) => { + 'id': '${index + 1}', + 'uid': '99', + 'nickname': 'Listener ${index + 1}', + 'content': 'Comment ${index + 1}', + 'likes': 0, + 'liked': false, + 'replyCount': index == 0 ? 2 : 0, + }, + ), + super( + credentials: Credentials(token: '$uid&fixture', deviceId: 'fixture'), + ); + + final List> rows; + final pages = []; + final creates = >>[]; + final replies = [ + { + 'id': '101', + 'uid': '99', + 'nickname': 'Reply author', + 'content': 'First reply', + 'likes': 0, + 'liked': false, + }, + { + 'id': '102', + 'uid': '99', + 'nickname': 'Reply author', + 'content': 'Second reply', + 'likes': 0, + 'liked': false, + }, + ]; + int replyReads = 0; + Completer? likeGate; + + @override + Future> request( + String method, + String origin, + String path, { + Map? query, + Map? data, + bool account = true, + bool signed = false, + bool form = false, + }) async { + if (path == '/comment-mobile/v1/create') { + final pending = Completer>(); + creates.add(pending); + return pending.future; + } + if (path == '/comment-mobile/like') { + await likeGate?.future; + final row = [ + ...rows, + ...replies, + ].singleWhere((row) => row['id'] == data!['commentId']); + final liked = data!['islike'] == 'true'; + if (row['liked'] != liked) + row['likes'] = (row['likes'] as int) + (liked ? 1 : -1); + row['liked'] = liked; + return {'ret': 0}; + } + if (path == '/revision/comment/queryReplies') { + replyReads++; + return { + 'data': { + 'currentPage': 1, + 'totalComment': 2, + 'totalPage': 1, + 'replies': replies, + }, + }; + } + if (path.startsWith('/comment-mobile/v1/track/comment/')) { + final page = query!['pageId'] as int; + pages.add(page); + return { + 'comment': { + 'pageId': page, + 'totalCount': rows.length, + 'maxPageId': (rows.length / 20).ceil(), + 'list': rows.skip((page - 1) * 20).take(20).toList(), + }, + }; + } + throw StateError('Unexpected request: $method $path'); + } +} + +class _OpenAccounts implements AccountService { + @override + bool get admissionPaused => false; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _CommentsRuntime extends ChangeNotifier implements AppRuntime { + _CommentsRuntime(this.client); + @override + _CommentsClient client; + @override + final AccountService accounts = _OpenAccounts(); + + void useClient(_CommentsClient next) { + client = next; + notifyListeners(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +Finder _row(String id) => find.byKey(ValueKey(('xmly:100', id))); +Finder _like(Finder row) => find.descendant( + of: row, + matching: find.widgetWithIcon(TextButton, Icons.thumb_up_outlined), +); + +Widget _app(_CommentsRuntime runtime, {String trackId = '100'}) => MaterialApp( + theme: ThemeData(splashFactory: InkRipple.splashFactory), + home: Scaffold( + body: TrackCommentsPanel( + runtime: runtime, + track: Track(id: trackId, albumId: '200'), + ), + ), +); + +Future<_CommentsRuntime> _mount( + WidgetTester tester, + List<_CommentsClient> clients, +) async { + final runtime = _CommentsRuntime(clients.first); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + for (final client in clients) { + for (final create in client.creates) { + if (!create.isCompleted) create.complete({'ret': 0}); + } + final gate = client.likeGate; + if (gate != null && !gate.isCompleted) gate.complete(); + client.close(); + } + await tester.pump(); + runtime.dispose(); + }); + await tester.pumpWidget(_app(runtime)); + await tester.pumpAndSettle(); + return runtime; +} + +void main() { + testWidgets( + 'liking a later page keeps the page, position and recycled row state', + (tester) async { + final client = _CommentsClient(); + await _mount(tester, [client]); + final list = find.byKey(const ValueKey('trackCommentsScroll')); + final scrollable = find + .descendant(of: list, matching: find.byType(Scrollable)) + .first; + await tester.scrollUntilVisible( + _like(_row('25')), + 500, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + final controller = tester.widget(list).controller!; + final offset = controller.offset; + final pages = List.of(client.pages); + expect(pages, contains(2)); + + await tester.tap(_like(_row('25'))); + await tester.pumpAndSettle(); + expect( + client.pages, + pages, + reason: 'Liking must not reload the first page', + ); + expect(controller.offset, closeTo(offset, 1)); + expect( + find.descendant(of: _row('25'), matching: find.byIcon(Icons.thumb_up)), + findsOneWidget, + ); + + controller.jumpTo(0); + await tester.pumpAndSettle(); + expect(_row('25'), findsNothing); + controller.jumpTo(offset); + await tester.pumpAndSettle(); + expect( + find.descendant(of: _row('25'), matching: find.byIcon(Icons.thumb_up)), + findsOneWidget, + ); + expect( + find.descendant(of: _row('25'), matching: find.text('1')), + findsOneWidget, + ); + }, + ); + + testWidgets('a like survives its row being recycled before completion', ( + tester, + ) async { + final client = _CommentsClient()..likeGate = Completer(); + await _mount(tester, [client]); + await tester.tap(_like(_row('1'))); + await tester.pump(); + final list = find.byKey(const ValueKey('trackCommentsScroll')); + final scrollable = find + .descendant(of: list, matching: find.byType(Scrollable)) + .first; + await tester.scrollUntilVisible( + find.text('Comment 15'), + 500, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect(_row('1'), findsNothing); + final pages = List.of(client.pages); + client.likeGate!.complete(); + await tester.pumpAndSettle(); + tester.widget(list).controller!.jumpTo(0); + await tester.pumpAndSettle(); + expect(client.pages, pages); + expect( + find.descendant(of: _row('1'), matching: find.byIcon(Icons.thumb_up)), + findsOneWidget, + ); + expect( + find.descendant(of: _row('1'), matching: find.text('1')), + findsOneWidget, + ); + }); + + testWidgets('liking an inline reply does not reload its list', ( + tester, + ) async { + final client = _CommentsClient(count: 1); + await _mount(tester, [client]); + await tester.tap(find.byKey(const ValueKey('commentReplies:1'))); + await tester.pumpAndSettle(); + expect(client.replyReads, 1); + final reply = find.byKey(const ValueKey('101')); + await tester.tap(_like(reply)); + await tester.pumpAndSettle(); + expect(client.replyReads, 1); + expect( + find.descendant(of: reply, matching: find.byIcon(Icons.thumb_up)), + findsOneWidget, + ); + }); + + testWidgets( + 'a like completing after a refresh preserves the newer row data', + (tester) async { + final client = _CommentsClient(count: 1)..likeGate = Completer(); + await _mount(tester, [client]); + await tester.tap(_like(_row('1'))); + await tester.pump(); + client.rows.first['likes'] = 10; + client.rows.first['content'] = 'Refreshed comment'; + await tester.tap(find.byTooltip('刷新评论')); + await tester.pump(); + await tester.pump(); + client.likeGate!.complete(); + await tester.pumpAndSettle(); + expect(client.pages, [1, 1]); + expect(find.text('Refreshed comment'), findsOneWidget); + expect( + find.descendant(of: _row('1'), matching: find.text('11')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'an old account send cannot lock or finish a newer account send', + (tester) async { + final first = _CommentsClient(count: 1); + final second = _CommentsClient(uid: '2', count: 1); + final runtime = await _mount(tester, [first, second]); + await tester.enterText(find.byType(TextField), 'First account comment'); + await tester.tap(find.byTooltip('发送评论')); + await tester.pump(); + expect(first.creates, hasLength(1)); + runtime.useClient(second); + await tester.pump(); + await tester.pump(); + expect(tester.widget(find.byType(TextField)).enabled, isTrue); + + await tester.enterText(find.byType(TextField), 'Second account comment'); + await tester.tap(find.byTooltip('发送评论')); + await tester.pump(); + expect(second.creates, hasLength(1)); + first.creates.single.complete({'ret': 0}); + await tester.pump(); + await tester.pump(); + final field = tester.widget(find.byType(TextField)); + expect(field.enabled, isFalse); + expect(field.controller!.text, 'Second account comment'); + second.creates.single.complete({'ret': 0}); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + isEmpty, + ); + }, + ); + + for (final fails in [false, true]) { + testWidgets( + 'returning to a cached account keeps a draft after an old send ${fails ? 'fails' : 'succeeds'}', + (tester) async { + final first = _CommentsClient(count: 1); + final second = _CommentsClient(uid: '2', count: 1); + final runtime = await _mount(tester, [first, second]); + await tester.enterText(find.byType(TextField), 'Earlier comment'); + await tester.tap(find.byTooltip('发送评论')); + await tester.pump(); + runtime.useClient(second); + await tester.pump(); + runtime.useClient(first); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.byType(TextField)).enabled, + isTrue, + ); + await tester.enterText(find.byType(TextField), 'Keep this new draft'); + if (fails) { + first.creates.single.completeError( + const XmlyException('rate_limited', 'Earlier send failed'), + ); + } else { + first.creates.single.complete({'ret': 0}); + } + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Keep this new draft', + ); + expect(find.text('Earlier send failed'), findsNothing); + }, + ); + } + + testWidgets('changing tracks unlocks the composer and ignores an old send', ( + tester, + ) async { + final client = _CommentsClient(count: 1); + final runtime = await _mount(tester, [client]); + await tester.enterText(find.byType(TextField), 'Earlier track comment'); + await tester.tap(find.byTooltip('发送评论')); + await tester.pump(); + expect(client.creates, hasLength(1)); + await tester.pumpWidget(_app(runtime, trackId: '101')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.enabled, isTrue); + expect(field.controller!.text, isEmpty); + await tester.enterText(find.byType(TextField), 'New track draft'); + client.creates.single.complete({'ret': 0}); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'New track draft', + ); + }); +}