User experience & level

This commit is contained in:
LittleSheep 2024-09-08 12:32:21 +08:00
parent 1fd042bcae
commit d8b2c7f81e
10 changed files with 187 additions and 17 deletions

View File

@ -0,0 +1,51 @@
import 'package:get/get.dart';
import 'package:intl/intl.dart';
class ExperienceProvider extends GetxController {
static List<int> experienceToLevelRequirements = [
0, // Level 0
100, // Level 1
400, // Level 2
900, // Level 3
1600, // Level 4
2500, // Level 5
3600, // Level 6
4900, // Level 7
6400, // Level 8
8100, // Level 9
10000, // Level 10
12100, // Level 11
14400, // Level 12
36800 // Level 13
];
static List<String> levelLabelMapping =
List.generate(experienceToLevelRequirements.length, (x) => 'userLevel$x');
static (int level, String label) getLevelFromExp(int experience) {
final exp = experienceToLevelRequirements.reversed
.firstWhere((x) => x <= experience);
final idx = experienceToLevelRequirements.indexOf(exp);
return (idx, levelLabelMapping[idx]);
}
static double calcLevelUpProgress(int experience) {
final exp = experienceToLevelRequirements.reversed
.firstWhere((x) => x <= experience);
final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 1;
final nextExp = experienceToLevelRequirements[idx + 1];
return exp / nextExp;
}
static String calcLevelUpProgressLevel(int experience) {
final exp = experienceToLevelRequirements.reversed
.firstWhere((x) => x <= experience);
final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 'Infinity';
final nextExp = experienceToLevelRequirements[idx + 1];
final formatter =
NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format(exp)}/${formatter.format(nextExp)}';
}
}

View File

