Compare commits

...

13 Commits

Author SHA1 Message Date
7a3ab6fd7d 🚀 Launch 2.1.1+38 2024-12-22 19:50:08 +08:00
3d15c0b9f9 User fortune history 2024-12-22 19:37:44 +08:00
67a29b4305 Show user level 2024-12-22 19:11:53 +08:00
594f57e0d3 Tappable label tags 2024-12-22 17:37:37 +08:00
d1eb51c596 💄 Optimize styling 2024-12-22 17:30:41 +08:00
85d2eff7f8 Explore page filtered by post 2024-12-22 17:19:35 +08:00
2375c46852 Search filtering by categories 2024-12-22 15:57:37 +08:00
fd2eb5cda6 Localized post categories 2024-12-22 15:20:33 +08:00
1256f440bd Show post categories 2024-12-22 15:11:40 +08:00
5b05ca67b6 Editing categories 2024-12-22 14:56:34 +08:00
95af7140cd 💄 Optimize app bar 2024-12-22 13:54:46 +08:00
77e9994204 ;sparkles: Transparent app bar 2024-12-22 13:31:09 +08:00
3f6c186c13 Color scheme 2024-12-22 13:07:22 +08:00
28 changed files with 1510 additions and 361 deletions

View File

@ -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",
@ -178,12 +179,18 @@
"other": "{} comments"
},
"settingsAppearance": "Appearance",
"settingsAppBarTransparent": "Transparent App Bar",
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
"settingsBackgroundImage": "Background Image",
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
"settingsBackgroundImageClear": "Clear Existing Background Image",
"settingsBackgroundImageClearDescription": "Reset the background image to blank.",
"settingsThemeMaterial3": "Use Material You Design",
"settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.",
"settingsColorScheme": "Color Scheme",
"settingsColorSchemeDescription": "Set the application primary color.",
"settingsColorSeed": "Color Seed",
"settingsColorSeedDescription": "Select one of the present color schemes.",
"settingsNetwork": "Network",
"settingsNetworkServer": "HyperNet Server",
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
@ -450,7 +457,7 @@
"publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.",
"userUnblocked": "{} has been unblocked.",
"userBlocked": "{} has been blocked.",
"postSharingViaPicture": "Capturing post as picture, please stand by...",
"postSharingViaPicture": "Capturing post as picture, please wait...",
"postImageShareReadMore": "Scan the QR code to read full post",
"postImageShareAds": "Explore posts on the Solar Network",
"postShare": "Share",
@ -461,5 +468,25 @@
"shareIntentDescription": "What do you want to do with the content you are sharing?",
"shareIntentPostStory": "Post a Story",
"updateAvailable": "Update Available",
"updateOngoing": "正在更新,请稍后..."
"updateOngoing": "Updating, please wait...",
"custom": "Custom",
"colorSchemeIndigo": "Indigo",
"colorSchemeBlue": "Blue",
"colorSchemeGreen": "Green",
"colorSchemeYellow": "Yellow",
"colorSchemeOrange": "Orange",
"colorSchemeRed": "Red",
"colorSchemeWhite": "White",
"colorSchemeBlack": "Black",
"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"
}

View File

