json, {bool isCompact = false}) {
- ContentAdvertisement ad = ContentAdvertisement.fromJson(json['content_raw']['data'], isCompact: isCompact);
+ ContentAdvertisement ad = ContentAdvertisement.fromJson(json, isCompact: isCompact);
// We are not using 👇 anywhere + it causes the app to freeze
// ad.contentRegular = ContentRegular(json['content']);
return ad;
diff --git a/lib/model/post/content/Regular.dart b/lib/model/post/content/Regular.dart
index 12df6ff8..48ba6e24 100644
--- a/lib/model/post/content/Regular.dart
+++ b/lib/model/post/content/Regular.dart
@@ -1,3 +1,5 @@
+import 'dart:convert';
+
import 'package:fyx/model/enums/PostTypeEnum.dart';
import 'package:fyx/model/post/Content.dart';
import 'package:fyx/model/post/Image.dart';
@@ -111,8 +113,8 @@ class ContentRegular extends Content {
_body = _body.replaceAll(trailingBr, '');
var xmpTag = RegExp(r'(.*?)', caseSensitive: false, multiLine: true, dotAll: true);
- _body = _body.replaceAllMapped(xmpTag, (match) => '${match.group(1)}
');
- _rawBody = _rawBody.replaceAllMapped(xmpTag, (match) => '${match.group(1)}
');
+ _body = _body.replaceAllMapped(xmpTag, (match) => '${HtmlEscape().convert(match.group(1) ?? '')}
'); // TODO: Improve performance?
+ _rawBody = _rawBody.replaceAllMapped(xmpTag, (match) => '${HtmlEscape().convert(match.group(1) ?? '')}
'); // TODO: Improve performance?
} catch (error) {
Sentry.captureException(error, stackTrace: StackTrace.current);
}
diff --git a/lib/model/post/content/post_content_text.dart b/lib/model/post/content/post_content_text.dart
new file mode 100644
index 00000000..63f781b8
--- /dev/null
+++ b/lib/model/post/content/post_content_text.dart
@@ -0,0 +1,14 @@
+import 'package:fyx/model/enums/PostTypeEnum.dart';
+import 'package:fyx/model/post/Content.dart';
+
+enum ContentFormat { text, html, markdown }
+
+class PostContentText extends Content {
+ late final String data;
+ late final ContentFormat format;
+
+ PostContentText.fromJson(Map json) : super(PostTypeEnum.text, isCompact: false) {
+ this.data = json['data'] ?? '';
+ this.format = ContentFormat.values.firstWhere((value) => value.toString().contains(json['format']));
+ }
+}
diff --git a/lib/model/provider/ThemeModel.dart b/lib/model/provider/ThemeModel.dart
index 4de1a661..4eda1f6a 100644
--- a/lib/model/provider/ThemeModel.dart
+++ b/lib/model/provider/ThemeModel.dart
@@ -1,16 +1,35 @@
import 'package:flutter/cupertino.dart';
+import 'package:fyx/model/enums/SkinEnum.dart';
import 'package:fyx/model/enums/ThemeEnum.dart';
class ThemeModel extends ChangeNotifier {
final ThemeEnum initialTheme;
- ThemeModel(this.initialTheme);
+ final SkinEnum initialSkin;
+ final double initialFontSize;
+ ThemeModel(this.initialTheme, this.initialFontSize, {this.initialSkin = SkinEnum.fyx});
ThemeEnum? _theme;
+ SkinEnum? _skin;
+ double? _fontSize;
ThemeEnum get theme => _theme == null ? this.initialTheme : _theme!;
+ SkinEnum get skin => _skin == null ? this.initialSkin : _skin!;
+
+ double get fontSize => _fontSize == null ? this.initialFontSize : _fontSize!;
+
void setTheme(ThemeEnum val) {
_theme = val;
notifyListeners();
}
+
+ void setFontSize(double val) {
+ _fontSize = val;
+ notifyListeners();
+ }
+
+ void setSkin(SkinEnum skin) {
+ _skin = skin;
+ notifyListeners();
+ }
}
diff --git a/lib/model/reponses/DiscussionHomeResponse.dart b/lib/model/reponses/DiscussionHomeResponse.dart
index 638c55df..4cd3a5b4 100644
--- a/lib/model/reponses/DiscussionHomeResponse.dart
+++ b/lib/model/reponses/DiscussionHomeResponse.dart
@@ -1,24 +1,15 @@
import 'package:fyx/model/Discussion.dart';
+import 'package:fyx/model/DiscussionContent.dart';
import 'package:fyx/model/ResponseContext.dart';
class DiscussionHomeResponse {
- late Discussion _discussion;
- late ResponseContext _context;
- List _home = [];
- List _header = [];
+ late final Discussion discussion;
+ late final ResponseContext context;
+ late final List items;
DiscussionHomeResponse.fromJson(Map json) {
- this._home = List.from(json['home'] ?? []);
- this._header = List.from(json['header'] ?? []);
- this._discussion = Discussion.fromJson(json['discussion']);
- this._context = ResponseContext.fromJson(json['context']);
+ this.discussion = Discussion.fromJson(json['discussion_common']);
+ this.context = ResponseContext.fromJson(json['context']);
+ this.items = (json['items'] as List).map((item) => DiscussionContent.fromJson(item)).toList();
}
-
- ResponseContext get context => _context;
-
- Discussion get discussion => _discussion;
-
- List get header => _header;
-
- List get home => _home;
}
diff --git a/lib/model/reponses/DiscussionResponse.dart b/lib/model/reponses/DiscussionResponse.dart
index 3a194149..9733e936 100644
--- a/lib/model/reponses/DiscussionResponse.dart
+++ b/lib/model/reponses/DiscussionResponse.dart
@@ -1,30 +1,47 @@
import 'package:fyx/model/Discussion.dart';
+import 'package:fyx/model/DiscussionContent.dart';
import 'package:fyx/model/ResponseContext.dart';
+import 'package:fyx/model/enums/DiscussionTypeEnum.dart';
+
+enum DiscussionSpecificDataEnum { header }
class DiscussionResponse {
- late Discussion _discussion;
- List _posts = [];
- late ResponseContext _context;
+ late final Discussion discussion; // TODO: Rename this to DiscussionCommon
+ Map>? discussionSpecificData;
+ late final List posts;
+ ResponseContext? context;
+ bool error = false;
DiscussionResponse.accessDenied() {
- this._discussion = Discussion.fromJson(null);
- this._posts = [];
+ this.discussion = Discussion.fromJson(null);
+ this.posts = [];
}
// TODO: Return something more relevant.
DiscussionResponse.error() {
- DiscussionResponse.accessDenied();
+ this.error = true;
+ this.discussion = Discussion.fromJson(null);
+ this.posts = [];
}
DiscussionResponse.fromJson(Map json) {
- this._discussion = Discussion.fromJson(json['discussion_common']);
- this._posts = json['posts'] ?? [];
- this._context = ResponseContext.fromJson(json['context']);
- }
-
- Discussion get discussion => _discussion;
+ this.discussion = Discussion.fromJson(json['discussion_common']);
+ this.posts = json['posts'] ?? [];
+ this.context = ResponseContext.fromJson(json['context']);
- List get posts => _posts;
-
- ResponseContext get context => _context;
+ switch (this.discussion.type) {
+ case DiscussionTypeEnum.discussion:
+ this.discussionSpecificData = {
+ DiscussionSpecificDataEnum.header:
+ List.from(json['discussion_common']['discussion_specific_data']['header']).map((item) => DiscussionContent.fromJson(item)).toList()
+ };
+ break;
+ case DiscussionTypeEnum.event:
+ // TODO: Handle this case.
+ break;
+ case DiscussionTypeEnum.advertisement:
+ // TODO: Handle this case.
+ break;
+ }
+ }
}
diff --git a/lib/model/reponses/UnifiedSearchResponse.dart b/lib/model/reponses/UnifiedSearchResponse.dart
new file mode 100644
index 00000000..f02a80b3
--- /dev/null
+++ b/lib/model/reponses/UnifiedSearchResponse.dart
@@ -0,0 +1,41 @@
+enum UnifiedSearchType { discussions, events, advertisements }
+
+class UnifiedSearchResponse {
+ late Map> discussion;
+
+ UnifiedSearchResponse(this.discussion);
+
+ UnifiedSearchResponse.fromJson(Map json) {
+ this.discussion = {
+ UnifiedSearchType.discussions: (json['discussion']['discussions'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(),
+ UnifiedSearchType.events: (json['discussion']['events'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(),
+ UnifiedSearchType.advertisements: (json['discussion']['advertisements'] as List).map((item) => UnifiedDiscussionModel.fromJson(item)).toList(),
+ };
+ }
+}
+
+class UnifiedDiscussionModel {
+ late int id;
+ late String discussionType;
+ late String discussionName;
+ late String discussionNameHighlighted;
+ late String summary;
+ late String summaryHighlighted;
+
+ UnifiedDiscussionModel(
+ {required this.id,
+ required this.discussionType,
+ required this.discussionName,
+ this.discussionNameHighlighted = '',
+ this.summary = '',
+ this.summaryHighlighted = ''});
+
+ UnifiedDiscussionModel.fromJson(Map json) {
+ this.id = json['id'];
+ this.discussionType = json['discussion_type'];
+ this.discussionName = json['discussion_name'];
+ this.discussionNameHighlighted = json['discussion_name_highlighted'] ?? '';
+ this.summary = json['summary'] ?? '';
+ this.summaryHighlighted = json['summary_highlighted'] ?? '';
+ }
+}
diff --git a/lib/pages/DiscussionPage.dart b/lib/pages/DiscussionPage.dart
index bd068d89..71af0c76 100644
--- a/lib/pages/DiscussionPage.dart
+++ b/lib/pages/DiscussionPage.dart
@@ -2,23 +2,33 @@ import 'package:async/async.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
-import 'package:fyx/components/PullToRefreshList.dart';
-import 'package:fyx/components/post/Advertisement.dart';
-import 'package:fyx/components/post/PostListItem.dart';
-import 'package:fyx/components/post/SyntaxHighlighter.dart';
+import 'package:flutter_html/flutter_html.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:fyx/components/discussion_page_scaffold.dart';
+import 'package:fyx/components/post/advertisement.dart';
+import 'package:fyx/components/post/post_list_item.dart';
+import 'package:fyx/components/post/syntax_highlighter.dart';
+import 'package:fyx/components/pull_to_refresh_list.dart';
import 'package:fyx/controllers/AnalyticsProvider.dart';
import 'package:fyx/controllers/ApiController.dart';
import 'package:fyx/controllers/IApiProvider.dart';
import 'package:fyx/model/MainRepository.dart';
import 'package:fyx/model/Post.dart';
+import 'package:fyx/model/Settings.dart';
+import 'package:fyx/model/enums/DiscussionTypeEnum.dart';
import 'package:fyx/model/post/content/Advertisement.dart';
import 'package:fyx/model/reponses/DiscussionResponse.dart';
import 'package:fyx/pages/NewMessagePage.dart';
+import 'package:fyx/pages/discussion_home_page.dart';
+import 'package:fyx/state/batch_actions_provider.dart';
import 'package:fyx/theme/L.dart';
import 'package:fyx/theme/T.dart';
import 'package:fyx/theme/skin/Skin.dart';
import 'package:fyx/theme/skin/SkinColors.dart';
import 'package:hive_flutter/hive_flutter.dart';
+import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
+import 'package:scroll_to_index/scroll_to_index.dart';
+import 'package:tap_canvas/tap_canvas.dart';
class DiscussionPageArguments {
final int discussionId;
@@ -29,17 +39,33 @@ class DiscussionPageArguments {
DiscussionPageArguments(this.discussionId, {this.postId, this.filterByUser, this.search});
}
-class DiscussionPage extends StatefulWidget {
+class DiscussionPage extends ConsumerStatefulWidget {
@override
_DiscussionPageState createState() => _DiscussionPageState();
}
-class _DiscussionPageState extends State {
+class _DiscussionPageState extends ConsumerState {
final AsyncMemoizer _memoizer = AsyncMemoizer();
late SkinColors colors;
int _refreshList = 0;
bool _hasInitData = false;
+ // Did we close the context menu by tapping outside?
+ // Tapping on menu button is outside click. -> This toggle solves the issue of closing and immediate opening
+ // the menu when the menu is open and user clicks on three dots...
+ bool _closedByOutsideTap = false;
+
+ // Display the context menu?
+ bool _popupMenu = false;
+
+ // Is the discussion saved in bookmarks?
+ bool? _bookmark;
+
+ String? _searchTerm;
+
+ // Progress indicator if some posts are being deleted...
+ bool _deleting = false;
+
Future _fetchData(discussionId, postId, user, {String? search}) {
return this._memoizer.runOnce(() {
return Future.delayed(
@@ -79,41 +105,40 @@ class _DiscussionPageState extends State {
isWarning: true, title: snapshot.error.toString(), label: L.GENERAL_CLOSE, onPress: () => Navigator.of(context).pop());
}
if (snapshot.hasData) {
- if (snapshot.data!.discussion.accessDenied) {
+ if (snapshot.data!.error) {
+ return T.feedbackScreen(context,
+ isWarning: true,
+ title: 'Něco se pokazilo.\nChybu, prosím, nahlašte ID LUCIEN.',
+ label: L.GENERAL_CLOSE,
+ onPress: () => Navigator.of(context).pop());
+ } else if (snapshot.data!.discussion.accessDenied) {
return T.feedbackScreen(context,
title: L.ACCESS_DENIED_ERROR, icon: Icons.do_not_disturb_alt, label: L.GENERAL_CLOSE, onPress: () => Navigator.of(context).pop());
}
+ _bookmark ??= (snapshot.data!.discussion.bookmark?.bookmark ?? false);
return this._createDiscussionPage(snapshot.data!, pageArguments);
}
return _pageScaffold(title: L.GENERAL_LOADING, body: T.feedbackScreen(context, isLoading: true));
});
}
- Widget _pageScaffold({required String title, required Widget body}) {
- return CupertinoPageScaffold(
- navigationBar: CupertinoNavigationBar(
- leading: CupertinoNavigationBarBackButton(
- color: colors.primary,
- onPressed: () {
- Navigator.of(context).pop();
+ Widget _pageScaffold({required String title, required Widget body, DiscussionResponse? discussionResponse}) {
+ return DiscussionPageScaffold(
+ title: title,
+ child: body,
+ trailing: Visibility(
+ visible: discussionResponse != null && discussionResponse.discussion.type == DiscussionTypeEnum.discussion,
+ child: GestureDetector(
+ onTap: () {
+ if (_closedByOutsideTap) {
+ setState(() => _closedByOutsideTap = false); // reset _closedByOutsideTap
+ return;
+ }
+ setState(() => _popupMenu = true);
},
+ child: Icon(Icons.more_horiz),
),
- middle: Container(
- alignment: Alignment.center,
- width: MediaQuery.of(context).size.width - 120,
- child: Tooltip(
- message: title,
- child: Text(
- // https://github.com/flutter/flutter/issues/18761
- Characters(title).replaceAll(Characters(''), Characters('\u{200B}')).toString(),
- style: TextStyle(color: colors.text),
- overflow: TextOverflow.ellipsis),
- padding: EdgeInsets.all(8.0), // needed until https://github.com/flutter/flutter/issues/86170 is fixed
- margin: EdgeInsets.all(8.0),
- showDuration: Duration(seconds: 3),
- ))),
- child: body,
- );
+ ));
}
Widget _createDiscussionPage(DiscussionResponse discussionResponse, DiscussionPageArguments pageArguments) {
@@ -121,101 +146,306 @@ class _DiscussionPageState extends State {
// TODO: Not ideal, probably better to use Provider. Or not?
SyntaxHighlighter.languageContext = discussionResponse.discussion.name;
+ final textStyleContext = TextStyle(fontSize: Settings().fontSize);
+
return _pageScaffold(
+ discussionResponse: discussionResponse,
title: discussionResponse.discussion.name,
body: Stack(
children: [
- NotificationListener(
- onNotification: (notification) {
- void rebuild(Element el) {
- el.markNeedsBuild();
- el.visitChildren(rebuild);
- }
-
- // Rebuild the widget tree if Dismissible didn't removed the item due to the server error.
- (context as Element).visitChildren(rebuild);
- return false;
+ PullToRefreshList>(
+ searchLabel: 'Hledej @nick a nebo text...',
+ searchTerm: this._searchTerm,
+ onSearch: (term) {
+ setState(() => this._searchTerm = term);
+ this.refresh();
},
- child: PullToRefreshList(
- rebuild: _refreshList,
- isInfinite: true,
- pinnedWidget: getPinnedWidget(discussionResponse),
- sliverListBuilder: (List data) {
- return ValueListenableBuilder(
- valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts', 'blockedUsers']),
- builder: (BuildContext context, value, Widget? child) {
- var filtered = data;
- if (data[0] is PostListItem) {
- filtered = data
- .where((item) => !MainRepository().settings.isPostBlocked((item as PostListItem).post.id))
- .where((item) => !MainRepository().settings.isUserBlocked((item as PostListItem).post.nick))
- .toList();
- }
- return SliverList(
- delegate: SliverChildBuilderDelegate(
- (context, i) => filtered[i],
- childCount: filtered.length,
- ),
- );
- },
- );
- },
- dataProvider: (lastId) async {
- var result;
- if (lastId != null) {
- // If we load next page(s)
- var response = await ApiController().loadDiscussion(pageArguments.discussionId, lastId: lastId, user: pageArguments.filterByUser);
- result = response.posts;
- } else {
- // If we load init data or we refresh data on pull
- if (!this._hasInitData) {
- // If we load init data, use the data from FutureBuilder
- result = discussionResponse.posts;
- this._hasInitData = true;
- } else {
- // If we just pull to refresh, load a fresh data
- var response = await ApiController().loadDiscussion(pageArguments.discussionId, user: pageArguments.filterByUser);
- result = response.posts;
+ onSearchClear: () {
+ setState(() => this._searchTerm = '');
+ this.refresh();
+ },
+ rebuild: _refreshList,
+ isInfinite: true,
+ pinnedWidget: getPinnedWidget(discussionResponse),
+ sliverListBuilder: (List data, {controller}) {
+ return ValueListenableBuilder(
+ valueListenable: MainRepository().settings.box.listenable(keys: ['blockedPosts', 'blockedUsers']),
+ builder: (BuildContext context, value, Widget? child) {
+ var filtered = data;
+ if (data[0] is PostListItem) {
+ filtered = data
+ .where((item) => !MainRepository().settings.isPostBlocked((item as PostListItem).post.id))
+ .where((item) => !MainRepository().settings.isUserBlocked((item as PostListItem).post.nick))
+ .toList();
}
+ final kUnreadIndex = filtered.lastIndexWhere((item) => (item as PostListItem).post.isNew);
+ return SliverList(
+ delegate: SliverChildBuilderDelegate(
+ (context, i) {
+ final postItem = AutoScrollTag(child: filtered[i], key: ValueKey(i), index: i, controller: controller);
+ if (i == kUnreadIndex) {
+ return unseenPill(postItem, kUnreadIndex + 1);
+ }
+ return postItem;
+ },
+ childCount: filtered.length,
+ ),
+ );
+ },
+ );
+ },
+ dataProvider: (lastId) async {
+ var response;
+ var result;
+ if (lastId != null) {
+ // If we load next page(s)
+ response = await ApiController()
+ .loadDiscussion(pageArguments.discussionId, lastId: lastId, user: pageArguments.filterByUser, search: this._searchTerm);
+ } else {
+ // If we load init data or we refresh data on pull
+ if (!this._hasInitData) {
+ // If we load init data, use the data from FutureBuilder
+ response = discussionResponse;
+ this._hasInitData = true;
+ } else {
+ // If we just pull to refresh, load a fresh data
+ response =
+ await ApiController().loadDiscussion(pageArguments.discussionId, user: pageArguments.filterByUser, search: this._searchTerm);
}
- List data = (result as List)
- .map((post) {
- return Post.fromJson(post, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode);
- })
- .where((post) => !MainRepository().settings.isPostBlocked(post.id))
- .where((post) => !MainRepository().settings.isUserBlocked(post.nick))
- .map((post) => PostListItem(post, onUpdate: this.refresh, isHighlighted: post.isNew))
- .toList();
-
- int? id;
- try {
- id = Post.fromJson((result as List).last, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode).id;
- } catch (error) {}
- return DataProviderResult(data, lastId: id);
- },
- ),
+ }
+
+ result = response.posts;
+ List data = (result as List)
+ .map((post) {
+ return Post.fromJson(post, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode);
+ })
+ .where((post) => !MainRepository().settings.isPostBlocked(post.id))
+ .where((post) => !MainRepository().settings.isUserBlocked(post.nick))
+ .map((post) => PostListItem(post, onUpdate: this.refresh, isHighlighted: post.isNew))
+ .toList();
+
+ int? id;
+ try {
+ id = Post.fromJson((result as List).last, pageArguments.discussionId, isCompact: MainRepository().settings.useCompactMode).id;
+ } catch (error) {}
+ return DataProviderResult(data,
+ lastId: id,
+ postId: pageArguments.postId,
+ jumpIndex: response.discussion.lastVisit > 0 ? data.where((listItem) => listItem.post.isNew).length : 0);
+ },
),
Visibility(
visible: discussionResponse.discussion.accessRights.canWrite != false || discussionResponse.discussion.rights.canWrite != false,
child: Positioned(
right: 20,
+ left: 20,
bottom: 20,
child: SafeArea(
- child: FloatingActionButton(
- backgroundColor: colors.primary,
- foregroundColor: colors.background,
- child: Icon(Icons.add),
- onPressed: () => Navigator.of(context).pushNamed('/new-message',
- arguments: NewMessageSettings(
- onClose: this.refresh,
- onSubmit: (String? inputField, String message, List