Compare commits
	
		
			5 Commits
		
	
	
		
			77e9994204
			...
			2375c46852
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2375c46852 | |||
| fd2eb5cda6 | |||
| 1256f440bd | |||
| 5b05ca67b6 | |||
| 95af7140cd | 
| @@ -57,7 +57,7 @@ | ||||
|   "reply": "Reply", | ||||
|   "unset": "Unset", | ||||
|   "untitled": "Untitled", | ||||
|   "postDetail": "Post detail", | ||||
|   "postDetail": "Post Detail", | ||||
|   "postNoun": "Post", | ||||
|   "postReadMore": "Read more", | ||||
|   "postReadEstimate": "Est read time {}", | ||||
| @@ -139,6 +139,7 @@ | ||||
|   "fieldPostTitle": "Title", | ||||
|   "fieldPostDescription": "Description", | ||||
|   "fieldPostTags": "Tags", | ||||
|   "fieldPostCategories": "Categories", | ||||
|   "fieldPostAlias": "Alias", | ||||
|   "fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.", | ||||
|   "postPublish": "Publish", | ||||
| @@ -477,5 +478,15 @@ | ||||
|   "colorSchemeRed": "Red", | ||||
|   "colorSchemeWhite": "White", | ||||
|   "colorSchemeBlack": "Black", | ||||
|   "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect." | ||||
|   "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.", | ||||
|   "postCategoryTechnology": "Technology", | ||||
|   "postCategoryGaming": "Gaming", | ||||
|   "postCategoryLife": "Life", | ||||
|   "postCategoryArts": "Arts", | ||||
|   "postCategorySports": "Sports", | ||||
|   "postCategoryMusic": "Music", | ||||
|   "postCategoryNews": "News", | ||||
|   "postCategoryKnowledge": "Knowledge", | ||||
|   "postCategoryLiterature": "Literature", | ||||
|   "postCategoryUncategorized": "Uncategorized" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,7 @@ | ||||
|   "fieldPostTitle": "标题", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "标签", | ||||
|   "fieldPostCategories": "分类", | ||||
|   "fieldPostAlias": "别名", | ||||
|   "fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。", | ||||
|   "postPublish": "发布", | ||||
| @@ -416,7 +417,7 @@ | ||||
|   "accountStatus": "状态", | ||||
|   "accountStatusOnline": "在线", | ||||
|   "accountStatusOffline": "离线", | ||||
|   "accountStatusLastSeen": "最后一次在 {} 上线", | ||||
|   "accountStatusLastSeen": "最后一次上线于 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "发表于 {}", | ||||
| @@ -475,5 +476,15 @@ | ||||
|   "colorSchemeRed": "红色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主题色已应用,可能需要重启来生效。" | ||||
|   "colorSchemeApplied": "主题色已应用,可能需要重启来生效。", | ||||
|   "postCategoryTechnology": "技术", | ||||
|   "postCategoryGaming": "游戏", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "艺术", | ||||
|   "postCategorySports": "体育", | ||||
|   "postCategoryMusic": "音乐", | ||||
|   "postCategoryNews": "新闻", | ||||
|   "postCategoryKnowledge": "知识", | ||||
|   "postCategoryLiterature": "文学", | ||||
|   "postCategoryUncategorized": "未分类" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,7 @@ | ||||
|   "fieldPostTitle": "標題", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "標籤", | ||||
|   "fieldPostCategories": "分類", | ||||
|   "fieldPostAlias": "別名", | ||||
|   "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", | ||||
|   "postPublish": "發佈", | ||||
| @@ -416,7 +417,7 @@ | ||||
|   "accountStatus": "狀態", | ||||
|   "accountStatusOnline": "在線", | ||||
|   "accountStatusOffline": "離線", | ||||
|   "accountStatusLastSeen": "最後一次在 {} 上線", | ||||
|   "accountStatusLastSeen": "最後一次上線於 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "發表於 {}", | ||||
| @@ -475,5 +476,15 @@ | ||||
|   "colorSchemeRed": "紅色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。" | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。", | ||||
|   "postCategoryTechnology": "技術", | ||||
|   "postCategoryGaming": "遊戲", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "藝術", | ||||
|   "postCategorySports": "體育", | ||||
|   "postCategoryMusic": "音樂", | ||||
|   "postCategoryNews": "新聞", | ||||
|   "postCategoryKnowledge": "知識", | ||||
|   "postCategoryLiterature": "文學", | ||||
|   "postCategoryUncategorized": "未分類" | ||||
| } | ||||
|   | ||||
| @@ -123,6 +123,7 @@ | ||||
|   "fieldPostTitle": "標題", | ||||
|   "fieldPostDescription": "描述", | ||||
|   "fieldPostTags": "標籤", | ||||
|   "fieldPostCategories": "分類", | ||||
|   "fieldPostAlias": "別名", | ||||
|   "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", | ||||
|   "postPublish": "釋出", | ||||
| @@ -416,7 +417,7 @@ | ||||
|   "accountStatus": "狀態", | ||||
|   "accountStatusOnline": "線上", | ||||
|   "accountStatusOffline": "離線", | ||||
|   "accountStatusLastSeen": "最後一次在 {} 上線", | ||||
|   "accountStatusLastSeen": "最後一次上線於 {}", | ||||
|   "postArticle": "Solar Network 上的文章", | ||||
|   "postStory": "Solar Network 上的故事", | ||||
|   "articleWrittenAt": "發表於 {}", | ||||
| @@ -475,5 +476,15 @@ | ||||
|   "colorSchemeRed": "紅色", | ||||
|   "colorSchemeWhite": "白色", | ||||
|   "colorSchemeBlack": "黑色", | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。" | ||||
|   "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。", | ||||
|   "postCategoryTechnology": "技術", | ||||
|   "postCategoryGaming": "遊戲", | ||||
|   "postCategoryLife": "生活", | ||||
|   "postCategoryArts": "藝術", | ||||
|   "postCategorySports": "體育", | ||||
|   "postCategoryMusic": "音樂", | ||||
|   "postCategoryNews": "新聞", | ||||
|   "postCategoryKnowledge": "知識", | ||||
|   "postCategoryLiterature": "文學", | ||||
|   "postCategoryUncategorized": "未分類" | ||||
| } | ||||
|   | ||||
| @@ -178,6 +178,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|   List<int> visibleUsers = List.empty(); | ||||
|   List<int> invisibleUsers = List.empty(); | ||||
|   List<String> tags = List.empty(); | ||||
|   List<String> categories = List.empty(); | ||||
|   PostWriteMedia? thumbnail; | ||||
|   List<PostWriteMedia> attachments = List.empty(growable: true); | ||||
|   DateTime? publishedAt, publishedUntil; | ||||
| @@ -207,6 +208,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|         invisibleUsers = List.from(post.invisibleUsersList ?? []); | ||||
|         visibility = post.visibility; | ||||
|         tags = List.from(post.tags.map((ele) => ele.alias)); | ||||
|         categories = List.from(post.categories.map((ele) => ele.alias)); | ||||
|         attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); | ||||
|  | ||||
|         if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { | ||||
| @@ -345,6 +347,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|           if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, | ||||
|           'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), | ||||
|           'tags': tags.map((ele) => {'alias': ele}).toList(), | ||||
|           'categories': categories.map((ele) => {'alias': ele}).toList(), | ||||
|           'visibility': visibility, | ||||
|           'visible_users_list': visibleUsers, | ||||
|           'invisible_users_list': invisibleUsers, | ||||
| @@ -431,6 +434,11 @@ class PostWriteController extends ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setCategories(List<String> value) { | ||||
|     categories = value; | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void setVisibility(int value) { | ||||
|     visibility = value; | ||||
|     notifyListeners(); | ||||
| @@ -467,6 +475,9 @@ class PostWriteController extends ChangeNotifier { | ||||
|     titleController.clear(); | ||||
|     descriptionController.clear(); | ||||
|     contentController.clear(); | ||||
|     aliasController.clear(); | ||||
|     tags.clear(); | ||||
|     categories.clear(); | ||||
|     attachments.clear(); | ||||
|     editingPost = null; | ||||
|     replyingPost = null; | ||||
| @@ -480,6 +491,7 @@ class PostWriteController extends ChangeNotifier { | ||||
|     contentController.dispose(); | ||||
|     titleController.dispose(); | ||||
|     descriptionController.dispose(); | ||||
|     aliasController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -118,12 +118,14 @@ class SnPostContentProvider { | ||||
|     int take = 10, | ||||
|     int offset = 0, | ||||
|     Iterable<String>? tags, | ||||
|     Iterable<String>? categories, | ||||
|   }) async { | ||||
|     final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: { | ||||
|       'take': take, | ||||
|       'offset': offset, | ||||
|       'probe': searchTerm, | ||||
|       if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), | ||||
|       if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','), | ||||
|     }); | ||||
|     final List<SnPost> out = await _preloadRelatedDataInBatch( | ||||
|       List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import 'dart:developer'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:path/path.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:shared_preferences/shared_preferences.dart'; | ||||
| import 'package:surface/providers/config.dart'; | ||||
|   | ||||
| @@ -228,65 +228,72 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM | ||||
|       body: CustomScrollView( | ||||
|         controller: _scrollController, | ||||
|         slivers: [ | ||||
|           SliverAppBar( | ||||
|             expandedHeight: _appBarHeight, | ||||
|             title: _account == null | ||||
|                 ? Text('loading').tr() | ||||
|                 : RichText( | ||||
|                     textAlign: TextAlign.center, | ||||
|                     text: TextSpan(children: [ | ||||
|                       TextSpan( | ||||
|                         text: _account!.nick, | ||||
|                         style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                               color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                               shadows: labelShadows, | ||||
|                             ), | ||||
|                       ), | ||||
|                       const TextSpan(text: '\n'), | ||||
|                       TextSpan( | ||||
|                         text: '@${_account!.name}', | ||||
|                         style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                               color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                               shadows: labelShadows, | ||||
|                             ), | ||||
|                       ), | ||||
|                     ]), | ||||
|           Theme( | ||||
|             data: Theme.of(context).copyWith( | ||||
|               appBarTheme: Theme.of(context).appBarTheme.copyWith( | ||||
|                     foregroundColor: Colors.white, | ||||
|                   ), | ||||
|             pinned: true, | ||||
|             flexibleSpace: _account != null | ||||
|                 ? Stack( | ||||
|                     fit: StackFit.expand, | ||||
|                     children: [ | ||||
|                       UniversalImage( | ||||
|                         sn.getAttachmentUrl(_account!.banner), | ||||
|                         fit: BoxFit.cover, | ||||
|                         height: imageHeight, | ||||
|                         width: _appBarWidth, | ||||
|                         cacheHeight: imageHeight, | ||||
|                         cacheWidth: _appBarWidth, | ||||
|                       ), | ||||
|                       Positioned( | ||||
|                         top: 0, | ||||
|                         left: 0, | ||||
|                         right: 0, | ||||
|                         height: 56 + MediaQuery.of(context).padding.top, | ||||
|                         child: ClipRect( | ||||
|                           child: BackdropFilter( | ||||
|                             filter: ImageFilter.blur( | ||||
|                               sigmaX: _appBarBlur, | ||||
|                               sigmaY: _appBarBlur, | ||||
|                             ), | ||||
|                             child: Container( | ||||
|                               color: Colors.black.withOpacity( | ||||
|                                 clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|             ), | ||||
|             child: SliverAppBar( | ||||
|               expandedHeight: _appBarHeight, | ||||
|               title: _account == null | ||||
|                   ? Text('loading').tr() | ||||
|                   : RichText( | ||||
|                       textAlign: TextAlign.center, | ||||
|                       text: TextSpan(children: [ | ||||
|                         TextSpan( | ||||
|                           text: _account!.nick, | ||||
|                           style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                 color: Colors.white, | ||||
|                                 shadows: labelShadows, | ||||
|                               ), | ||||
|                         ), | ||||
|                         const TextSpan(text: '\n'), | ||||
|                         TextSpan( | ||||
|                           text: '@${_account!.name}', | ||||
|                           style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                 color: Colors.white, | ||||
|                                 shadows: labelShadows, | ||||
|                               ), | ||||
|                         ), | ||||
|                       ]), | ||||
|                     ), | ||||
|               pinned: true, | ||||
|               flexibleSpace: _account != null | ||||
|                   ? Stack( | ||||
|                       fit: StackFit.expand, | ||||
|                       children: [ | ||||
|                         UniversalImage( | ||||
|                           sn.getAttachmentUrl(_account!.banner), | ||||
|                           fit: BoxFit.cover, | ||||
|                           height: imageHeight, | ||||
|                           width: _appBarWidth, | ||||
|                           cacheHeight: imageHeight, | ||||
|                           cacheWidth: _appBarWidth, | ||||
|                         ), | ||||
|                         Positioned( | ||||
|                           top: 0, | ||||
|                           left: 0, | ||||
|                           right: 0, | ||||
|                           height: 56 + MediaQuery.of(context).padding.top, | ||||
|                           child: ClipRect( | ||||
|                             child: BackdropFilter( | ||||
|                               filter: ImageFilter.blur( | ||||
|                                 sigmaX: _appBarBlur, | ||||
|                                 sigmaY: _appBarBlur, | ||||
|                               ), | ||||
|                               child: Container( | ||||
|                                 color: Colors.black.withOpacity( | ||||
|                                   clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ), | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ) | ||||
|                 : null, | ||||
|                       ], | ||||
|                     ) | ||||
|                   : null, | ||||
|             ), | ||||
|           ), | ||||
|           if (_account != null) | ||||
|             SliverToBoxAdapter( | ||||
|   | ||||
| @@ -23,6 +23,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|   bool _isBusy = false; | ||||
|  | ||||
|   List<String> _searchTags = List.empty(growable: true); | ||||
|   List<String> _searchCategories = List.empty(growable: true); | ||||
|  | ||||
|   final List<SnPost> _posts = List.empty(growable: true); | ||||
|   int? _postCount; | ||||
| @@ -31,7 +32,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|   Duration? _lastTook; | ||||
|  | ||||
|   Future<void> _fetchPosts() async { | ||||
|     if (_searchTerm.isEmpty && _searchTags.isEmpty) return; | ||||
|     if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return; | ||||
|     if (_postCount != null && _posts.length >= _postCount!) return; | ||||
|  | ||||
|     setState(() => _isBusy = true); | ||||
| @@ -45,6 +46,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|         take: 10, | ||||
|         offset: _posts.length, | ||||
|         tags: _searchTags, | ||||
|         categories: _searchCategories, | ||||
|       ); | ||||
|       final List<SnPost> out = result.$1; | ||||
|       _postCount = result.$2; | ||||
| @@ -73,9 +75,20 @@ class _PostSearchScreenState extends State<PostSearchScreen> { | ||||
|               setState(() => _searchTags = value); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(4), | ||||
|           PostCategoriesField( | ||||
|             labelText: 'fieldPostCategories'.tr(), | ||||
|             initialCategories: _searchCategories, | ||||
|             onUpdate: (value) { | ||||
|               setState(() => _searchCategories = value); | ||||
|             }, | ||||
|           ), | ||||
|         ], | ||||
|       ).padding(horizontal: 24, vertical: 16), | ||||
|     ); | ||||
|     ).then((_) { | ||||
|       _posts.clear(); | ||||
|       _fetchPosts(); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   | ||||
| @@ -277,70 +277,77 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi | ||||
|               handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), | ||||
|               sliver: MultiSliver( | ||||
|                 children: [ | ||||
|                   SliverAppBar( | ||||
|                     expandedHeight: _appBarHeight, | ||||
|                     title: _publisher == null | ||||
|                         ? Text('loading').tr() | ||||
|                         : RichText( | ||||
|                             textAlign: TextAlign.center, | ||||
|                             text: TextSpan(children: [ | ||||
|                               TextSpan( | ||||
|                                 text: _publisher!.nick, | ||||
|                                 style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                       color: Theme.of(context).appBarTheme.foregroundColor!, | ||||
|                                       shadows: labelShadows, | ||||
|                                     ), | ||||
|                               ), | ||||
|                               const TextSpan(text: '\n'), | ||||
|                               TextSpan( | ||||
|                                 text: '@${_publisher!.name}', | ||||
|                                 style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                       color: Colors.white, | ||||
|                                       shadows: labelShadows, | ||||
|                                     ), | ||||
|                               ), | ||||
|                             ]), | ||||
|                           ), | ||||
|                     pinned: true, | ||||
|                     flexibleSpace: _publisher != null | ||||
|                         ? Stack( | ||||
|                             fit: StackFit.expand, | ||||
|                             children: [ | ||||
|                               if (_publisher!.banner.isNotEmpty) | ||||
|                                 UniversalImage( | ||||
|                                   sn.getAttachmentUrl(_publisher!.banner), | ||||
|                                   fit: BoxFit.cover, | ||||
|                                   height: imageHeight, | ||||
|                                   width: _appBarWidth, | ||||
|                                   cacheHeight: imageHeight, | ||||
|                                   cacheWidth: _appBarWidth, | ||||
|                                 ) | ||||
|                               else | ||||
|                                 Container( | ||||
|                                   color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                   Theme( | ||||
|                     data: Theme.of(context).copyWith( | ||||
|                       appBarTheme: Theme.of(context).appBarTheme.copyWith( | ||||
|                         foregroundColor: Colors.white, | ||||
|                       ), | ||||
|                     ), | ||||
|                     child: SliverAppBar( | ||||
|                       expandedHeight: _appBarHeight, | ||||
|                       title: _publisher == null | ||||
|                           ? Text('loading').tr() | ||||
|                           : RichText( | ||||
|                               textAlign: TextAlign.center, | ||||
|                               text: TextSpan(children: [ | ||||
|                                 TextSpan( | ||||
|                                   text: _publisher!.nick, | ||||
|                                   style: Theme.of(context).textTheme.titleLarge!.copyWith( | ||||
|                                         color: Colors.white, | ||||
|                                         shadows: labelShadows, | ||||
|                                       ), | ||||
|                                 ), | ||||
|                               Positioned( | ||||
|                                 top: 0, | ||||
|                                 left: 0, | ||||
|                                 right: 0, | ||||
|                                 height: 56 + MediaQuery.of(context).padding.top, | ||||
|                                 child: ClipRect( | ||||
|                                   child: BackdropFilter( | ||||
|                                     filter: ImageFilter.blur( | ||||
|                                       sigmaX: _appBarBlur, | ||||
|                                       sigmaY: _appBarBlur, | ||||
|                                     ), | ||||
|                                     child: Container( | ||||
|                                       color: Colors.black.withOpacity( | ||||
|                                         clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                 const TextSpan(text: '\n'), | ||||
|                                 TextSpan( | ||||
|                                   text: '@${_publisher!.name}', | ||||
|                                   style: Theme.of(context).textTheme.bodySmall!.copyWith( | ||||
|                                         color: Colors.white, | ||||
|                                         shadows: labelShadows, | ||||
|                                       ), | ||||
|                                 ), | ||||
|                               ]), | ||||
|                             ), | ||||
|                       pinned: true, | ||||
|                       flexibleSpace: _publisher != null | ||||
|                           ? Stack( | ||||
|                               fit: StackFit.expand, | ||||
|                               children: [ | ||||
|                                 if (_publisher!.banner.isNotEmpty) | ||||
|                                   UniversalImage( | ||||
|                                     sn.getAttachmentUrl(_publisher!.banner), | ||||
|                                     fit: BoxFit.cover, | ||||
|                                     height: imageHeight, | ||||
|                                     width: _appBarWidth, | ||||
|                                     cacheHeight: imageHeight, | ||||
|                                     cacheWidth: _appBarWidth, | ||||
|                                   ) | ||||
|                                 else | ||||
|                                   Container( | ||||
|                                     color: Theme.of(context).colorScheme.surfaceContainer, | ||||
|                                   ), | ||||
|                                 Positioned( | ||||
|                                   top: 0, | ||||
|                                   left: 0, | ||||
|                                   right: 0, | ||||
|                                   height: 56 + MediaQuery.of(context).padding.top, | ||||
|                                   child: ClipRect( | ||||
|                                     child: BackdropFilter( | ||||
|                                       filter: ImageFilter.blur( | ||||
|                                         sigmaX: _appBarBlur, | ||||
|                                         sigmaY: _appBarBlur, | ||||
|                                       ), | ||||
|                                       child: Container( | ||||
|                                         color: Colors.black.withOpacity( | ||||
|                                           clampDouble(_appBarBlur * 0.1, 0, 0.5), | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                             ], | ||||
|                           ) | ||||
|                         : null, | ||||
|                               ], | ||||
|                             ) | ||||
|                           : null, | ||||
|                     ), | ||||
|                   ), | ||||
|                   if (_publisher != null) | ||||
|                     SliverToBoxAdapter( | ||||
|   | ||||
| @@ -19,7 +19,7 @@ class SnPost with _$SnPost { | ||||
|     required String? alias, | ||||
|     required String? aliasPrefix, | ||||
|     @Default([]) List<SnPostTag> tags, | ||||
|     @Default([]) List<dynamic> categories, | ||||
|     @Default([]) List<SnPostCategory> categories, | ||||
|     required List<SnPost>? replies, | ||||
|     required int? replyId, | ||||
|     required int? repostId, | ||||
| @@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag { | ||||
|       _$SnPostTagFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnPostCategory with _$SnPostCategory { | ||||
|   const factory SnPostCategory({ | ||||
|     required int id, | ||||
|     required DateTime createdAt, | ||||
|     required DateTime updatedAt, | ||||
|     required dynamic deletedAt, | ||||
|     required String alias, | ||||
|     required String name, | ||||
|     required String description, | ||||
|     required dynamic posts, | ||||
|   }) = _SnPostCategory; | ||||
|  | ||||
|   factory SnPostCategory.fromJson(Map<String, Object?> json) => | ||||
|       _$SnPostCategoryFromJson(json); | ||||
| } | ||||
|  | ||||
| @freezed | ||||
| class SnPostPreload with _$SnPostPreload { | ||||
|   const factory SnPostPreload({ | ||||
|   | ||||
| @@ -30,7 +30,7 @@ mixin _$SnPost { | ||||
|   String? get alias => throw _privateConstructorUsedError; | ||||
|   String? get aliasPrefix => throw _privateConstructorUsedError; | ||||
|   List<SnPostTag> get tags => throw _privateConstructorUsedError; | ||||
|   List<dynamic> get categories => throw _privateConstructorUsedError; | ||||
|   List<SnPostCategory> get categories => throw _privateConstructorUsedError; | ||||
|   List<SnPost>? get replies => throw _privateConstructorUsedError; | ||||
|   int? get replyId => throw _privateConstructorUsedError; | ||||
|   int? get repostId => throw _privateConstructorUsedError; | ||||
| @@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> { | ||||
|       String? alias, | ||||
|       String? aliasPrefix, | ||||
|       List<SnPostTag> tags, | ||||
|       List<dynamic> categories, | ||||
|       List<SnPostCategory> categories, | ||||
|       List<SnPost>? replies, | ||||
|       int? replyId, | ||||
|       int? repostId, | ||||
| @@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost> | ||||
|       categories: null == categories | ||||
|           ? _value.categories | ||||
|           : categories // ignore: cast_nullable_to_non_nullable | ||||
|               as List<dynamic>, | ||||
|               as List<SnPostCategory>, | ||||
|       replies: freezed == replies | ||||
|           ? _value.replies | ||||
|           : replies // ignore: cast_nullable_to_non_nullable | ||||
| @@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> { | ||||
|       String? alias, | ||||
|       String? aliasPrefix, | ||||
|       List<SnPostTag> tags, | ||||
|       List<dynamic> categories, | ||||
|       List<SnPostCategory> categories, | ||||
|       List<SnPost>? replies, | ||||
|       int? replyId, | ||||
|       int? repostId, | ||||
| @@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res> | ||||
|       categories: null == categories | ||||
|           ? _value._categories | ||||
|           : categories // ignore: cast_nullable_to_non_nullable | ||||
|               as List<dynamic>, | ||||
|               as List<SnPostCategory>, | ||||
|       replies: freezed == replies | ||||
|           ? _value._replies | ||||
|           : replies // ignore: cast_nullable_to_non_nullable | ||||
| @@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost { | ||||
|       required this.alias, | ||||
|       required this.aliasPrefix, | ||||
|       final List<SnPostTag> tags = const [], | ||||
|       final List<dynamic> categories = const [], | ||||
|       final List<SnPostCategory> categories = const [], | ||||
|       required final List<SnPost>? replies, | ||||
|       required this.replyId, | ||||
|       required this.repostId, | ||||
| @@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost { | ||||
|     return EqualUnmodifiableListView(_tags); | ||||
|   } | ||||
|  | ||||
|   final List<dynamic> _categories; | ||||
|   final List<SnPostCategory> _categories; | ||||
|   @override | ||||
|   @JsonKey() | ||||
|   List<dynamic> get categories { | ||||
|   List<SnPostCategory> get categories { | ||||
|     if (_categories is EqualUnmodifiableListView) return _categories; | ||||
|     // ignore: implicit_dynamic_type | ||||
|     return EqualUnmodifiableListView(_categories); | ||||
| @@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost { | ||||
|       required final String? alias, | ||||
|       required final String? aliasPrefix, | ||||
|       final List<SnPostTag> tags, | ||||
|       final List<dynamic> categories, | ||||
|       final List<SnPostCategory> categories, | ||||
|       required final List<SnPost>? replies, | ||||
|       required final int? replyId, | ||||
|       required final int? repostId, | ||||
| @@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost { | ||||
|   @override | ||||
|   List<SnPostTag> get tags; | ||||
|   @override | ||||
|   List<dynamic> get categories; | ||||
|   List<SnPostCategory> get categories; | ||||
|   @override | ||||
|   List<SnPost>? get replies; | ||||
|   @override | ||||
| @@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag { | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| SnPostCategory _$SnPostCategoryFromJson(Map<String, dynamic> json) { | ||||
|   return _SnPostCategory.fromJson(json); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| mixin _$SnPostCategory { | ||||
|   int get id => throw _privateConstructorUsedError; | ||||
|   DateTime get createdAt => throw _privateConstructorUsedError; | ||||
|   DateTime get updatedAt => throw _privateConstructorUsedError; | ||||
|   dynamic get deletedAt => throw _privateConstructorUsedError; | ||||
|   String get alias => throw _privateConstructorUsedError; | ||||
|   String get name => throw _privateConstructorUsedError; | ||||
|   String get description => throw _privateConstructorUsedError; | ||||
|   dynamic get posts => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Serializes this SnPostCategory to a JSON map. | ||||
|   Map<String, dynamic> toJson() => throw _privateConstructorUsedError; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   $SnPostCategoryCopyWith<SnPostCategory> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class $SnPostCategoryCopyWith<$Res> { | ||||
|   factory $SnPostCategoryCopyWith( | ||||
|           SnPostCategory value, $Res Function(SnPostCategory) then) = | ||||
|       _$SnPostCategoryCopyWithImpl<$Res, SnPostCategory>; | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       dynamic deletedAt, | ||||
|       String alias, | ||||
|       String name, | ||||
|       String description, | ||||
|       dynamic posts}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class _$SnPostCategoryCopyWithImpl<$Res, $Val extends SnPostCategory> | ||||
|     implements $SnPostCategoryCopyWith<$Res> { | ||||
|   _$SnPostCategoryCopyWithImpl(this._value, this._then); | ||||
|  | ||||
|   // ignore: unused_field | ||||
|   final $Val _value; | ||||
|   // ignore: unused_field | ||||
|   final $Res Function($Val) _then; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? alias = null, | ||||
|     Object? name = null, | ||||
|     Object? description = null, | ||||
|     Object? posts = freezed, | ||||
|   }) { | ||||
|     return _then(_value.copyWith( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|       alias: null == alias | ||||
|           ? _value.alias | ||||
|           : alias // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       name: null == name | ||||
|           ? _value.name | ||||
|           : name // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       posts: freezed == posts | ||||
|           ? _value.posts | ||||
|           : posts // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|     ) as $Val); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| abstract class _$$SnPostCategoryImplCopyWith<$Res> | ||||
|     implements $SnPostCategoryCopyWith<$Res> { | ||||
|   factory _$$SnPostCategoryImplCopyWith(_$SnPostCategoryImpl value, | ||||
|           $Res Function(_$SnPostCategoryImpl) then) = | ||||
|       __$$SnPostCategoryImplCopyWithImpl<$Res>; | ||||
|   @override | ||||
|   @useResult | ||||
|   $Res call( | ||||
|       {int id, | ||||
|       DateTime createdAt, | ||||
|       DateTime updatedAt, | ||||
|       dynamic deletedAt, | ||||
|       String alias, | ||||
|       String name, | ||||
|       String description, | ||||
|       dynamic posts}); | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| class __$$SnPostCategoryImplCopyWithImpl<$Res> | ||||
|     extends _$SnPostCategoryCopyWithImpl<$Res, _$SnPostCategoryImpl> | ||||
|     implements _$$SnPostCategoryImplCopyWith<$Res> { | ||||
|   __$$SnPostCategoryImplCopyWithImpl( | ||||
|       _$SnPostCategoryImpl _value, $Res Function(_$SnPostCategoryImpl) _then) | ||||
|       : super(_value, _then); | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @pragma('vm:prefer-inline') | ||||
|   @override | ||||
|   $Res call({ | ||||
|     Object? id = null, | ||||
|     Object? createdAt = null, | ||||
|     Object? updatedAt = null, | ||||
|     Object? deletedAt = freezed, | ||||
|     Object? alias = null, | ||||
|     Object? name = null, | ||||
|     Object? description = null, | ||||
|     Object? posts = freezed, | ||||
|   }) { | ||||
|     return _then(_$SnPostCategoryImpl( | ||||
|       id: null == id | ||||
|           ? _value.id | ||||
|           : id // ignore: cast_nullable_to_non_nullable | ||||
|               as int, | ||||
|       createdAt: null == createdAt | ||||
|           ? _value.createdAt | ||||
|           : createdAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       updatedAt: null == updatedAt | ||||
|           ? _value.updatedAt | ||||
|           : updatedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as DateTime, | ||||
|       deletedAt: freezed == deletedAt | ||||
|           ? _value.deletedAt | ||||
|           : deletedAt // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|       alias: null == alias | ||||
|           ? _value.alias | ||||
|           : alias // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       name: null == name | ||||
|           ? _value.name | ||||
|           : name // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       description: null == description | ||||
|           ? _value.description | ||||
|           : description // ignore: cast_nullable_to_non_nullable | ||||
|               as String, | ||||
|       posts: freezed == posts | ||||
|           ? _value.posts | ||||
|           : posts // ignore: cast_nullable_to_non_nullable | ||||
|               as dynamic, | ||||
|     )); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// @nodoc | ||||
| @JsonSerializable() | ||||
| class _$SnPostCategoryImpl implements _SnPostCategory { | ||||
|   const _$SnPostCategoryImpl( | ||||
|       {required this.id, | ||||
|       required this.createdAt, | ||||
|       required this.updatedAt, | ||||
|       required this.deletedAt, | ||||
|       required this.alias, | ||||
|       required this.name, | ||||
|       required this.description, | ||||
|       required this.posts}); | ||||
|  | ||||
|   factory _$SnPostCategoryImpl.fromJson(Map<String, dynamic> json) => | ||||
|       _$$SnPostCategoryImplFromJson(json); | ||||
|  | ||||
|   @override | ||||
|   final int id; | ||||
|   @override | ||||
|   final DateTime createdAt; | ||||
|   @override | ||||
|   final DateTime updatedAt; | ||||
|   @override | ||||
|   final dynamic deletedAt; | ||||
|   @override | ||||
|   final String alias; | ||||
|   @override | ||||
|   final String name; | ||||
|   @override | ||||
|   final String description; | ||||
|   @override | ||||
|   final dynamic posts; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SnPostCategory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     return identical(this, other) || | ||||
|         (other.runtimeType == runtimeType && | ||||
|             other is _$SnPostCategoryImpl && | ||||
|             (identical(other.id, id) || other.id == id) && | ||||
|             (identical(other.createdAt, createdAt) || | ||||
|                 other.createdAt == createdAt) && | ||||
|             (identical(other.updatedAt, updatedAt) || | ||||
|                 other.updatedAt == updatedAt) && | ||||
|             const DeepCollectionEquality().equals(other.deletedAt, deletedAt) && | ||||
|             (identical(other.alias, alias) || other.alias == alias) && | ||||
|             (identical(other.name, name) || other.name == name) && | ||||
|             (identical(other.description, description) || | ||||
|                 other.description == description) && | ||||
|             const DeepCollectionEquality().equals(other.posts, posts)); | ||||
|   } | ||||
|  | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   int get hashCode => Object.hash( | ||||
|       runtimeType, | ||||
|       id, | ||||
|       createdAt, | ||||
|       updatedAt, | ||||
|       const DeepCollectionEquality().hash(deletedAt), | ||||
|       alias, | ||||
|       name, | ||||
|       description, | ||||
|       const DeepCollectionEquality().hash(posts)); | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   @override | ||||
|   @pragma('vm:prefer-inline') | ||||
|   _$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith => | ||||
|       __$$SnPostCategoryImplCopyWithImpl<_$SnPostCategoryImpl>( | ||||
|           this, _$identity); | ||||
|  | ||||
|   @override | ||||
|   Map<String, dynamic> toJson() { | ||||
|     return _$$SnPostCategoryImplToJson( | ||||
|       this, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| abstract class _SnPostCategory implements SnPostCategory { | ||||
|   const factory _SnPostCategory( | ||||
|       {required final int id, | ||||
|       required final DateTime createdAt, | ||||
|       required final DateTime updatedAt, | ||||
|       required final dynamic deletedAt, | ||||
|       required final String alias, | ||||
|       required final String name, | ||||
|       required final String description, | ||||
|       required final dynamic posts}) = _$SnPostCategoryImpl; | ||||
|  | ||||
|   factory _SnPostCategory.fromJson(Map<String, dynamic> json) = | ||||
|       _$SnPostCategoryImpl.fromJson; | ||||
|  | ||||
|   @override | ||||
|   int get id; | ||||
|   @override | ||||
|   DateTime get createdAt; | ||||
|   @override | ||||
|   DateTime get updatedAt; | ||||
|   @override | ||||
|   dynamic get deletedAt; | ||||
|   @override | ||||
|   String get alias; | ||||
|   @override | ||||
|   String get name; | ||||
|   @override | ||||
|   String get description; | ||||
|   @override | ||||
|   dynamic get posts; | ||||
|  | ||||
|   /// Create a copy of SnPostCategory | ||||
|   /// with the given fields replaced by the non-null parameter values. | ||||
|   @override | ||||
|   @JsonKey(includeFromJson: false, includeToJson: false) | ||||
|   _$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith => | ||||
|       throw _privateConstructorUsedError; | ||||
| } | ||||
|  | ||||
| SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { | ||||
|   return _SnPostPreload.fromJson(json); | ||||
| } | ||||
|   | ||||
| @@ -22,7 +22,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl( | ||||
|               ?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|       categories: json['categories'] as List<dynamic>? ?? const [], | ||||
|       categories: (json['categories'] as List<dynamic>?) | ||||
|               ?.map((e) => SnPostCategory.fromJson(e as Map<String, dynamic>)) | ||||
|               .toList() ?? | ||||
|           const [], | ||||
|       replies: (json['replies'] as List<dynamic>?) | ||||
|           ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) | ||||
|           .toList(), | ||||
| @@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) => | ||||
|       'alias': instance.alias, | ||||
|       'alias_prefix': instance.aliasPrefix, | ||||
|       'tags': instance.tags.map((e) => e.toJson()).toList(), | ||||
|       'categories': instance.categories, | ||||
|       'categories': instance.categories.map((e) => e.toJson()).toList(), | ||||
|       'replies': instance.replies?.map((e) => e.toJson()).toList(), | ||||
|       'reply_id': instance.replyId, | ||||
|       'repost_id': instance.repostId, | ||||
| @@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) => | ||||
|       'posts': instance.posts, | ||||
|     }; | ||||
|  | ||||
| _$SnPostCategoryImpl _$$SnPostCategoryImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPostCategoryImpl( | ||||
|       id: (json['id'] as num).toInt(), | ||||
|       createdAt: DateTime.parse(json['created_at'] as String), | ||||
|       updatedAt: DateTime.parse(json['updated_at'] as String), | ||||
|       deletedAt: json['deleted_at'], | ||||
|       alias: json['alias'] as String, | ||||
|       name: json['name'] as String, | ||||
|       description: json['description'] as String, | ||||
|       posts: json['posts'], | ||||
|     ); | ||||
|  | ||||
| Map<String, dynamic> _$$SnPostCategoryImplToJson( | ||||
|         _$SnPostCategoryImpl instance) => | ||||
|     <String, dynamic>{ | ||||
|       'id': instance.id, | ||||
|       'created_at': instance.createdAt.toIso8601String(), | ||||
|       'updated_at': instance.updatedAt.toIso8601String(), | ||||
|       'deleted_at': instance.deletedAt, | ||||
|       'alias': instance.alias, | ||||
|       'name': instance.name, | ||||
|       'description': instance.description, | ||||
|       'posts': instance.posts, | ||||
|     }; | ||||
|  | ||||
| _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => | ||||
|     _$SnPostPreloadImpl( | ||||
|       thumbnail: json['thumbnail'] == null | ||||
|   | ||||
| @@ -179,6 +179,7 @@ class PostItem extends StatelessWidget { | ||||
|                     children: [ | ||||
|                       if (data.visibility > 0) _PostVisibilityHint(data: data), | ||||
|                       _PostTruncatedHint(data: data), | ||||
|                       if (data.tags.isNotEmpty) _PostTagsList(data: data), | ||||
|                     ], | ||||
|                   ).padding(horizontal: 12), | ||||
|                   const Gap(8), | ||||
| @@ -186,7 +187,6 @@ class PostItem extends StatelessWidget { | ||||
|               ), | ||||
|             ), | ||||
|             Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8), | ||||
|             if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6), | ||||
|             _PostBottomAction( | ||||
|               data: data, | ||||
|               showComments: showComments, | ||||
| @@ -966,23 +966,55 @@ class _PostTagsList extends StatelessWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Wrap( | ||||
|       spacing: 4, | ||||
|       runSpacing: 4, | ||||
|       children: data.tags | ||||
|           .map( | ||||
|             (ele) => InkWell( | ||||
|               child: Text( | ||||
|                 '#${ele.alias}', | ||||
|                 style: TextStyle( | ||||
|                   decoration: TextDecoration.underline, | ||||
|     return Column( | ||||
|       mainAxisSize: MainAxisSize.min, | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Wrap( | ||||
|           spacing: 4, | ||||
|           runSpacing: 4, | ||||
|           children: data.categories | ||||
|               .map( | ||||
|                 (ele) => InkWell( | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.category, size: 20), | ||||
|                       const Gap(4), | ||||
|                       Text( | ||||
|                         'postCategory${ele.alias.capitalize()}'.trExists() | ||||
|                             ? 'postCategory${ele.alias.capitalize()}'.tr() | ||||
|                             : ele.alias, | ||||
|                         style: GoogleFonts.robotoMono(), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: () {}, | ||||
|                 ), | ||||
|               ).fontSize(13), | ||||
|               onTap: () {}, | ||||
|             ), | ||||
|           ) | ||||
|           .toList(), | ||||
|     ).opacity(0.8); | ||||
|               ) | ||||
|               .toList(), | ||||
|         ).opacity(0.8), | ||||
|         Wrap( | ||||
|           spacing: 4, | ||||
|           runSpacing: 4, | ||||
|           children: data.tags | ||||
|               .map( | ||||
|                 (ele) => InkWell( | ||||
|                   child: Row( | ||||
|                     mainAxisSize: MainAxisSize.min, | ||||
|                     children: [ | ||||
|                       const Icon(Symbols.label, size: 20), | ||||
|                       const Gap(4), | ||||
|                       Text(ele.alias, style: GoogleFonts.robotoMono()), | ||||
|                     ], | ||||
|                   ), | ||||
|                   onTap: () {}, | ||||
|                 ), | ||||
|               ) | ||||
|               .toList(), | ||||
|         ).opacity(0.8), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -1023,6 +1055,7 @@ class _PostTruncatedHint extends StatelessWidget { | ||||
|     return SingleChildScrollView( | ||||
|       scrollDirection: Axis.horizontal, | ||||
|       child: Row( | ||||
|         spacing: 8, | ||||
|         children: [ | ||||
|           if (data.body['content_length'] != null) | ||||
|             Row( | ||||
| @@ -1035,7 +1068,7 @@ class _PostTruncatedHint extends StatelessWidget { | ||||
|                   ).inSeconds}s', | ||||
|                 ]), | ||||
|               ], | ||||
|             ).padding(right: 8), | ||||
|             ), | ||||
|           if (data.body['content_length'] != null) | ||||
|             Row( | ||||
|               children: [ | ||||
|   | ||||
| @@ -83,167 +83,178 @@ class PostMetaEditor extends StatelessWidget { | ||||
|     return ListenableBuilder( | ||||
|       listenable: controller, | ||||
|       builder: (context, _) { | ||||
|         return Column( | ||||
|           children: [ | ||||
|             TextField( | ||||
|               controller: controller.titleController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'fieldPostTitle'.tr(), | ||||
|                 border: UnderlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: (_) => | ||||
|                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ).padding(horizontal: 24), | ||||
|             if (controller.mode == 'articles') const Gap(4), | ||||
|             if (controller.mode == 'articles') | ||||
|         return SingleChildScrollView( | ||||
|           padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), | ||||
|           child: Column( | ||||
|             children: [ | ||||
|               TextField( | ||||
|                 controller: controller.descriptionController, | ||||
|                 maxLines: null, | ||||
|                 controller: controller.titleController, | ||||
|                 decoration: InputDecoration( | ||||
|                   labelText: 'fieldPostDescription'.tr(), | ||||
|                   labelText: 'fieldPostTitle'.tr(), | ||||
|                   border: UnderlineInputBorder(), | ||||
|                 ), | ||||
|                 onTapOutside: (_) => | ||||
|                     FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ).padding(horizontal: 24), | ||||
|             const Gap(4), | ||||
|             PostTagsField( | ||||
|               initialTags: controller.tags, | ||||
|               labelText: 'fieldPostTags'.tr(), | ||||
|               onUpdate: (value) { | ||||
|                 controller.setTags(value); | ||||
|               }, | ||||
|             ).padding(horizontal: 24), | ||||
|             const Gap(4), | ||||
|             TextField( | ||||
|               controller: controller.aliasController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'fieldPostAlias'.tr(), | ||||
|                 helperText: 'fieldPostAliasHint'.tr(), | ||||
|                 helperMaxLines: 2, | ||||
|                 border: UnderlineInputBorder(), | ||||
|               ), | ||||
|               onTapOutside: (_) => | ||||
|                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ).padding(horizontal: 24), | ||||
|             const Gap(12), | ||||
|             ListTile( | ||||
|               contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|               leading: const Icon(Symbols.visibility), | ||||
|               title: Text('postVisibility').tr(), | ||||
|               subtitle: Text('postVisibilityDescription').tr(), | ||||
|               trailing: SizedBox( | ||||
|                 width: 180, | ||||
|                 child: DropdownButtonHideUnderline( | ||||
|                   child: DropdownButton2<int>( | ||||
|                     isExpanded: true, | ||||
|                     items: kPostVisibilityLevel.entries | ||||
|                         .map( | ||||
|                           (entry) => DropdownMenuItem<int>( | ||||
|                             value: entry.key, | ||||
|                             child: Text( | ||||
|                               entry.value, | ||||
|                               style: const TextStyle(fontSize: 14), | ||||
|                             ).tr(), | ||||
|                           ), | ||||
|                         ) | ||||
|                         .toList(), | ||||
|                     value: controller.visibility, | ||||
|                     onChanged: (int? value) { | ||||
|                       if (value != null) { | ||||
|                         controller.setVisibility(value); | ||||
|                       } | ||||
|                     }, | ||||
|                     buttonStyleData: const ButtonStyleData( | ||||
|                       height: 40, | ||||
|                       padding: EdgeInsets.symmetric( | ||||
|                         horizontal: 4, | ||||
|                         vertical: 8, | ||||
|               if (controller.mode == 'articles') const Gap(4), | ||||
|               if (controller.mode == 'articles') | ||||
|                 TextField( | ||||
|                   controller: controller.descriptionController, | ||||
|                   maxLines: null, | ||||
|                   decoration: InputDecoration( | ||||
|                     labelText: 'fieldPostDescription'.tr(), | ||||
|                     border: UnderlineInputBorder(), | ||||
|                   ), | ||||
|                   onTapOutside: (_) => | ||||
|                       FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                 ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               PostTagsField( | ||||
|                 initialTags: controller.tags, | ||||
|                 labelText: 'fieldPostTags'.tr(), | ||||
|                 onUpdate: (value) { | ||||
|                   controller.setTags(value); | ||||
|                 }, | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               PostCategoriesField( | ||||
|                 initialCategories: controller.categories, | ||||
|                 labelText: 'fieldPostCategories'.tr(), | ||||
|                 onUpdate: (value) { | ||||
|                   controller.setCategories(value); | ||||
|                 }, | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(4), | ||||
|               TextField( | ||||
|                 controller: controller.aliasController, | ||||
|                 decoration: InputDecoration( | ||||
|                   labelText: 'fieldPostAlias'.tr(), | ||||
|                   helperText: 'fieldPostAliasHint'.tr(), | ||||
|                   helperMaxLines: 2, | ||||
|                   border: UnderlineInputBorder(), | ||||
|                 ), | ||||
|                 onTapOutside: (_) => | ||||
|                     FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ).padding(horizontal: 24), | ||||
|               const Gap(12), | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: const Icon(Symbols.visibility), | ||||
|                 title: Text('postVisibility').tr(), | ||||
|                 subtitle: Text('postVisibilityDescription').tr(), | ||||
|                 trailing: SizedBox( | ||||
|                   width: 180, | ||||
|                   child: DropdownButtonHideUnderline( | ||||
|                     child: DropdownButton2<int>( | ||||
|                       isExpanded: true, | ||||
|                       items: kPostVisibilityLevel.entries | ||||
|                           .map( | ||||
|                             (entry) => DropdownMenuItem<int>( | ||||
|                               value: entry.key, | ||||
|                               child: Text( | ||||
|                                 entry.value, | ||||
|                                 style: const TextStyle(fontSize: 14), | ||||
|                               ).tr(), | ||||
|                             ), | ||||
|                           ) | ||||
|                           .toList(), | ||||
|                       value: controller.visibility, | ||||
|                       onChanged: (int? value) { | ||||
|                         if (value != null) { | ||||
|                           controller.setVisibility(value); | ||||
|                         } | ||||
|                       }, | ||||
|                       buttonStyleData: const ButtonStyleData( | ||||
|                         height: 40, | ||||
|                         padding: EdgeInsets.symmetric( | ||||
|                           horizontal: 4, | ||||
|                           vertical: 8, | ||||
|                         ), | ||||
|                       ), | ||||
|                       menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|                     ), | ||||
|                     menuItemStyleData: const MenuItemStyleData(height: 40), | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             if (controller.visibility == 2) | ||||
|               if (controller.visibility == 2) | ||||
|                 ListTile( | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   leading: Icon(Symbols.person), | ||||
|                   trailing: Icon(Symbols.chevron_right), | ||||
|                   title: Text('postVisibleUsers').tr(), | ||||
|                   subtitle: Text('postSelectedUsers') | ||||
|                       .plural(controller.visibleUsers.length), | ||||
|                   onTap: () { | ||||
|                     _selectVisibleUser(context); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (controller.visibility == 3) | ||||
|                 ListTile( | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   leading: Icon(Symbols.person), | ||||
|                   trailing: Icon(Symbols.chevron_right), | ||||
|                   title: Text('postInvisibleUsers').tr(), | ||||
|                   subtitle: Text('postSelectedUsers') | ||||
|                       .plural(controller.invisibleUsers.length), | ||||
|                   onTap: () { | ||||
|                     _selectInvisibleUser(context); | ||||
|                   }, | ||||
|                 ), | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: Icon(Symbols.person), | ||||
|                 trailing: Icon(Symbols.chevron_right), | ||||
|                 title: Text('postVisibleUsers').tr(), | ||||
|                 subtitle: Text('postSelectedUsers') | ||||
|                     .plural(controller.visibleUsers.length), | ||||
|                 leading: const Icon(Symbols.event_available), | ||||
|                 title: Text('postPublishedAt').tr(), | ||||
|                 subtitle: Text( | ||||
|                   controller.publishedAt != null | ||||
|                       ? dateFormatter.format(controller.publishedAt!) | ||||
|                       : 'unset'.tr(), | ||||
|                 ), | ||||
|                 trailing: controller.publishedAt != null | ||||
|                     ? IconButton( | ||||
|                         icon: const Icon(Symbols.cancel), | ||||
|                         onPressed: () { | ||||
|                           controller.setPublishedAt(null); | ||||
|                         }, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|                 onTap: () { | ||||
|                   _selectVisibleUser(context); | ||||
|                   _selectDate( | ||||
|                     context, | ||||
|                     initialDateTime: controller.publishedAt, | ||||
|                   ).then((value) { | ||||
|                     controller.setPublishedAt(value); | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             if (controller.visibility == 3) | ||||
|               ListTile( | ||||
|                 contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                 leading: Icon(Symbols.person), | ||||
|                 trailing: Icon(Symbols.chevron_right), | ||||
|                 title: Text('postInvisibleUsers').tr(), | ||||
|                 subtitle: Text('postSelectedUsers') | ||||
|                     .plural(controller.invisibleUsers.length), | ||||
|                 leading: const Icon(Symbols.event_busy), | ||||
|                 title: Text('postPublishedUntil').tr(), | ||||
|                 subtitle: Text( | ||||
|                   controller.publishedUntil != null | ||||
|                       ? dateFormatter.format(controller.publishedUntil!) | ||||
|                       : 'unset'.tr(), | ||||
|                 ), | ||||
|                 trailing: controller.publishedUntil != null | ||||
|                     ? IconButton( | ||||
|                         icon: const Icon(Symbols.cancel), | ||||
|                         onPressed: () { | ||||
|                           controller.setPublishedUntil(null); | ||||
|                         }, | ||||
|                       ) | ||||
|                     : null, | ||||
|                 contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|                 onTap: () { | ||||
|                   _selectInvisibleUser(context); | ||||
|                   _selectDate( | ||||
|                     context, | ||||
|                     initialDateTime: controller.publishedUntil, | ||||
|                   ).then((value) { | ||||
|                     controller.setPublishedUntil(value); | ||||
|                   }); | ||||
|                 }, | ||||
|               ), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.event_available), | ||||
|               title: Text('postPublishedAt').tr(), | ||||
|               subtitle: Text( | ||||
|                 controller.publishedAt != null | ||||
|                     ? dateFormatter.format(controller.publishedAt!) | ||||
|                     : 'unset'.tr(), | ||||
|               ), | ||||
|               trailing: controller.publishedAt != null | ||||
|                   ? IconButton( | ||||
|                       icon: const Icon(Symbols.cancel), | ||||
|                       onPressed: () { | ||||
|                         controller.setPublishedAt(null); | ||||
|                       }, | ||||
|                     ) | ||||
|                   : null, | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|               onTap: () { | ||||
|                 _selectDate( | ||||
|                   context, | ||||
|                   initialDateTime: controller.publishedAt, | ||||
|                 ).then((value) { | ||||
|                   controller.setPublishedAt(value); | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
|             ListTile( | ||||
|               leading: const Icon(Symbols.event_busy), | ||||
|               title: Text('postPublishedUntil').tr(), | ||||
|               subtitle: Text( | ||||
|                 controller.publishedUntil != null | ||||
|                     ? dateFormatter.format(controller.publishedUntil!) | ||||
|                     : 'unset'.tr(), | ||||
|               ), | ||||
|               trailing: controller.publishedUntil != null | ||||
|                   ? IconButton( | ||||
|                       icon: const Icon(Symbols.cancel), | ||||
|                       onPressed: () { | ||||
|                         controller.setPublishedUntil(null); | ||||
|                       }, | ||||
|                     ) | ||||
|                   : null, | ||||
|               contentPadding: const EdgeInsets.only(left: 24, right: 18), | ||||
|               onTap: () { | ||||
|                 _selectDate( | ||||
|                   context, | ||||
|                   initialDateTime: controller.publishedUntil, | ||||
|                 ).then((value) { | ||||
|                   controller.setPublishedUntil(value); | ||||
|                 }); | ||||
|               }, | ||||
|             ), | ||||
|           ], | ||||
|         ).padding(vertical: 8); | ||||
|             ], | ||||
|           ).padding(vertical: 8), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| import 'package:surface/providers/sn_network.dart'; | ||||
| import 'package:surface/widgets/dialog.dart'; | ||||
|  | ||||
| class PostTagsField extends StatefulWidget { | ||||
|   final List<String>? initialTags; | ||||
| @@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget { | ||||
|   State<PostTagsField> createState() => _PostTagsFieldState(); | ||||
| } | ||||
|  | ||||
| class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   static const List<String> kTagsDividers = [' ', ',']; | ||||
| const List<String> kTagsDividers = [' ', ',']; | ||||
|  | ||||
| class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   late final _Debounceable<List<String>?, String> _debouncedSearch; | ||||
|  | ||||
|   final List<String> _currentTags = List.empty(growable: true); | ||||
| @@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> { | ||||
|                             color: Theme.of(context).colorScheme.primary, | ||||
|                           ), | ||||
|                           margin: const EdgeInsets.only(right: 8), | ||||
|                           padding: const EdgeInsets.symmetric( | ||||
|                               horizontal: 10.0, vertical: 4.0), | ||||
|                           padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), | ||||
|                           child: Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
| @@ -155,6 +156,155 @@ class _PostTagsFieldState extends State<PostTagsField> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class PostCategoriesField extends StatefulWidget { | ||||
|   final List<String>? initialCategories; | ||||
|   final String labelText; | ||||
|   final Function(List<String>) onUpdate; | ||||
|  | ||||
|   const PostCategoriesField({ | ||||
|     super.key, | ||||
|     this.initialCategories, | ||||
|     required this.labelText, | ||||
|     required this.onUpdate, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<PostCategoriesField> createState() => _PostCategoriesFieldState(); | ||||
| } | ||||
|  | ||||
| class _PostCategoriesFieldState extends State<PostCategoriesField> { | ||||
|   late final _Debounceable<List<String>?, String> _debouncedSearch; | ||||
|  | ||||
|   final List<String> _currentCategories = List.empty(growable: true); | ||||
|  | ||||
|   String? _currentSearchProbe; | ||||
|   List<String> _lastAutocompleteResult = List.empty(); | ||||
|   TextEditingController? _textEditingController; | ||||
|  | ||||
|   Future<List<String>?> _searchCategories(String probe) async { | ||||
|     _currentSearchProbe = probe; | ||||
|  | ||||
|     final sn = context.read<SnNetworkProvider>(); | ||||
|     final resp = await sn.client.get( | ||||
|       '/cgi/co/categories?take=10&probe=$_currentSearchProbe', | ||||
|     ); | ||||
|  | ||||
|     if (_currentSearchProbe != probe) { | ||||
|       return null; | ||||
|     } | ||||
|     _currentSearchProbe = null; | ||||
|  | ||||
|     return resp.data.map((x) => x['alias']).toList().cast<String>(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _debouncedSearch = _debounce<List<String>?, String>(_searchCategories); | ||||
|     if (widget.initialCategories != null) { | ||||
|       _currentCategories.addAll(widget.initialCategories!); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Autocomplete<String>( | ||||
|       optionsBuilder: (TextEditingValue textEditingValue) async { | ||||
|         final result = await _debouncedSearch(textEditingValue.text); | ||||
|         if (result == null) { | ||||
|           return _lastAutocompleteResult; | ||||
|         } | ||||
|         _lastAutocompleteResult = result; | ||||
|         return result; | ||||
|       }, | ||||
|       onSelected: (String value) { | ||||
|         if (value.isEmpty) return; | ||||
|         if (!_currentCategories.contains(value)) { | ||||
|           setState(() => _currentCategories.add(value)); | ||||
|         } | ||||
|         _textEditingController?.clear(); | ||||
|         widget.onUpdate(_currentCategories); | ||||
|       }, | ||||
|       fieldViewBuilder: (context, controller, focusNode, onSubmitted) { | ||||
|         _textEditingController = controller; | ||||
|         return TextField( | ||||
|           controller: controller, | ||||
|           focusNode: focusNode, | ||||
|           decoration: InputDecoration( | ||||
|             label: Text(widget.labelText), | ||||
|             border: const UnderlineInputBorder(), | ||||
|             prefixIconConstraints: BoxConstraints( | ||||
|               maxWidth: MediaQuery.of(context).size.width * 0.75, | ||||
|             ), | ||||
|             prefixIcon: _currentCategories.isNotEmpty | ||||
|                 ? SingleChildScrollView( | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     child: Row( | ||||
|                       children: _currentCategories.map((String category) { | ||||
|                         return Container( | ||||
|                           decoration: BoxDecoration( | ||||
|                             borderRadius: const BorderRadius.all( | ||||
|                               Radius.circular(20.0), | ||||
|                             ), | ||||
|                             color: Theme.of(context).colorScheme.primary, | ||||
|                           ), | ||||
|                           margin: const EdgeInsets.only(right: 8), | ||||
|                           padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), | ||||
|                           child: Row( | ||||
|                             mainAxisAlignment: MainAxisAlignment.spaceBetween, | ||||
|                             children: [ | ||||
|                               InkWell( | ||||
|                                 child: Text( | ||||
|                                   'postCategory${category.capitalize()}'.trExists() | ||||
|                                       ? 'postCategory${category.capitalize()}'.tr() | ||||
|                                       : '#$category', | ||||
|                                   style: const TextStyle(color: Colors.white), | ||||
|                                 ), | ||||
|                               ), | ||||
|                               const Gap(4), | ||||
|                               InkWell( | ||||
|                                 child: const Icon( | ||||
|                                   Icons.cancel, | ||||
|                                   size: 14.0, | ||||
|                                   color: Color.fromARGB(255, 233, 233, 233), | ||||
|                                 ), | ||||
|                                 onTap: () { | ||||
|                                   setState(() => _currentCategories.remove(category)); | ||||
|                                   widget.onUpdate(_currentCategories); | ||||
|                                 }, | ||||
|                               ) | ||||
|                             ], | ||||
|                           ), | ||||
|                         ); | ||||
|                       }).toList(), | ||||
|                     ), | ||||
|                   ) | ||||
|                 : null, | ||||
|           ), | ||||
|           onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|           onChanged: (value) { | ||||
|             for (final divider in kTagsDividers) { | ||||
|               if (value.endsWith(divider)) { | ||||
|                 final tagValue = value.substring(0, value.length - 1); | ||||
|                 if (tagValue.isEmpty) return; | ||||
|                 if (!_currentCategories.contains(tagValue)) { | ||||
|                   setState(() => _currentCategories.add(tagValue)); | ||||
|                 } | ||||
|                 controller.clear(); | ||||
|                 widget.onUpdate(_currentCategories); | ||||
|                 break; | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onSubmitted: (_) { | ||||
|             onSubmitted(); | ||||
|           }, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| typedef _Debounceable<S, T> = Future<S?> Function(T parameter); | ||||
|  | ||||
| _Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user