@ -123,6 +123,7 @@
"fieldPostTitle": "标题",
"fieldPostDescription": "描述",
"fieldPostTags": "标签",
"fieldPostCategories": "分类",
"fieldPostAlias": "别名",
"fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。",
"postPublish": "发布",
@ -182,6 +183,12 @@
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
"settingsThemeMaterial3": "使用 Material You 设计范式",
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
"settingsAppBarTransparent": "透明顶栏",
"settingsAppBarTransparentDescription": "为顶栏启用透明效果。",
"settingsColorScheme": "主题色",
"settingsColorSchemeDescription": "设置应用主题色。",
"settingsColorSeed": "预设色彩主题",
"settingsColorSeedDescription": "选择一个预设色彩主题。",
"settingsNetwork": "网络",
"settingsNetworkServer": "HyperNet 服务器",
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
@ -410,7 +417,7 @@
"accountStatus": "状态",
"accountStatusOnline": "在线",
"accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线",
"accountStatusLastSeen": "最后一次上线于 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"articleWrittenAt": "发表于 {}",
@ -459,5 +466,25 @@
"shareIntentDescription": "您想对您分享的内容做些什么?",
"shareIntentPostStory": "发布动态",
"updateAvailable": "检测到更新可用",
"updateOngoing": "正在更新,请稍后……"
"updateOngoing": "正在更新,请稍后……",
"custom": "自定义",
"colorSchemeIndigo": "靛蓝",
"colorSchemeBlue": "蓝色",
"colorSchemeGreen": "绿色",
"colorSchemeYellow": "黄色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "红色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postCategoryTechnology": "技术",
"postCategoryGaming": "游戏",
"postCategoryLife": "生活",
"postCategoryArts": "艺术",
"postCategorySports": "体育",
"postCategoryMusic": "音乐",
"postCategoryNews": "新闻",
"postCategoryKnowledge": "知识",
"postCategoryLiterature": "文学",
"postCategoryUncategorized": "未分类"
}

View File

@ -123,6 +123,9 @@
"fieldPostTitle": "標題",
"fieldPostDescription": "描述",
"fieldPostTags": "標籤",
"fieldPostCategories": "分類",
"fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "發佈",
"postPublishedAt": "發佈於",
"postPublishedUntil": "取消發佈於",
@ -180,6 +183,12 @@
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
"settingsThemeMaterial3": "使用 Material You 設計範式",
"settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。",
"settingsAppBarTransparent": "透明頂欄",
"settingsAppBarTransparentDescription": "為頂欄啓用透明效果。",
"settingsColorScheme": "主題色",
"settingsColorSchemeDescription": "設置應用主題色。",
"settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsNetwork": "網絡",
"settingsNetworkServer": "HyperNet 服務器",
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
@ -368,6 +377,8 @@
"dailyCheckNegativeHint6": "出門",
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
"happyBirthday": "生日快樂,{}",
"celebrateMerryXmas": "聖誕快樂,{}",
"celebrateNewYear": "新年快樂,{}",
"friendNew": "添加好友",
"friendRequests": "好友請求",
"friendRequestsDescription": {
@ -406,7 +417,7 @@
"accountStatus": "狀態",
"accountStatusOnline": "在線",
"accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線",
"accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"articleWrittenAt": "發表於 {}",
@ -453,5 +464,27 @@
"poweredBy": "由 {} 提供支持",
"shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "發佈動態"
"shareIntentPostStory": "發佈動態",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
"colorSchemeIndigo": "靛藍",
"colorSchemeBlue": "藍色",
"colorSchemeGreen": "綠色",
"colorSchemeYellow": "黃色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "紅色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryUncategorized": "未分類"
}

View File

