From 6e03a0028017459851dd91ad06ef2b4e853481b3 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sun, 2 Mar 2025 21:08:38 +0800
Subject: [PATCH] :sparkles: Wearable badge

---
 assets/translations/en-US.json        |   6 +-
 assets/translations/zh-CN.json        |   8 +-
 assets/translations/zh-HK.json        |   8 +-
 assets/translations/zh-TW.json        |   8 +-
 lib/router.dart                       | 114 +++++++++++-----------
 lib/screens/account.dart              |  10 ++
 lib/screens/account/badges.dart       | 135 ++++++++++++++++++++++++++
 lib/screens/account/profile_page.dart | 112 ++++++++-------------
 lib/theme.dart                        |  17 ++++
 lib/types/account.dart                |   1 +
 lib/types/account.freezed.dart        |  27 +++++-
 lib/types/account.g.dart              |   2 +
 12 files changed, 312 insertions(+), 136 deletions(-)
 create mode 100644 lib/screens/account/badges.dart

diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json
index 3fac7ef..11f4a20 100644
--- a/assets/translations/en-US.json
+++ b/assets/translations/en-US.json
@@ -743,5 +743,9 @@
   "fieldLocation": "Location",
   "fieldLinks": "Links",
   "fieldLinkName": "Name",
-  "fieldLinkUrl": "URL"
+  "fieldLinkUrl": "URL",
+  "screenAccountBadges": "Badges",
+  "accountBadges": "Badges",
+  "accountBadgesDescription": "View and manage your badges.",
+  "badgeActivated": "Activated badge {}."
 }
diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json
index 9ec3b9b..1287c50 100644
--- a/assets/translations/zh-CN.json
+++ b/assets/translations/zh-CN.json
@@ -515,7 +515,7 @@
   "accountBirthday": "出生于 {}",
   "accountBadge": "徽章",
   "accountCheckInNoRecords": "暂无运势记录",
-  "badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
+  "badgeCompanyStaff": "工作人员",
   "badgeSiteMigration": "Solar Network 原住民",
   "accountStatus": "状态",
   "accountStatusOnline": "在线",
@@ -741,5 +741,9 @@
   "fieldLocation": "位置",
   "fieldLinks": "链接",
   "fieldLinkName": "名称",
-  "fieldLinkUrl": "链接"
+  "fieldLinkUrl": "链接",
+  "screenAccountBadges": "徽章",
+  "accountBadges": "徽章",
+  "accountBadgesDescription": "查看并管理你的徽章。",
+  "badgeActivated": "已佩戴徽章 {}。"
 }
diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json
index 2ca7774..aab7e8d 100644
--- a/assets/translations/zh-HK.json
+++ b/assets/translations/zh-HK.json
@@ -515,7 +515,7 @@
   "accountBirthday": "出生於 {}",
   "accountBadge": "徽章",
   "accountCheckInNoRecords": "暫無運勢記錄",
-  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
+  "badgeCompanyStaff": "工作人員",
   "badgeSiteMigration": "Solar Network 原住民",
   "accountStatus": "狀態",
   "accountStatusOnline": "在線",
@@ -741,5 +741,9 @@
   "fieldLocation": "位置",
   "fieldLinks": "鏈接",
   "fieldLinkName": "名稱",
-  "fieldLinkUrl": "鏈接"
+  "fieldLinkUrl": "鏈接",
+  "screenAccountBadges": "徽章",
+  "accountBadges": "徽章",
+  "accountBadgesDescription": "查看並管理你的徽章。",
+  "badgeActivated": "已佩戴徽章 {}。"
 }
diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json
index 9960502..8f52c6e 100644
--- a/assets/translations/zh-TW.json
+++ b/assets/translations/zh-TW.json
@@ -515,7 +515,7 @@
   "accountBirthday": "出生於 {}",
   "accountBadge": "徽章",
   "accountCheckInNoRecords": "暫無運勢記錄",
-  "badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
+  "badgeCompanyStaff": "工作人員",
   "badgeSiteMigration": "Solar Network 原住民",
   "accountStatus": "狀態",
   "accountStatusOnline": "在線",
@@ -741,5 +741,9 @@
   "fieldLocation": "位置",
   "fieldLinks": "鏈接",
   "fieldLinkName": "名稱",
