Skip to content

Commit 9486ece

Browse files
authored
refactor(samples): Improve User Profile UI (#21)
1 parent 04dad55 commit 9486ece

16 files changed

+1411
-888
lines changed

pubspec.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ packages:
468468
source: hosted
469469
version: "2.1.4"
470470
stream_core:
471-
dependency: transitive
471+
dependency: "direct main"
472472
description:
473473
path: "packages/stream_core"
474474
ref: "280b1045e39388668fd060439259831611b51b5a"
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
3+
import 'package:stream_feeds/stream_feeds.dart';
4+
5+
import '../../../core/di/di_initializer.dart';
6+
import '../../../theme/extensions/theme_extensions.dart';
7+
import 'user_comments_item.dart';
8+
9+
class UserComments extends StatefulWidget {
10+
const UserComments({
11+
required this.activityId,
12+
required this.feed,
13+
this.scrollController,
14+
super.key,
15+
});
16+
17+
final String activityId;
18+
final Feed feed;
19+
final ScrollController? scrollController;
20+
21+
@override
22+
State<UserComments> createState() => _UserCommentsState();
23+
}
24+
25+
class _UserCommentsState extends State<UserComments> {
26+
StreamFeedsClient get client => locator<StreamFeedsClient>();
27+
28+
late Activity activity;
29+
RemoveListener? _removeFeedListener;
30+
late List<FeedOwnCapability> capabilities;
31+
32+
@override
33+
void initState() {
34+
super.initState();
35+
_getActivity();
36+
_observeFeedCapabilities();
37+
}
38+
39+
@override
40+
void didUpdateWidget(covariant UserComments oldWidget) {
41+
super.didUpdateWidget(oldWidget);
42+
if (oldWidget.activityId != widget.activityId ||
43+
oldWidget.feed != widget.feed) {
44+
activity.dispose();
45+
_getActivity();
46+
}
47+
if (oldWidget.feed != widget.feed) {
48+
_observeFeedCapabilities();
49+
}
50+
}
51+
52+
@override
53+
void dispose() {
54+
_removeFeedListener?.call();
55+
activity.dispose();
56+
super.dispose();
57+
}
58+
59+
@override
60+
Widget build(BuildContext context) {
61+
return StateNotifierBuilder(
62+
stateNotifier: activity.notifier,
63+
builder: (context, state, child) {
64+
final comments = state.comments;
65+
final activity = state.activity;
66+
final canLoadMore = state.canLoadMoreComments;
67+
68+
return Column(
69+
children: [
70+
_buildHeader(
71+
context,
72+
activity,
73+
comments,
74+
),
75+
Expanded(
76+
child: _buildUserCommentsList(
77+
context,
78+
comments,
79+
canLoadMore,
80+
),
81+
),
82+
],
83+
);
84+
},
85+
);
86+
}
87+
88+
Widget _buildHeader(
89+
BuildContext context,
90+
ActivityData? activity,
91+
List<ThreadedCommentData> comments,
92+
) {
93+
final totalComments = activity?.commentCount ?? 0;
94+
95+
return Container(
96+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
97+
decoration: BoxDecoration(
98+
border: Border(
99+
bottom: BorderSide(color: context.appColors.borders),
100+
),
101+
),
102+
child: Row(
103+
mainAxisAlignment: MainAxisAlignment.spaceBetween,
104+
children: [
105+
Column(
106+
crossAxisAlignment: CrossAxisAlignment.start,
107+
children: [
108+
Text(
109+
'Comments',
110+
style: context.appTextStyles.headlineBold,
111+
),
112+
if (totalComments > 0)
113+
Text(
114+
'$totalComments ${totalComments == 1 ? 'comment' : 'comments'}',
115+
style: context.appTextStyles.footnote.copyWith(
116+
color: context.appColors.textLowEmphasis,
117+
),
118+
),
119+
],
120+
),
121+
OutlinedButton.icon(
122+
onPressed: _onReplyClick,
123+
icon: const Icon(Icons.chat_bubble_outline_rounded),
124+
label: const Text('Add'),
125+
style: OutlinedButton.styleFrom(
126+
minimumSize: Size.zero,
127+
iconColor: context.appColors.accentPrimary,
128+
foregroundColor: context.appColors.accentPrimary,
129+
side: BorderSide(color: context.appColors.accentPrimary),
130+
padding: const EdgeInsets.symmetric(
131+
horizontal: 12,
132+
vertical: 6,
133+
),
134+
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
135+
textStyle: context.appTextStyles.footnoteBold,
136+
),
137+
),
138+
],
139+
),
140+
);
141+
}
142+
143+
Widget _buildUserCommentsList(
144+
BuildContext context,
145+
List<ThreadedCommentData> comments,
146+
bool canLoadMore,
147+
) {
148+
if (comments.isEmpty) return const EmptyComments();
149+
150+
return RefreshIndicator(
151+
onRefresh: _getActivity,
152+
child: ListView.separated(
153+
padding: const EdgeInsets.symmetric(horizontal: 16),
154+
controller: widget.scrollController,
155+
itemCount: comments.length + 1,
156+
separatorBuilder: (context, index) => Divider(
157+
height: 1,
158+
color: context.appColors.borders,
159+
),
160+
itemBuilder: (context, index) {
161+
if (index == comments.length) {
162+
return switch (canLoadMore) {
163+
true => TextButton(
164+
onPressed: activity.queryMoreComments,
165+
child: const Text('Load more...'),
166+
),
167+
false => const Padding(
168+
padding: EdgeInsets.all(16),
169+
child: Center(
170+
child: Text('End of comments'),
171+
),
172+
),
173+
};
174+
}
175+
176+
final comment = comments[index];
177+
178+
return UserCommentItem(
179+
comment: comment,
180+
onHeartClick: _onHeartClick,
181+
onReplyClick: _onReplyClick,
182+
onLongPressComment: _onLongPressComment,
183+
);
184+
},
185+
),
186+
);
187+
}
188+
189+
void _observeFeedCapabilities() {
190+
_removeFeedListener?.call();
191+
_removeFeedListener = widget.feed.notifier.addListener(_onFeedStateChange);
192+
}
193+
194+
void _onFeedStateChange(FeedState state) {
195+
capabilities = state.ownCapabilities;
196+
}
197+
198+
Future<void> _getActivity() async {
199+
activity = client.activity(
200+
activityId: widget.activityId,
201+
fid: widget.feed.fid,
202+
);
203+
204+
await activity.get();
205+
}
206+
207+
void _onHeartClick(ThreadedCommentData comment, bool isAdding) {
208+
const type = 'heart';
209+
210+
if (isAdding) {
211+
activity.addCommentReaction(
212+
commentId: comment.id,
213+
request: const AddCommentReactionRequest(type: type),
214+
);
215+
} else {
216+
activity.deleteCommentReaction(
217+
comment.id,
218+
type,
219+
);
220+
}
221+
}
222+
223+
Future<void> _onReplyClick([ThreadedCommentData? parentComment]) async {
224+
final text = await _displayTextInputDialog(context, title: 'Add comment');
225+
if (text == null) return;
226+
227+
await activity.addComment(
228+
request: ActivityAddCommentRequest(
229+
comment: text,
230+
parentId: parentComment?.id,
231+
activityId: activity.activityId,
232+
),
233+
);
234+
}
235+
236+
void _onLongPressComment(ThreadedCommentData comment) {
237+
final isOwnComment = comment.user.id == client.user.id;
238+
if (!isOwnComment) return;
239+
final canEdit = capabilities.contains(FeedOwnCapability.updateComment);
240+
final canDelete = capabilities.contains(FeedOwnCapability.deleteComment);
241+
if (!canEdit && !canDelete) return;
242+
243+
final chooseActionDialog = SimpleDialog(
244+
children: [
245+
if (canEdit)
246+
SimpleDialogOption(
247+
child: const Text('Edit'),
248+
onPressed: () {
249+
Navigator.pop(context);
250+
_editComment(context, comment);
251+
},
252+
),
253+
if (canDelete)
254+
SimpleDialogOption(
255+
child: const Text('Delete'),
256+
onPressed: () {
257+
activity.deleteComment(comment.id);
258+
Navigator.pop(context);
259+
},
260+
),
261+
],
262+
);
263+
264+
showDialog<void>(
265+
context: context,
266+
builder: (context) {
267+
return chooseActionDialog;
268+
},
269+
);
270+
}
271+
272+
Future<void> _editComment(
273+
BuildContext context,
274+
ThreadedCommentData comment,
275+
) async {
276+
final text = await _displayTextInputDialog(
277+
context,
278+
title: 'Edit comment',
279+
initialText: comment.text,
280+
positiveAction: 'Edit',
281+
);
282+
283+
if (text == null) return;
284+
285+
await activity.updateComment(
286+
comment.id,
287+
ActivityUpdateCommentRequest(comment: text),
288+
);
289+
}
290+
291+
Future<String?> _displayTextInputDialog(
292+
BuildContext context, {
293+
required String title,
294+
String? initialText,
295+
String positiveAction = 'Add',
296+
}) async {
297+
final textFieldController = TextEditingController();
298+
textFieldController.text = initialText ?? '';
299+
return showDialog<String>(
300+
context: context,
301+
builder: (context) {
302+
return AlertDialog(
303+
title: Text(title),
304+
content: TextField(controller: textFieldController),
305+
actions: <Widget>[
306+
TextButton(
307+
child: const Text('Cancel'),
308+
onPressed: () {
309+
Navigator.pop(context);
310+
},
311+
),
312+
TextButton(
313+
child: Text(positiveAction),
314+
onPressed: () {
315+
Navigator.pop(context, textFieldController.text);
316+
},
317+
),
318+
],
319+
);
320+
},
321+
);
322+
}
323+
}
324+
325+
class EmptyComments extends StatelessWidget {
326+
const EmptyComments({super.key});
327+
328+
@override
329+
Widget build(BuildContext context) {
330+
return Center(
331+
child: Column(
332+
mainAxisAlignment: MainAxisAlignment.center,
333+
children: [
334+
Icon(
335+
size: 64,
336+
Icons.chat_bubble_outline_rounded,
337+
color: context.appColors.textLowEmphasis,
338+
),
339+
const SizedBox(height: 16),
340+
Text(
341+
'No comments yet',
342+
style: context.appTextStyles.headline.copyWith(
343+
color: context.appColors.textLowEmphasis,
344+
),
345+
),
346+
const SizedBox(height: 8),
347+
Text(
348+
'Be the first to share your thoughts!',
349+
style: context.appTextStyles.body.copyWith(
350+
color: context.appColors.textLowEmphasis,
351+
),
352+
textAlign: TextAlign.center,
353+
),
354+
],
355+
),
356+
);
357+
}
358+
}

0 commit comments

Comments
 (0)