@ -123,6 +123,9 @@
"fieldPostTitle": "標題",
"fieldPostDescription": "描述",
"fieldPostTags": "標籤",
"fieldPostCategories": "分類",
"fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "釋出",
"postPublishedAt": "釋出於",
"postPublishedUntil": "取消釋出於",
@ -180,6 +183,12 @@
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
"settingsThemeMaterial3": "使用 Material You 設計正規化",
"settingsThemeMaterial3Description": "將應用主題設定為 Material 3 設計正規化的主題。",
"settingsAppBarTransparent": "透明頂欄",
"settingsAppBarTransparentDescription": "為頂欄啟用透明效果。",
"settingsColorScheme": "主題色",
"settingsColorSchemeDescription": "設定應用主題色。",
"settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsNetwork": "網路",
"settingsNetworkServer": "HyperNet 伺服器",
"settingsNetworkServerDescription": "設定 HyperNet 伺服器地址,選擇我們提供的,或者自己搭建。",
@ -368,6 +377,8 @@
"dailyCheckNegativeHint6": "出門",
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
"happyBirthday": "生日快樂,{}",
"celebrateMerryXmas": "聖誕快樂,{}",
"celebrateNewYear": "新年快樂,{}",
"friendNew": "新增好友",
"friendRequests": "好友請求",
"friendRequestsDescription": {
@ -406,7 +417,7 @@
"accountStatus": "狀態",
"accountStatusOnline": "線上",
"accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線",
"accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"articleWrittenAt": "發表於 {}",
@ -453,5 +464,27 @@
"poweredBy": "由 {} 提供支援",
"shareIntent": "分享",
"shareIntentDescription": "您想對您分享的內容做些什麼?",
"shareIntentPostStory": "釋出動態"
"shareIntentPostStory": "釋出動態",
"updateAvailable": "檢測到更新可用",
"updateOngoing": "正在更新,請稍後……",
"custom": "自定義",
"colorSchemeIndigo": "靛藍",
"colorSchemeBlue": "藍色",
"colorSchemeGreen": "綠色",
"colorSchemeYellow": "黃色",
"colorSchemeOrange": "橙色",
"colorSchemeRed": "紅色",
"colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryUncategorized": "未分類"
}

View File

@ -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();
}
}

View File

@ -9,6 +9,10 @@ const kRtkStoreKey = 'nex_user_rtk';
const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
const kNetworkServerStoreKey = 'app_server_url';
const kAppbarTransparentStoreKey = 'app_bar_transparent';
const kAppBackgroundStoreKey = 'app_has_background';
const kAppColorSchemeStoreKey = 'app_color_scheme';
const Map<String, FilterQuality> kImageQualityLevel = {
'settingsImageQualityLowest': FilterQuality.none,
'settingsImageQualityLow': FilterQuality.low,

View File

@ -0,0 +1,41 @@
import 'package:intl/intl.dart';
const List<int> kExperienceToLevelRequirements = [
0, // Level 0
1000, // Level 1
4000, // Level 2
9000, // Level 3
16000, // Level 4
25000, // Level 5
36000, // Level 6
49000, // Level 7
64000, // Level 8
81000, // Level 9
100000, // Level 10
121000, // Level 11
144000, // Level 12
368000 // Level 13
];
int getLevelFromExp(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
return idx;
}
double calcLevelUpProgress(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 1;
final nextExp = kExperienceToLevelRequirements[idx + 1];
return (experience - exp).abs() / (exp - nextExp).abs();
}
String calcLevelUpProgressLevel(int experience) {
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
final idx = kExperienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= kExperienceToLevelRequirements.length) return 'Infinity';
final nextExp = exp - kExperienceToLevelRequirements[idx + 1];
final formatter = NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
}

View File

@ -83,12 +83,16 @@ class SnPostContentProvider {
int offset = 0,
String? type,
String? author,
Iterable<String>? categories,
Iterable<String>? tags,
}) async {
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
'take': take,
'offset': offset,
if (type != null) 'type': type,
if (author != null) 'author': author,
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)) ?? []),
@ -118,12 +122,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)) ?? []),

