import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:jihu_gitlab_app/core/load_state.dart'; import 'package:jihu_gitlab_app/core/widgets/avatar/avatar.dart'; import 'package:jihu_gitlab_app/core/widgets/avatar_and_name.dart'; import 'package:jihu_gitlab_app/core/widgets/common_app_bar.dart'; import 'package:jihu_gitlab_app/core/widgets/loading_button.dart'; import 'package:jihu_gitlab_app/core/widgets/selector/selector.dart'; import 'package:jihu_gitlab_app/core/widgets/toast.dart'; import 'package:jihu_gitlab_app/domain/discussions.dart'; import 'package:jihu_gitlab_app/domain/note.dart'; import 'package:jihu_gitlab_app/modules/todo_list/todo_param.dart'; import 'package:jihu_gitlab_app/modules/todo_list/widgets/member.dart'; import 'package:jihu_gitlab_app/modules/todo_list/widgets/member_provider.dart'; import '../../../core/widgets/changeable_time.dart'; import 'discussions_model.dart'; class DiscussionsPage extends StatefulWidget { final TodoParam param; const DiscussionsPage({super.key, required this.param}); @override State<StatefulWidget> createState() => _DiscussionsPageState(); } class _DiscussionsPageState extends State<DiscussionsPage> { final TextEditingController _commentController = TextEditingController(); FocusNode commentFocusNode = FocusNode(); final DiscussionsModel _model = DiscussionsModel(); String placeholder = "Write a comment ..."; int inputTextLength = 0; @override void initState() { super.initState(); _toggleLoading(true); _model.getDiscussions(widget.param).then((value) => _toggleLoading(false)).catchError((e) => _toggleLoading(false)); } @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: true, appBar: _buildAppBar(context), backgroundColor: const Color(0xF8F8FAFF), body: SafeArea( child: Stack( children: <Widget>[ Offstage( offstage: !(_model.loadState == LoadState.successState), child: GestureDetector( onTap: () { setState(() { placeholder = "Write a comment ..."; _model.selectedNoteId = null; commentFocusNode.unfocus(); }); }, child: Column(children: [Expanded(child: SingleChildScrollView(child: _buildIssueDetailPageView())), _buildIssueReplyView()]), )), Offstage(offstage: !(_model.loadState == LoadState.loadingState), child: _buildLoadingView()), ], ), ), ); } CommonAppBar _buildAppBar(BuildContext context) { return CommonAppBar(title: Text(_model.issueDetail.iid == 0 ? "" : '${_model.issueDetail.projectName} #${_model.issueDetail.iid}'), showLeading: true); } // TODO Extract to Loading widget Widget _buildLoadingView() { return Center( heightFactor: 100, child: Column( children: [ Container(height: 100), Image.asset("assets/images/loading.png", height: 40, width: 40), Container(height: 5), const Text("Loading", style: TextStyle(fontSize: 14, color: Color.fromRGBO(23, 19, 33, 1))) ], ), ); } Widget _buildIssueDetailPageView() { return Column( children: [ _buildIssueDetailView(), Container( margin: _model.comments.isEmpty ? const EdgeInsets.all(0) : const EdgeInsets.fromLTRB(12, 12, 12, 0), padding: _model.comments.isEmpty ? const EdgeInsets.all(0) : const EdgeInsets.only(left: 12), width: MediaQuery.of(context).size.width, height: _model.comments.isEmpty ? 0 : 40, alignment: Alignment.centerLeft, decoration: const BoxDecoration(color: Colors.white), child: Column(mainAxisSize: MainAxisSize.min, children: const [Text("Comments", style: TextStyle(color: Color(0xFF1A1B36), fontSize: 16, fontWeight: FontWeight.w600))]), ), Container( padding: _model.comments.isEmpty ? const EdgeInsets.all(0) : const EdgeInsets.fromLTRB(12, 12, 12, 0), margin: _model.comments.isEmpty ? const EdgeInsets.all(0) : const EdgeInsets.fromLTRB(12, 0, 12, 0), constraints: _model.comments.isEmpty ? const BoxConstraints(minHeight: 0) : const BoxConstraints(minHeight: 50), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(4))), child: _buildDiscussionList(), ), ], ); } void _saveCommentAndRefresh() async { var commentContent = _commentController.text; if (commentContent.isEmpty) { Toast.show('Please write a comment '); return; } _toggleLoading(true); try { await _model.saveComment(commentContent).then((value) => Toast.success(context, 'Successful')).catchError((e) => Toast.error(context, 'Failed')); await _model.getDiscussions(widget.param); } finally { _toggleLoading(false); } _commentController.clear(); commentFocusNode.unfocus(); } Widget _buildIssueReplyView() { return Align( alignment: Alignment.bottomCenter, child: Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: const BoxDecoration(color: Color(0xF8F8FAFF), border: Border(top: BorderSide(width: 0.2, color: Color(0xFFCECECE)))), child: Row(children: [ Flexible(child: _buildInputView()), IconButton( icon: const Icon(Icons.alternate_email), onPressed: () async { List<Member> result = await _openMemberSelector(); if (result.isNotEmpty) { var selectedUsername = result[0].username; _commentController.text = "${_commentController.text}@$selectedUsername "; } }, ), _buildAddCommentButton() ]), ), ); } Widget _buildInputView() { return Container( constraints: const BoxConstraints(minHeight: 32), margin: const EdgeInsets.fromLTRB(0, 12, 8, 8), padding: const EdgeInsets.only(left: 8), decoration: const BoxDecoration(color: Colors.white), child: TextField( maxLines: null, controller: _commentController, focusNode: commentFocusNode, onChanged: (text) async { if (text.endsWith("@") && text.length > inputTextLength) { List<Member> result = await _openMemberSelector(); if (result.isNotEmpty) { var selectedUsername = result[0].username; _commentController.text = "${_commentController.text}$selectedUsername "; } } setState(() { _model.submittable = text.isNotEmpty; }); inputTextLength = text.length; }, cursorColor: const Color(0xFFFC6D26), decoration: InputDecoration(hintText: placeholder, border: InputBorder.none, hintStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w400)), )); } Widget _buildAddCommentButton() { return LoadingButton( isLoading: _model.isLoading, disabled: !_model.submittable, text: const Text("Comment", style: TextStyle(color: Colors.white, fontSize: 12)), onPressed: () { _saveCommentAndRefresh(); }); } Widget _buildIssueDetailView() { return Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Container( padding: const EdgeInsets.all(12), margin: const EdgeInsets.fromLTRB(12, 12, 12, 0), constraints: const BoxConstraints(minHeight: 50), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(4))), child: Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Row(children: [ Expanded(child: Text.rich(TextSpan(text: _model.issueDetail.title, style: const TextStyle(fontSize: 18, color: Color(0xFF1A1B36), fontWeight: FontWeight.w600)))), ]), Markdown( padding: const EdgeInsets.only(left: 0, top: 12, right: 16, bottom: 16), shrinkWrap: true, data: _model.issueDetail.description, selectable: true, physics: const NeverScrollableScrollPhysics(), ), Row( children: [ Text(_model.issueDetail.recentActivities(), style: const TextStyle(color: Color(0xFF87878C), fontWeight: FontWeight.w400, fontSize: 12)), ChangeableTime.show(() => setState(() {}), true, _model.issueDetail.recentActivityTimes(), const Color(0xFF87878C), FontWeight.w400, 12) ], ), ], ), ) ], ); } Widget _buildDiscussionList() { return ListView.separated( physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) { Discussion comment = _model.comments[index]; return Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Avatar(avatarUrl: comment.notes[0].author.avatarUrl, size: 24), Text(" ${comment.notes[0].author.name}", style: const TextStyle(color: Color(0xFF1A1B36), fontWeight: FontWeight.w400, fontSize: 13)), ChangeableTime.show(() => setState(() {}), true, comment.notes[0].createdAt, const Color(0xFF95979A), FontWeight.w400, 12), ], ), InkWell( onTap: () { setState(() { placeholder = "Reply ${comment.notes[0].author.name}:"; _model.selectedNoteId = _model.comments[index].notes[0].id; _model.selectedDiscussionId = _model.comments[index].id; FocusScope.of(context).requestFocus(commentFocusNode); }); }, child: Image.asset("assets/images/replay.png", height: 16, width: 16)), ], ), Column( children: [ Markdown(padding: const EdgeInsets.fromLTRB(0, 12, 0, 12), shrinkWrap: true, data: comment.notes[0].body, selectable: true, physics: const NeverScrollableScrollPhysics()), ], ), _model.comments[index].individualNote == false && _model.comments[index].notes.length > 1 ? Container( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFF3F3F3)), color: Colors.white, borderRadius: const BorderRadius.all(Radius.circular(4))), child: _buildDiscussionNotesView(_model.comments[index].notes)) : Container(height: 0), ], ); }, itemCount: _model.comments.length, shrinkWrap: true, separatorBuilder: (BuildContext context, int index) { return const Divider(height: 30, color: Colors.white); }, ); } Widget _buildDiscussionNotesView(List<Note> notes) { List<Widget> widgets = []; Widget content = Column(); for (int i = 0; i < notes.length; i++) { if (i == 0) { continue; } widgets.add(Row( children: [ Avatar(avatarUrl: notes[i].author.avatarUrl, size: 24), Text(" ${notes[i].author.name}", style: const TextStyle(color: Color(0xFF1A1B36), fontWeight: FontWeight.w400, fontSize: 13)), ChangeableTime.show(() => setState(() {}), true, notes[i].createdAt, const Color(0xFF95979A)), ], )); widgets.add(Column( children: [Markdown(padding: const EdgeInsets.fromLTRB(0, 12, 0, 12), shrinkWrap: true, data: notes[i].body, selectable: true, physics: const NeverScrollableScrollPhysics())], )); } content = Column(children: widgets); return content; } void _toggleLoading(bool on) { setState(() { _model.isLoading = on; }); } Future<List<Member>> _openMemberSelector() async { return (await Navigator.of(context).push(MaterialPageRoute(builder: (context) { return Selector<int, Member>( itemBuilder: (BuildContext context, int index, dynamic member) => AvatarAndName(username: member.username, avatarUrl: member.avatarUrl), filter: (keyword, member) => member.username.toLowerCase().contains(keyword.toLowerCase()), dataProvider: MemberProvider(projectId: widget.param.projectId), keyMapper: (member) => member.id, title: 'Select contact', key: const Key('member-selector'), ); })) ?? <Member>[]) as List<Member>; } }