@ -8,10 +8,12 @@ import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/attachments/attachment_list.dart'; import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
@ -143,7 +145,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 2, length: 3,
child: NestedScrollView( child: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
@ -211,6 +213,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
bottom: TabBar( bottom: TabBar(
tabs: [ tabs: [
Tab(text: 'profilePage'.tr),
Tab(text: 'profilePosts'.tr), Tab(text: 'profilePosts'.tr),
Tab(text: 'profileAlbum'.tr), Tab(text: 'profileAlbum'.tr),
], ],
@ -221,6 +224,24 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
body: TabBarView( body: TabBarView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
Column(
children: [
const Gap(16),
AccountHeadingWidget(
name: _userinfo!.name,
nick: _userinfo!.nick,
desc: _userinfo!.description,
badges: _userinfo!.badges,
banner: _userinfo!.banner,
avatar: _userinfo!.avatar,
status: Get.find<StatusProvider>()
.getSomeoneStatus(_userinfo!.name),
detail: _userinfo,
profile: _userinfo!.profile,
extraWidgets: const [],
),
],
),
RefreshIndicator( RefreshIndicator(
onRefresh: () => Future.wait([ onRefresh: () => Future.wait([
_postController.reloadAllOver(), _postController.reloadAllOver(),

View File

@ -136,10 +136,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'today'.tr, DateTime.now().day == DateTime.now().toUtc().day
? 'today'.tr
: 'yesterday'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
Text(DateFormat('yyyy/MM/dd').format(DateTime.now())), Text(DateFormat('yyyy/MM/dd').format(DateTime.now().toUtc())),
], ],
).paddingOnly(top: 8, left: 18, right: 18, bottom: 12), ).paddingOnly(top: 8, left: 18, right: 18, bottom: 12),
Card( Card(
@ -502,16 +504,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
), ),
Text( Text(
'dashboardFooter'.tr, 'dashboardFooter'.tr,
style: [const Locale('zh', 'CN'), const Locale('zh', 'HK')] style: TextStyle(color: _unFocusColor, fontSize: 12),
.contains(Get.deviceLocale)
? GoogleFonts.notoSerifHk(
color: _unFocusColor,
fontSize: 12,
)
: TextStyle(
color: _unFocusColor,
fontSize: 12,
),
) )
], ],
).paddingAll(8), ).paddingAll(8),

View File

@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/current_state_action.dart';
class TitleShell extends StatelessWidget { class TitleShell extends StatelessWidget {
final bool showAppBar; final bool showAppBar;
@ -32,6 +33,12 @@ class TitleShell extends StatelessWidget {
), ),
centerTitle: isCenteredTitle, centerTitle: isCenteredTitle,
toolbarHeight: SolianTheme.toolbarHeight(context), toolbarHeight: SolianTheme.toolbarHeight(context),
actions: [
const BackgroundStateWidget(),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
) )
: null, : null,
body: child, body: child,

View File

@ -10,6 +10,7 @@ const i18nEnglish = {
'draft': 'Draft', 'draft': 'Draft',
'dashboard': 'Dashboard', 'dashboard': 'Dashboard',
'today': 'Today', 'today': 'Today',
'yesterday': 'Yesterday',
'draftSave': 'Save', 'draftSave': 'Save',
'draftBox': 'Draft Box', 'draftBox': 'Draft Box',
'more': 'More', 'more': 'More',
@ -33,6 +34,7 @@ const i18nEnglish = {
'dailySignTier4': 'Everything will be awesome', 'dailySignTier4': 'Everything will be awesome',
'dashboardFooter': 'Don\'t be serious, just for fun.', 'dashboardFooter': 'Don\'t be serious, just for fun.',
'visitProfilePage': 'Visit Profile Page', 'visitProfilePage': 'Visit Profile Page',
'profilePage': 'Page',
'profilePosts': 'Posts', 'profilePosts': 'Posts',
'profileAlbum': 'Album', 'profileAlbum': 'Album',
'chat': 'Chat', 'chat': 'Chat',
@ -400,4 +402,18 @@ const i18nEnglish = {
'collapse': 'Collapse', 'collapse': 'Collapse',
'expand': 'Expand', 'expand': 'Expand',
'typingMessage': '@user are typing...', 'typingMessage': '@user are typing...',
'userLevel0': 'Newbie',
'userLevel1': 'Novice',
'userLevel2': 'Apprentice',
'userLevel3': 'Explorer',
'userLevel4': 'Adventurer',
'userLevel5': 'Warrior',
'userLevel6': 'Knight',
'userLevel7': 'Champion',
'userLevel8': 'Hero',
'userLevel9': 'Master',
'userLevel10': 'Grandmaster',
'userLevel11': 'Legend',
'userLevel12': 'Mythic',
'userLevel13': 'Immortal',
}; };

View File

@ -26,6 +26,7 @@ const i18nSimplifiedChinese = {
'unlink': '移除链接', 'unlink': '移除链接',
'dashboard': '仪表盘', 'dashboard': '仪表盘',
'today': '今日', 'today': '今日',
'yesterday': '昨日',
'feedSearch': '搜索资讯', 'feedSearch': '搜索资讯',
'feedSearchWithTag': '检索带有 #@key 标签的资讯', 'feedSearchWithTag': '检索带有 #@key 标签的资讯',
'feedSearchWithCategory': '检索位于分类 @category 的资讯', 'feedSearchWithCategory': '检索位于分类 @category 的资讯',
@ -41,6 +42,7 @@ const i18nSimplifiedChinese = {
'dailySignTier4': '诸事皆宜', 'dailySignTier4': '诸事皆宜',
'dashboardFooter': '占卜多少沾点玩,人生还得靠实力', 'dashboardFooter': '占卜多少沾点玩,人生还得靠实力',
'visitProfilePage': '造访个人主页', 'visitProfilePage': '造访个人主页',
'profilePage': '主页',
'profilePosts': '帖子', 'profilePosts': '帖子',
'profileAlbum': '相簿', 'profileAlbum': '相簿',
'chat': '聊天', 'chat': '聊天',
@ -370,4 +372,18 @@ const i18nSimplifiedChinese = {
'collapse': '折叠', 'collapse': '折叠',
'expand': '展开', 'expand': '展开',
'typingMessage': '@user 正在输入中…', 'typingMessage': '@user 正在输入中…',
'userLevel0': '不慕名利',
'userLevel1': '初出茅庐',
'userLevel2': '小试牛刀',
'userLevel3': '磨杵成针',
'userLevel4': '披荆斩棘',
'userLevel5': '力挽狂澜',
'userLevel6': '一骑当千',
'userLevel7': '所向披靡',
'userLevel8': '气吞山河',
'userLevel9': '峰造极',
'userLevel10': '出神入化',
'userLevel11': '名垂千古',
'userLevel12': '独占鳌头',
'userLevel13': '万古流芳',
}; };

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/account_status.dart'; import 'package:solian/models/account_status.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/experience.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_badge.dart'; import 'package:solian/widgets/account/account_badge.dart';
import 'package:solian/widgets/account/account_status_action.dart'; import 'package:solian/widgets/account/account_status_action.dart';
@ -18,6 +20,7 @@ class AccountHeadingWidget extends StatelessWidget {
final String nick; final String nick;
final String? desc; final String? desc;
final Account? detail; final Account? detail;
final AccountProfile? profile;
final List<AccountBadge>? badges; final List<AccountBadge>? badges;
final List<Widget>? extraWidgets; final List<Widget>? extraWidgets;
@ -33,6 +36,7 @@ class AccountHeadingWidget extends StatelessWidget {
required this.desc, required this.desc,
required this.badges, required this.badges,
this.detail, this.detail,
this.profile,
this.status, this.status,
this.extraWidgets, this.extraWidgets,
this.onEditStatus, this.onEditStatus,
@ -130,7 +134,10 @@ class AccountHeadingWidget extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text(info.$3), Text(
info.$3,
style: const TextStyle(height: 1),
).paddingOnly(bottom: 3),
if (!status.isOnline && status.lastSeenAt != null) if (!status.isOnline && status.lastSeenAt != null)
Opacity( Opacity(
opacity: 0.75, opacity: 0.75,
@ -182,6 +189,63 @@ class AccountHeadingWidget extends StatelessWidget {
), ),
), ),
).paddingSymmetric(horizontal: 16), ).paddingSymmetric(horizontal: 16),
if (profile != null)
Card(
child: ListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
visualDensity:
const VisualDensity(horizontal: -4, vertical: -2),
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
ExperienceProvider.getLevelFromExp(
profile!.experience ?? 0,
).$2.tr,
),
const Gap(4),
Badge(
label: Text(
'Lv${ExperienceProvider.getLevelFromExp(
profile!.experience ?? 0,
).$1}',
style: GoogleFonts.dosis(
fontWeight: FontWeight.bold,
),
),
).paddingOnly(top: 1),
],
),
subtitle: SizedBox(
height: 20,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: LinearProgressIndicator(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
value: ExperienceProvider.calcLevelUpProgress(
profile!.experience ?? 0,
),
),
),
const Gap(8),
Text(
'${ExperienceProvider.calcLevelUpProgressLevel(profile!.experience ?? 0)} EXP',
style: GoogleFonts.robotoMono(
fontSize: 10,
height: 0.5,
),
),
],
),
),
),
).paddingSymmetric(horizontal: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: Card( child: Card(

View File

@ -99,6 +99,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
nick: _userinfo!.nick, nick: _userinfo!.nick,
desc: _userinfo!.description, desc: _userinfo!.description,
detail: _userinfo!, detail: _userinfo!,
profile: _userinfo!.profile,
badges: _userinfo!.badges, badges: _userinfo!.badges,
status: status:
Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name), Get.find<StatusProvider>().getSomeoneStatus(_userinfo!.name),

View File

@ -347,8 +347,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
FutureBuilder( FutureBuilder(
future: element.file.length(), future: element.file.length(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData) if (!snapshot.hasData) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
return Text( return Text(
_formatBytes(snapshot.data!), _formatBytes(snapshot.data!),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,

View File

@ -131,7 +131,7 @@ class DailySignHistoryChartDialog extends StatelessWidget {
reservedSize: 28, reservedSize: 28,
interval: 86400000, interval: 86400000,
getTitlesWidget: (value, _) => Text( getTitlesWidget: (value, _) => Text(
DateFormat('MM/dd').format( DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
value.toInt(), value.toInt(),
), ),
@ -231,7 +231,7 @@ class DailySignHistoryChartDialog extends StatelessWidget {
reservedSize: 28, reservedSize: 28,
interval: 86400000, interval: 86400000,
getTitlesWidget: (value, _) => Text( getTitlesWidget: (value, _) => Text(
DateFormat('MM/dd').format( DateFormat('dd').format(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
value.toInt(), value.toInt(),
), ),