View File

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:surface/theme.dart';
@ -11,8 +13,8 @@ class ThemeProvider extends ChangeNotifier {
});
}
void reloadTheme({bool? useMaterial3}) {
createAppThemeSet().then((value) {
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
theme = value;
notifyListeners();
});

View File

@ -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';

View File

@ -77,8 +77,11 @@ final _appRoutes = [
GoRoute(
path: '/search',
name: 'postSearch',
builder: (context, state) => const AppBackground(
child: PostSearchScreen(),
builder: (context, state) => AppBackground(
child: PostSearchScreen(
initialTags: state.uri.queryParameters['tags']?.split(','),
initialCategories: state.uri.queryParameters['categories']?.split(','),
),
),
),
GoRoute(

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
@ -9,10 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/experience.dart';
import 'package:surface/providers/relationship.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/screens/abuse_report.dart';
import 'package:surface/types/account.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
@ -61,6 +64,19 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
}
}
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
return List.from(
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
}
}
SnAccountStatusInfo? _status;
Future<void> _fetchStatus() async {
@ -228,65 +244,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(
@ -430,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.calendar_add_on),
const Gap(8),
@ -437,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.cake),
const Gap(8),
@ -450,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.identity_platform),
const Gap(8),
@ -459,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
).opacity(0.8),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.star),
const Gap(8),
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
const Gap(8),
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
const Gap(8),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 160),
child: LinearProgressIndicator(
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
borderRadius: BorderRadius.circular(8),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
).alignment(Alignment.centerLeft),
),
],
),
],
).padding(horizontal: 8),
],
@ -466,6 +512,27 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter(
child: FutureBuilder<List<SnCheckInRecord>>(
future: _getCheckInRecords(),
builder: (context, snapshot) {
if (!snapshot.hasData) return const SizedBox.shrink();
final records = snapshot.data!;
return SizedBox(
width: double.infinity,
height: 240,
child: CheckInRecordChart(records: records),
).padding(
right: 24,
left: 16,
top: 12,
);
},
),
),
const SliverGap(12),
SliverToBoxAdapter(child: const Divider()),
const SliverGap(12),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -534,3 +601,105 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
);
}
}
class CheckInRecordChart extends StatelessWidget {
const CheckInRecordChart({
super.key,
required this.records,
});
final List<SnCheckInRecord> records;
@override
Widget build(BuildContext context) {
return LineChart(
LineChartData(
lineBarsData: [
LineChartBarData(
color: Theme.of(context).colorScheme.primary,
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: List.filled(
records.length,
Theme.of(context).colorScheme.primary.withOpacity(0.3),
).toList(),
),
),
spots: records
.map(
(x) => FlSpot(
x.createdAt
.copyWith(
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
microsecond: 0,
)
.millisecondsSinceEpoch
.toDouble(),
x.resultTier.toDouble(),
),
)
.toList(),
)
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots
.map(
(spot) => LineTooltipItem(
'${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
.toList(),
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: 1,
getTitlesWidget: (value, _) => Align(
alignment: Alignment.centerRight,
child: Text(
kCheckInResultTierSymbols[value.toInt()],
textAlign: TextAlign.right,
).padding(right: 8),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 86400000,
getTitlesWidget: (value, _) => Text(
DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch(
value.toInt(),
),
),
textAlign: TextAlign.center,
).padding(top: 8),
),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
),
);
}
}

View File