-  "fieldLinkUrl": "鏈接"
+  "fieldLinkUrl": "鏈接",
+  "screenAccountBadges": "徽章",
+  "accountBadges": "徽章",
+  "accountBadgesDescription": "查看並管理你的徽章。",
+  "badgeActivated": "已佩戴徽章 {}。"
 }
diff --git a/lib/router.dart b/lib/router.dart
index 87cf943..b3abde9 100644
--- a/lib/router.dart
+++ b/lib/router.dart
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
 import 'package:surface/screens/abuse_report.dart';
 import 'package:surface/screens/account.dart';
 import 'package:surface/screens/account/account_settings.dart';
+import 'package:surface/screens/account/badges.dart';
 import 'package:surface/screens/account/factor_settings.dart';
 import 'package:surface/screens/account/profile_page.dart';
 import 'package:surface/screens/account/profile_edit.dart';
@@ -42,8 +43,8 @@ import 'package:surface/types/post.dart';
 import 'package:surface/widgets/about.dart';
 import 'package:surface/widgets/navigation/app_scaffold.dart';
 
-Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
-    Animation<double> secondaryAnimation, Widget child) {
+Widget _fadeThroughTransition(
+    BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
   return FadeThroughTransition(
     animation: animation,
     secondaryAnimation: secondaryAnimation,
@@ -85,15 +86,13 @@ final _appRoutes = [
         name: 'postSearch',
         builder: (context, state) => PostSearchScreen(
           initialTags: state.uri.queryParameters['tags']?.split(','),
-          initialCategories:
-              state.uri.queryParameters['categories']?.split(','),
+          initialCategories: state.uri.queryParameters['categories']?.split(','),
         ),
       ),
       GoRoute(
         path: '/publishers/:name',
         name: 'postPublisher',
-        builder: (context, state) =>
-            PostPublisherScreen(name: state.pathParameters['name']!),
+        builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
       ),
       GoRoute(
         path: '/:slug',
@@ -106,55 +105,61 @@ final _appRoutes = [
     ],
   ),
   GoRoute(
-      path: '/account',
-      name: 'account',
-      builder: (context, state) => const AccountScreen(),
-      routes: [
-        GoRoute(
-          path: '/wallet',
-          name: 'accountWallet',
-          builder: (context, state) => const WalletScreen(),
+    path: '/account',
+    name: 'account',
+    builder: (context, state) => const AccountScreen(),
+    routes: [
+      GoRoute(
+        path: '/badges',
+        name: 'accountBadges',
+        builder: (context, state) => const AccountBadgesScreen(),
+      ),
+      GoRoute(
+        path: '/wallet',
+        name: 'accountWallet',
+        builder: (context, state) => const WalletScreen(),
+      ),
+      GoRoute(
+        path: '/settings',
+        name: 'accountSettings',
+        builder: (context, state) => AccountSettingsScreen(),
+      ),
+      GoRoute(
+        path: '/settings/factors',
+        name: 'factorSettings',
+        builder: (context, state) => FactorSettingsScreen(),
+      ),
+      GoRoute(
+        path: '/profile/edit',
+        name: 'accountProfileEdit',
+        builder: (context, state) => ProfileEditScreen(),
+      ),
+      GoRoute(
+        path: '/publishers',
+        name: 'accountPublishers',
+        builder: (context, state) => PublisherScreen(),
+      ),
+      GoRoute(
+        path: '/publishers/new',
+        name: 'accountPublisherNew',
+        builder: (context, state) => AccountPublisherNewScreen(),
+      ),
+      GoRoute(
+        path: '/publishers/edit/:name',
+        name: 'accountPublisherEdit',
+        builder: (context, state) => AccountPublisherEditScreen(
+          name: state.pathParameters['name']!,
         ),
-        GoRoute(
-          path: '/settings',
-          name: 'accountSettings',
-          builder: (context, state) => AccountSettingsScreen(),
+      ),
+      GoRoute(
+        path: '/:name',
+        name: 'accountProfilePage',
+        pageBuilder: (context, state) => NoTransitionPage(
+          child: UserScreen(name: state.pathParameters['name']!),
         ),
-        GoRoute(
-          path: '/settings/factors',
-          name: 'factorSettings',
-          builder: (context, state) => FactorSettingsScreen(),
-        ),
-        GoRoute(
-          path: '/profile/edit',
-          name: 'accountProfileEdit',
-          builder: (context, state) => ProfileEditScreen(),
-        ),
-        GoRoute(
-          path: '/publishers',
-          name: 'accountPublishers',
-          builder: (context, state) => PublisherScreen(),
-        ),
-        GoRoute(
-          path: '/publishers/new',
-          name: 'accountPublisherNew',
-          builder: (context, state) => AccountPublisherNewScreen(),
-        ),
-        GoRoute(
-          path: '/publishers/edit/:name',
-          name: 'accountPublisherEdit',
-          builder: (context, state) => AccountPublisherEditScreen(
-            name: state.pathParameters['name']!,
-          ),
-        ),
-        GoRoute(
-          path: '/:name',
-          name: 'accountProfilePage',
-          pageBuilder: (context, state) => NoTransitionPage(
-            child: UserScreen(name: state.pathParameters['name']!),
-          ),
-        ),
-      ]),
+      ),
+    ],
+  ),
   GoRoute(
     path: '/chat',
     name: 'chat',
@@ -217,8 +222,7 @@ final _appRoutes = [
       GoRoute(
         path: '/:alias',
         name: 'realmDetail',
-        builder: (context, state) =>
-            RealmDetailScreen(alias: state.pathParameters['alias']!),
+        builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
       ),
     ],
   ),
diff --git a/lib/screens/account.dart b/lib/screens/account.dart
index 592fbb6..ff8f006 100644
--- a/lib/screens/account.dart
+++ b/lib/screens/account.dart
@@ -173,6 +173,16 @@ class _AuthorizedAccountScreen extends StatelessWidget {
             GoRouter.of(context).pushNamed('accountWallet');
           },
         ),
+        ListTile(
+          title: Text('accountBadges').tr(),
+          subtitle: Text('accountBadgesDescription').tr(),
+          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+          leading: const Icon(Symbols.award_star),
+          trailing: const Icon(Symbols.chevron_right),
+          onTap: () {
+            GoRouter.of(context).pushNamed('accountBadges');
+          },
+        ),
         ListTile(
           title: Text('accountSettings').tr(),
           subtitle: Text('accountSettingsSubtitle').tr(),
diff --git a/lib/screens/account/badges.dart b/lib/screens/account/badges.dart
new file mode 100644
index 0000000..b3df0d6
--- /dev/null
+++ b/lib/screens/account/badges.dart
@@ -0,0 +1,135 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:google_fonts/google_fonts.dart';
+import 'package:material_symbols_icons/material_symbols_icons.dart';
+import 'package:provider/provider.dart';
+import 'package:styled_widget/styled_widget.dart';
+import 'package:surface/providers/sn_network.dart';
+import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
+import 'package:surface/theme.dart';
+import 'package:surface/types/account.dart';
+import 'package:surface/widgets/dialog.dart';
+import 'package:surface/widgets/loading_indicator.dart';
+import 'package:surface/widgets/navigation/app_scaffold.dart';
+
+class AccountBadgesScreen extends StatefulWidget {
+  const AccountBadgesScreen({super.key});
+
+  @override
+  State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
+}
+
+class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
+  bool _isBusy = false;
+  List<SnAccountBadge>? _badges;
+
+  Future<void> _fetchBadges() async {
+    setState(() => _isBusy = true);
+    try {
+      final sn = context.read<SnNetworkProvider>();
+      final resp = await sn.client.get('/cgi/id/badges/me');
+      if (!mounted) return;
+      setState(
+        () => _badges = List<SnAccountBadge>.from(
+          resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
+        ),
+      );
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() => _isBusy = false);
+    }
+  }
+
+  bool _isActivating = false;
+
+  Future<void> _activateBadge(SnAccountBadge badge) async {
+    try {
+      setState(() => _isActivating = true);
+      final sn = context.read<SnNetworkProvider>();
+      await sn.client.post('/cgi/id/badges/${badge.id}/active');
+      if (!mounted) return;
+      context.showSnackbar('badgeActivated'.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
+      await _fetchBadges();
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() => _isActivating = false);
+    }
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    _fetchBadges();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AppScaffold(
+      appBar: AppBar(
+        title: Text('screenAccountBadges').tr(),
+      ),
+      body: Column(
+        children: [
+          LoadingIndicator(isActive: _isBusy),
+          if (_badges != null)
+            Expanded(
+              child: MediaQuery.removePadding(
+                context: context,
+                removeTop: true,
+                child: RefreshIndicator(
+                  onRefresh: _fetchBadges,
+                  child: ListView.builder(
+                    itemCount: _badges!.length,
+                    itemBuilder: (context, idx) {
+                      final badge = _badges![idx];
+                      return ListTile(
+                        title: Text(
+                          kBadgesMeta[badge.type]?.$1 ?? 'unknown',
+                        ).tr(),
+                        contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
+                        subtitle: Column(
+                          crossAxisAlignment: CrossAxisAlignment.start,
+                          children: [
+                            if (badge.metadata['title'] != null)
+                              Text(badge.metadata['title']).fontSize(14).bold()
+                            else
+                              Text(
+                                '#${badge.id.toString().padLeft(8, '0')}',
+                                style: GoogleFonts.robotoMono(),
+                              ).fontSize(14).bold(),
+                            Text(
+                              DateFormat('y/M/d').format(badge.createdAt),
+                            )
+                          ],
+                        ),
+                        trailing: IconButton(
+                          icon: const Icon(Symbols.check),
+                          onPressed: (badge.isActive || _isActivating)
+                              ? null
+                              : () {
+                                  _activateBadge(badge);
+                                },
+                        ),
+                        leading: Icon(
+                          kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
+                          color: badge.metadata['color'] != null
+                              ? HexColor.fromHex(badge.metadata['color']!)
+                              : kBadgesMeta[badge.type]?.$3,
+                          fill: 1,
+                        ),
+                      );
+                    },
+                  ),
+                ),
+              ),
+            ),
+        ],
+      ),
+    );
+  }
+}
diff --git a/lib/screens/account/profile_page.dart b/lib/screens/account/profile_page.dart
index 58bf2b8..5fe9de9 100644
--- a/lib/screens/account/profile_page.dart
+++ b/lib/screens/account/profile_page.dart
@@ -20,6 +20,7 @@ import 'package:surface/types/post.dart';
 import 'package:surface/widgets/account/account_image.dart';
 import 'package:surface/widgets/dialog.dart';
 import 'package:surface/widgets/universal_image.dart';
+import 'package:surface/theme.dart';
 
 const Map<String, (String, IconData, Color)> kBadgesMeta = {
   'company.staff': (
@@ -43,8 +44,7 @@ class UserScreen extends StatefulWidget {
   State<UserScreen> createState() => _UserScreenState();
 }
 
-class _UserScreenState extends State<UserScreen>
-    with SingleTickerProviderStateMixin {
+class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
   late final ScrollController _scrollController = ScrollController();
 
   SnAccount? _account;
@@ -70,8 +70,7 @@ class _UserScreenState extends State<UserScreen>
   Future<void> _getCheckInRecords() async {
     try {
       final sn = context.read<SnNetworkProvider>();
-      final resp =
-          await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
+      final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
       setState(() {
         _records = List.from(
           resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
@@ -104,8 +103,7 @@ class _UserScreenState extends State<UserScreen>
   Future<void> _fetchPublishers() async {
     try {
       final sn = context.read<SnNetworkProvider>();
-      final resp =
-          await sn.client.get('/cgi/co/publishers?user=${widget.name}');
+      final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}');
       _publishers = List<SnPublisher>.from(
         resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
       );
@@ -151,8 +149,7 @@ class _UserScreenState extends State<UserScreen>
         'related': _account!.name,
       });
       if (!mounted) return;
-      context.showSnackbar(
-          'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
+      context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
     } catch (err) {
       if (!mounted) return;
       context.showErrorDialog(err);
@@ -168,11 +165,9 @@ class _UserScreenState extends State<UserScreen>
 
     try {
       final rel = context.read<SnRelationshipProvider>();
-      await rel.updateRelationship(
-          _account!.id, 1, _accountRelationship?.permNodes ?? {});
+      await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
       if (!mounted) return;
-      context.showSnackbar(
-          'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
+      context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
     } catch (err) {
       if (!mounted) return;
       context.showErrorDialog(err);
@@ -198,14 +193,12 @@ class _UserScreenState extends State<UserScreen>
   double _appBarBlur = 0.0;
 
   late final _appBarWidth = MediaQuery.of(context).size.width;
-  late final _appBarHeight =
-      (_appBarWidth * kBannerAspectRatio).roundToDouble();
+  late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
 
   void _updateAppBarBlur() {
     if (_scrollController.offset > _appBarHeight) return;
     setState(() {
-      _appBarBlur =
-          (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
+      _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
     });
   }
 
@@ -273,20 +266,18 @@ class _UserScreenState extends State<UserScreen>
                       text: TextSpan(children: [
                         TextSpan(
                           text: _account!.nick,
-                          style:
-                              Theme.of(context).textTheme.titleLarge!.copyWith(
-                                    color: Colors.white,
-                                    shadows: labelShadows,
-                                  ),
+                          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,
-                                  ),
+                          style: Theme.of(context).textTheme.bodySmall!.copyWith(
+                                color: Colors.white,
+                                shadows: labelShadows,
+                              ),
                         ),
                       ]),
                     ),
@@ -354,8 +345,7 @@ class _UserScreenState extends State<UserScreen>
                       PopupMenuButton(
                         padding: EdgeInsets.zero,
                         style: ButtonStyle(
-                          visualDensity:
-                              VisualDensity(horizontal: -4, vertical: -4),
+                          visualDensity: VisualDensity(horizontal: -4, vertical: -4),
                         ),
                         itemBuilder: (context) => [
                           PopupMenuItem(
@@ -415,9 +405,7 @@ class _UserScreenState extends State<UserScreen>
                           Symbols.circle,
                           fill: 1,
                           size: 16,
-                          color: (_status?.isOnline ?? false)
-                              ? Colors.green
-                              : Colors.grey,
+                          color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
                         ).padding(all: 4),
                         const Gap(8),
                         Text(
@@ -427,9 +415,7 @@ class _UserScreenState extends State<UserScreen>
                                   : 'accountStatusOffline'.tr()
                               : 'loading'.tr(),
                         ),
-                        if (_status != null &&
-                            !_status!.isOnline &&
-                            _status!.lastSeenAt != null)
+                        if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
                           Text(
                             'accountStatusLastSeen'.tr(args: [
                               _status!.lastSeenAt != null
@@ -450,13 +436,12 @@ class _UserScreenState extends State<UserScreen>
                             richMessage: TextSpan(
                               children: [
                                 TextSpan(
-                                    text: kBadgesMeta[ele.type]?.$1.tr() ??
-                                        'unknown'.tr()),
+                                  text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr(),
+                                ),
                                 if (ele.metadata['title'] != null)
                                   TextSpan(
                                     text: '\n${ele.metadata['title']}',
-                                    style: const TextStyle(
-                                        fontWeight: FontWeight.bold),
+                                    style: const TextStyle(fontWeight: FontWeight.bold),
                                   ),
                                 TextSpan(text: '\n'),
                                 TextSpan(
@@ -465,9 +450,10 @@ class _UserScreenState extends State<UserScreen>
                               ],
                             ),
                             child: Icon(
-                              kBadgesMeta[ele.type]?.$2 ??
-                                  Symbols.question_mark,
-                              color: kBadgesMeta[ele.type]?.$3,
+                              kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
+                              color: ele.metadata['color'] != null
+                                  ? HexColor.fromHex(ele.metadata['color']!)
+                                  : kBadgesMeta[ele.type]?.$3,
                               fill: 1,
                             ),
                           ),
@@ -482,9 +468,7 @@ class _UserScreenState extends State<UserScreen>
                         children: [
                           const Icon(Symbols.calendar_add_on),
                           const Gap(8),
-                          Text('publisherJoinedAt').tr(args: [
-                            DateFormat('y/M/d').format(_account!.createdAt)
-                          ]),
+                          Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
                         ],
                       ),
                       Row(
@@ -517,24 +501,17 @@ class _UserScreenState extends State<UserScreen>
                         children: [
                           const Icon(Symbols.star),
                           const Gap(8),
-                          Text(
-                              'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
+                          Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
                           const Gap(8),
-                          Text(calcLevelUpProgressLevel(
-                                  _account?.profile?.experience ?? 0))
-                              .fontSize(11)
-                              .opacity(0.5),
+                          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),
+                              value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
                               borderRadius: BorderRadius.circular(8),
-                              backgroundColor: Theme.of(context)
-                                  .colorScheme
-                                  .surfaceContainer,
+                              backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
                             ).alignment(Alignment.centerLeft),
                           ),
                         ],
@@ -554,11 +531,7 @@ class _UserScreenState extends State<UserScreen>
                   return Text(
                     'accountCheckInNoRecords',
                     textAlign: TextAlign.center,
-                  )
-                      .tr()
-                      .fontWeight(FontWeight.bold)
-                      .center()
-                      .padding(horizontal: 20, vertical: 8);
+                  ).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
                 }
                 return SizedBox(
                   width: double.infinity,
@@ -579,11 +552,7 @@ class _UserScreenState extends State<UserScreen>
             child: Column(
               crossAxisAlignment: CrossAxisAlignment.start,
               children: [
-                Text('accountBadge')
-                    .bold()
-                    .fontSize(17)
-                    .tr()
-                    .padding(horizontal: 20, bottom: 4),
+                Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
                 SizedBox(
                   height: 80,
                   width: double.infinity,
@@ -597,9 +566,10 @@ class _UserScreenState extends State<UserScreen>
                           child: Card(
                             child: ListTile(
                               leading: Icon(
-                                kBadgesMeta[badge.type]?.$2 ??
-                                    Symbols.question_mark,
-                                color: kBadgesMeta[badge.type]?.$3,
+                                kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
+                                color: badge.metadata['color'] != null
+                                    ? HexColor.fromHex(badge.metadata['color']!)
+                                    : kBadgesMeta[badge.type]?.$3,
                                 fill: 1,
                               ),
                               title: Text(
@@ -608,8 +578,7 @@ class _UserScreenState extends State<UserScreen>
                               subtitle: badge.metadata['title'] != null
                                   ? Text(badge.metadata['title'])
                                   : Text(
-                                      DateFormat('y/M/d')
-                                          .format(badge.createdAt),
+                                      DateFormat('y/M/d').format(badge.createdAt),
                                     ),
                             ),
                           ),
@@ -705,8 +674,7 @@ class CheckInRecordChart extends StatelessWidget {
                   ),
                 )
                 .toList(),
-            getTooltipColor: (_) =>
-                Theme.of(context).colorScheme.surfaceContainerHigh,
+            getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
           ),
         ),
         titlesData: FlTitlesData(
diff --git a/lib/theme.dart b/lib/theme.dart
index a797887..9e91024 100644
--- a/lib/theme.dart
+++ b/lib/theme.dart
@@ -89,3 +89,20 @@ Future<ThemeData> createAppTheme(
     ),
   );
 }
+
+extension HexColor on Color {
+  /// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
+  static Color fromHex(String hexString) {
+    final buffer = StringBuffer();
+    if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
+    buffer.write(hexString.replaceFirst('#', ''));
+    return Color(int.parse(buffer.toString(), radix: 16));
+  }
+
+  /// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
+  String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
+      '${alpha.toRadixString(16).padLeft(2, '0')}'
+      '${red.toRadixString(16).padLeft(2, '0')}'
+      '${green.toRadixString(16).padLeft(2, '0')}'
+      '${blue.toRadixString(16).padLeft(2, '0')}';
+}
diff --git a/lib/types/account.dart b/lib/types/account.dart
index 39eab2c..34df848 100644
--- a/lib/types/account.dart
+++ b/lib/types/account.dart
@@ -105,6 +105,7 @@ abstract class SnAccountBadge with _$SnAccountBadge {
     required dynamic deletedAt,
     required String type,
     required int accountId,
+    @Default(false) bool isActive,
     @Default({}) Map<String, dynamic> metadata,
   }) = _SnAccountBadge;
 
diff --git a/lib/types/account.freezed.dart b/lib/types/account.freezed.dart
index 95d3642..70e57ca 100644
--- a/lib/types/account.freezed.dart
+++ b/lib/types/account.freezed.dart
@@ -1835,6 +1835,7 @@ mixin _$SnAccountBadge {
   dynamic get deletedAt;
   String get type;
   int get accountId;
+  bool get isActive;
   Map<String, dynamic> get metadata;
 
   /// Create a copy of SnAccountBadge
@@ -1862,6 +1863,8 @@ mixin _$SnAccountBadge {
             (identical(other.type, type) || other.type == type) &&
             (identical(other.accountId, accountId) ||
                 other.accountId == accountId) &&
+            (identical(other.isActive, isActive) ||
+                other.isActive == isActive) &&
             const DeepCollectionEquality().equals(other.metadata, metadata));
   }
 
@@ -1875,11 +1878,12 @@ mixin _$SnAccountBadge {
       const DeepCollectionEquality().hash(deletedAt),
       type,
       accountId,
+      isActive,
       const DeepCollectionEquality().hash(metadata));
 
   @override
   String toString() {
-    return 'SnAccountBadge(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, accountId: $accountId, metadata: $metadata)';
+    return 'SnAccountBadge(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, accountId: $accountId, isActive: $isActive, metadata: $metadata)';
   }
 }
 
@@ -1896,6 +1900,7 @@ abstract mixin class $SnAccountBadgeCopyWith<$Res> {
       dynamic deletedAt,
       String type,
       int accountId,
+      bool isActive,
       Map<String, dynamic> metadata});
 }
 
@@ -1918,6 +1923,7 @@ class _$SnAccountBadgeCopyWithImpl<$Res>
     Object? deletedAt = freezed,
     Object? type = null,
     Object? accountId = null,
+    Object? isActive = null,
     Object? metadata = null,
   }) {
     return _then(_self.copyWith(
@@ -1945,6 +1951,10 @@ class _$SnAccountBadgeCopyWithImpl<$Res>
           ? _self.accountId
           : accountId // ignore: cast_nullable_to_non_nullable
               as int,
+      isActive: null == isActive
+          ? _self.isActive
+          : isActive // ignore: cast_nullable_to_non_nullable
+              as bool,
       metadata: null == metadata
           ? _self.metadata
           : metadata // ignore: cast_nullable_to_non_nullable
@@ -1963,6 +1973,7 @@ class _SnAccountBadge implements SnAccountBadge {
       required this.deletedAt,
       required this.type,
       required this.accountId,
+      this.isActive = false,
       final Map<String, dynamic> metadata = const {}})
       : _metadata = metadata;
   factory _SnAccountBadge.fromJson(Map<String, dynamic> json) =>
@@ -1980,6 +1991,9 @@ class _SnAccountBadge implements SnAccountBadge {
   final String type;
   @override
   final int accountId;
+  @override
+  @JsonKey()
+  final bool isActive;
   final Map<String, dynamic> _metadata;
   @override
   @JsonKey()
@@ -2018,6 +2032,8 @@ class _SnAccountBadge implements SnAccountBadge {
             (identical(other.type, type) || other.type == type) &&
             (identical(other.accountId, accountId) ||
                 other.accountId == accountId) &&
+            (identical(other.isActive, isActive) ||
+                other.isActive == isActive) &&
             const DeepCollectionEquality().equals(other._metadata, _metadata));
   }
 
@@ -2031,11 +2047,12 @@ class _SnAccountBadge implements SnAccountBadge {
       const DeepCollectionEquality().hash(deletedAt),
       type,
       accountId,
+      isActive,
       const DeepCollectionEquality().hash(_metadata));
 
   @override
   String toString() {
-    return 'SnAccountBadge(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, accountId: $accountId, metadata: $metadata)';
+    return 'SnAccountBadge(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, accountId: $accountId, isActive: $isActive, metadata: $metadata)';
   }
 }
 
@@ -2054,6 +2071,7 @@ abstract mixin class _$SnAccountBadgeCopyWith<$Res>
       dynamic deletedAt,
       String type,
       int accountId,
+      bool isActive,
       Map<String, dynamic> metadata});
 }
 
@@ -2076,6 +2094,7 @@ class __$SnAccountBadgeCopyWithImpl<$Res>
     Object? deletedAt = freezed,
     Object? type = null,
     Object? accountId = null,
+    Object? isActive = null,
     Object? metadata = null,
   }) {
     return _then(_SnAccountBadge(
@@ -2103,6 +2122,10 @@ class __$SnAccountBadgeCopyWithImpl<$Res>
           ? _self.accountId
           : accountId // ignore: cast_nullable_to_non_nullable
               as int,
+      isActive: null == isActive
+          ? _self.isActive
+          : isActive // ignore: cast_nullable_to_non_nullable
+              as bool,
       metadata: null == metadata
           ? _self._metadata
           : metadata // ignore: cast_nullable_to_non_nullable
diff --git a/lib/types/account.g.dart b/lib/types/account.g.dart
index 2e15e82..07477a9 100644
--- a/lib/types/account.g.dart
+++ b/lib/types/account.g.dart
@@ -187,6 +187,7 @@ _SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
       deletedAt: json['deleted_at'],
       type: json['type'] as String,
       accountId: (json['account_id'] as num).toInt(),
+      isActive: json['is_active'] as bool? ?? false,
       metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
     );
 
@@ -198,6 +199,7 @@ Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
       'deleted_at': instance.deletedAt,
       'type': instance.type,
       'account_id': instance.accountId,
+      'is_active': instance.isActive,
       'metadata': instance.metadata,
     };