@ -5,12 +5,27 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
const Map<String, IconData> kCategoryIcons = {
'technology': Symbols.tools_wrench,
'gaming': Symbols.gamepad,
'life': Symbols.nightlife,
'arts': Symbols.format_paint,
'sports': Symbols.sports_soccer,
'music': Symbols.music_note,
'news': Symbols.newspaper,
'knowledge': Symbols.book,
'literature': Symbols.book,
};
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@ -24,15 +39,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true);
final List<SnPostCategory> _categories = List.empty(growable: true);
int? _postCount;
String? _selectedCategory;
Future<void> _fetchCategories() async {
_categories.clear();
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/categories?take=100');
_categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
}
}
Future<void> _fetchPosts() async {
if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true);
final pt = context.read<SnPostContentProvider>();
final result = await pt.listPosts(take: 10, offset: _posts.length);
final result = await pt.listPosts(
take: 10,
offset: _posts.length,
categories: _selectedCategory != null ? [_selectedCategory!] : null,
);
final out = result.$1;
if (!mounted) return;
@ -43,10 +77,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
if (mounted) setState(() => _isBusy = false);
}
Future<void> _refreshPosts() {
_postCount = null;
_posts.clear();
return _fetchPosts();
}
@override
void initState() {
super.initState();
_fetchPosts();
_fetchCategories();
}
@override
@ -59,27 +100,20 @@ class _ExploreScreenState extends State<ExploreScreen> {
type: ExpandableFabType.up,
childrenAnimation: ExpandableFabAnimation.none,
overlayStyle: ExpandableFabOverlayStyle(
color: Theme.of(context)
.colorScheme
.surface
.withAlpha((255 * 0.5).round()),
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
),
openButtonBuilder: RotateFloatingActionButtonBuilder(
child: const Icon(Symbols.add, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor:
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
child: const Icon(Symbols.close, size: 28),
fabSize: ExpandableFabSize.regular,
foregroundColor:
Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor:
Theme.of(context).floatingActionButtonTheme.backgroundColor,
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
shape: const CircleBorder(),
),
children: [
@ -95,8 +129,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'stories',
}).then((value) {
if (value == true) {
_posts.clear();
_fetchPosts();
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
@ -117,8 +150,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
'mode': 'articles',
}).then((value) {
if (value == true) {
_posts.clear();
_fetchPosts();
_refreshPosts();
}
});
_fabKey.currentState!.toggle();
@ -131,10 +163,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
),
body: RefreshIndicator(
displacement: 40 + MediaQuery.of(context).padding.top,
onRefresh: () {
_posts.clear();
return _fetchPosts();
},
onRefresh: () => _refreshPosts(),
child: CustomScrollView(
slivers: [
SliverAppBar(
@ -151,6 +180,33 @@ class _ExploreScreenState extends State<ExploreScreen> {
),
const Gap(8),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
scrollDirection: Axis.horizontal,
itemCount: _categories.length,
itemBuilder: (context, idx) {
final ele = _categories[idx];
return StyledWidget(ChoiceChip(
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
label: Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.name,
),
selected: _selectedCategory == ele.alias,
onSelected: (value) {
_selectedCategory = value ? ele.alias : null;
_refreshPosts();
},
)).padding(horizontal: 4);
},
),
),
),
),
SliverInfiniteList(
itemCount: _posts.length,
@ -167,8 +223,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
setState(() => _posts[idx] = data);
},
onDeleted: () {
_posts.clear();
_fetchPosts();
_refreshPosts();
},
),
onTap: () {

View File

@ -17,14 +17,13 @@ import 'package:surface/providers/config.dart';
import 'package:surface/providers/post.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/widget.dart';
import 'package:surface/types/check_in.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart';
import '../providers/widget.dart';
class HomeScreenDashEntry {
final String name;
final Widget child;
@ -212,7 +211,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.get('/cgi/id/check-in/today');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} finally {
setState(() => _isBusy = false);
}
@ -225,7 +224,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
final home = context.read<HomeWidgetProvider>();
final resp = await sn.client.post('/cgi/id/check-in');
_todayRecord = SnCheckInRecord.fromJson(resp.data);
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);

View File

@ -13,7 +13,10 @@ import 'package:surface/widgets/post/post_tags_field.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostSearchScreen extends StatefulWidget {
const PostSearchScreen({super.key});
final Iterable<String>? initialTags;
final Iterable<String>? initialCategories;
const PostSearchScreen({super.key, this.initialTags, this.initialCategories});
@override
State<PostSearchScreen> createState() => _PostSearchScreenState();
@ -23,6 +26,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;
@ -30,8 +34,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
String _searchTerm = '';
Duration? _lastTook;
@override
void initState() {
super.initState();
_searchTags.addAll(widget.initialTags ?? []);
_searchCategories.addAll(widget.initialCategories ?? []);
if (_searchTags.isNotEmpty || _searchCategories.isNotEmpty) {
_fetchPosts();
}
}
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 +59,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 +88,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

View File

@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
Future<void> _fetchPublisher() async {
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
if (!mounted) return;
_publisher = SnPublisher.fromJson(resp.data);
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err).then((_) {
@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
} finally {
setState(() {});
}
try {
final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
_account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (_) {
// ignore
}
}
bool _isSubscribing = false;
@ -277,70 +283,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(

View File

@ -5,6 +5,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -18,6 +19,17 @@ import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart';
const Map<String, Color> kColorSchemes = {
'colorSchemeIndigo': Colors.indigo,
'colorSchemeBlue': Colors.blue,
'colorSchemeGreen': Colors.green,
'colorSchemeYellow': Colors.yellow,
'colorSchemeOrange': Colors.orange,
'colorSchemeRed': Colors.red,
'colorSchemeWhite': Colors.white,
'colorSchemeBlack': Colors.black,
};
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -77,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (image == null) return;
await File(image.path).copy('$_docBasepath/app_background_image');
_prefs.setBool('has_background_image', true);
_prefs.setBool(kAppBackgroundStoreKey, true);
setState(() {});
},
@ -98,7 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Symbols.chevron_right),
onTap: () {
File('$_docBasepath/app_background_image').deleteSync();
_prefs.remove('has_background_image');
_prefs.remove(kAppBackgroundStoreKey);
setState(() {});
},
);
@ -116,10 +128,118 @@ class _SettingsScreenState extends State<SettingsScreen> {
value ?? false,
);
});
final th = context.watch<ThemeProvider>();
final th = context.read<ThemeProvider>();
th.reloadTheme(useMaterial3: value ?? false);
},
),
ListTile(
leading: const Icon(Symbols.format_paint),
title: Text('settingsColorScheme').tr(),
subtitle: Text('settingsColorSchemeDescription').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
final color = await showDialog<Color?>(
context: context,
builder: (context) => AlertDialog(
content: SingleChildScrollView(
child: ColorPicker(
pickerColor: pickerColor,
onColorChanged: (color) => pickerColor = color,
enableAlpha: false,
hexInputBar: true,
),
),
actions: <Widget>[
TextButton(
child: const Text('dialogDismiss').tr(),
onPressed: () {
Navigator.of(context).pop();
},
),
TextButton(
child: const Text('dialogConfirm').tr(),
onPressed: () {
Navigator.of(context).pop(pickerColor);
},
),
],
),
);
if (color == null || !context.mounted) return;
_prefs.setInt(kAppColorSchemeStoreKey, color.value);
final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: color);
setState(() {});
context.showSnackbar('colorSchemeApplied'.tr());
},
),
ListTile(
leading: const Icon(Symbols.palette),
title: Text('settingsColorSeed').tr(),
subtitle: Text('settingsColorSeedDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int?>(
isExpanded: true,
items: [
...kColorSchemes.entries.mapIndexed((idx, ele) {
return DropdownMenuItem<int>(
value: idx,
child: Text(ele.key).tr(),
);
}),
DropdownMenuItem<int>(
value: -1,
child: Text('custom').tr(),
),
],
value: _prefs.getInt(kAppColorSchemeStoreKey) == null
? 1
: kColorSchemes.values
.toList()
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
onChanged: (int? value) {
if (value != null && value != -1) {
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value);
final th = context.read<ThemeProvider>();
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
setState(() {});
context.showSnackbar('colorSchemeApplied'.tr());
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 5,
),
height: 40,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
CheckboxListTile(
secondary: const Icon(Symbols.blur_on),
title: Text('settingsAppBarTransparent').tr(),
subtitle: Text('settingsAppBarTransparentDescription').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
value: _prefs.getBool(kAppbarTransparentStoreKey) ?? false,
onChanged: (value) {
_prefs.setBool(kAppbarTransparentStoreKey, value ?? false);
final th = context.read<ThemeProvider>();
th.reloadTheme();
setState(() {});
},
),
],
),
Column(
@ -189,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
horizontal: 16,
vertical: 5,
),
height: 40,
height: 56,
width: 160,
),
menuItemStyleData: const MenuItemStyleData(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/config.dart';
const kMaterialYouToggleStoreKey = 'app_theme_material_you';
@ -10,7 +11,7 @@ class ThemeSet {
ThemeSet({required this.light, required this.dark});
}
Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async {
Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
return ThemeSet(
light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
@ -19,16 +20,21 @@ Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async {
Future<ThemeData> createAppTheme(
Brightness brightness, {
Color? seedColorOverride,
bool? useMaterial3,
}) async {
final prefs = await SharedPreferences.getInstance();
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
final colorScheme = ColorScheme.fromSeed(
seedColor: Colors.indigo,
seedColor: seedColorOverride ?? seedColor,
brightness: brightness,
);
final hasBackground = prefs.getBool('has_background_image') ?? false;
final hasBackground = prefs.getBool(kAppBackgroundStoreKey) ?? false;
final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
return ThemeData(
useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
@ -42,8 +48,9 @@ Future<ThemeData> createAppTheme(
),
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: hasBackground ? colorScheme.primary.withOpacity(0.75) : colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
elevation: hasAppBarBlurry ? 0 : null,
backgroundColor: hasAppBarBlurry ? colorScheme.surfaceContainer.withAlpha(200) : colorScheme.primary,
foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary,
),
scaffoldBackgroundColor: Colors.transparent,
);

View File

@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'check_in.freezed.dart';
part 'check_in.g.dart';
const List<String> kCheckInResultTierSymbols = ['大凶', '', '中平', '', '大吉'];
@freezed
class SnCheckInRecord with _$SnCheckInRecord {
const SnCheckInRecord._();
@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord {
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
_$SnCheckInRecordFromJson(json);
String get symbol => switch (resultTier) {
0 => '大凶',
1 => '',
2 => '中平',
3 => '',
_ => '大吉',
};
String get symbol => kCheckInResultTierSymbols[resultTier];
}

View File

@ -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({

View File

@ -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);
}

View File

@ -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

View File

@ -142,7 +142,7 @@ class ChatMessage extends StatelessWidget {
onEdit: onEdit,
onDelete: onDelete,
),
)).padding(bottom: 4, top: isMerged ? 4 : 2),
)).padding(bottom: 4, top: 4),
switch (data.type) {
'messages.new' => _ChatMessageText(data: data),
_ => _ChatMessageSystemNotify(data: data),

View File

@ -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,
@ -245,7 +245,7 @@ class PostItem extends StatelessWidget {
horizontal: 16,
vertical: 4,
),
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, top: 4, bottom: 6),
],
),
),
@ -966,23 +966,69 @@ 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: () {
GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'categories': ele.alias,
},
);
},
),
).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: () {
GoRouter.of(context).pushNamed(
'postSearch',
queryParameters: {
'tags': ele.alias,
},
);
},
),
)
.toList(),
).opacity(0.8),
],
);
}
}
@ -1023,6 +1069,7 @@ class _PostTruncatedHint extends StatelessWidget {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: [
if (data.body['content_length'] != null)
Row(
@ -1035,7 +1082,7 @@ class _PostTruncatedHint extends StatelessWidget {
).inSeconds}s',
]),
],
).padding(right: 8),
),
if (data.body['content_length'] != null)
Row(
children: [

View File

@ -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),
);
},
);
}

View File

@ -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) {

View File

@ -614,6 +614,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864
url: "https://pub.dev"
source: hosted
version: "0.70.0"
flutter:
dependency: "direct main"
description: flutter
@ -643,6 +651,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_colorpicker:
dependency: "direct main"
description:
name: flutter_colorpicker
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_context_menu:
dependency: "direct main"
description:
@ -2137,4 +2153,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.24.0"
flutter: ">=3.27.0"

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2.1.1+37
version: 2.1.1+38
environment:
sdk: ^3.5.4
@ -111,6 +111,8 @@ dependencies:
flutter_app_update: ^3.2.2
in_app_review: ^2.0.10
version: ^3.0.2
flutter_colorpicker: ^1.1.0
fl_chart: ^0.70.0
dev_dependencies:
flutter_test: