From 335318ae3ff192ada7cc32410817da55148d21c6 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sun, 9 Mar 2025 14:00:35 +0800
Subject: [PATCH] :sparkles: Status system

---
 assets/translations/en-US.json           |  16 +-
 assets/translations/zh-CN.json           |  16 +-
 assets/translations/zh-HK.json           |  16 +-
 assets/translations/zh-TW.json           |  16 +-
 lib/screens/account.dart                 |  89 ++++-
 lib/screens/account/profile_page.dart    |  18 +-
 lib/types/account.dart                   |  22 +-
 lib/types/account.freezed.dart           | 422 ++++++++++++++++++++++-
 lib/types/account.g.dart                 |  40 ++-
 lib/widgets/account/account_popover.dart |  21 +-
 lib/widgets/account/account_status.dart  | 391 +++++++++++++++++++++
 11 files changed, 1034 insertions(+), 33 deletions(-)
 create mode 100644 lib/widgets/account/account_status.dart

diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json
index 76d7dcb..0347582 100644
--- a/assets/translations/en-US.json
+++ b/assets/translations/en-US.json
@@ -777,5 +777,19 @@
     "zero": "No streak",
     "one": "{} day streak",
     "other": "{} days streak"
-  }
+  },
+  "accountChangeStatus": "Change Status",
+  "accountStatusSilent": "Do not Disturb",
+  "accountStatusSilentDesc": "The notification will stop popping up",
+  "accountStatusInvisible": "Invisible",
+  "accountStatusInvisibleDesc": "Will show as offline, but all features still remain normal",
+  "accountCustomStatus": "Custom Status",
+  "accountCustomStatusDescription": "Customize your status.",
+  "accountClearStatus": "Clear Status",
+  "accountClearStatusDescription": "Clear your status, and let server decide which status you are for you.",
+  "fieldAccountStatusLabel": "Status Text",
+  "fieldAccountStatusClearAt": "Clear At",
+  "accountStatusNegative": "Negative",
+  "accountStatusNeutral": "Neutral",
+  "accountStatusPositive": "Positive"
 }
diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json
index ef05dd9..45e4ce3 100644
--- a/assets/translations/zh-CN.json
+++ b/assets/translations/zh-CN.json
@@ -775,5 +775,19 @@
     "zero": "无连击",
     "one": "连续签到 {} 天",
     "other": "连续签到 {} 天"
-  }
+  },
+  "accountChangeStatus": "修改状态",
+  "accountStatusSilent": "请勿打扰",
+  "accountStatusSilentDesc": "将会暂停所有通知推送",
+  "accountStatusInvisible": "隐身",
+  "accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
+  "accountCustomStatus": "自定义状态",
+  "accountCustomStatusDescription": "客制化你的状态。",
+  "accountClearStatus": "清除状态",
+  "accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
+  "fieldAccountStatusLabel": "状态文字",
+  "fieldAccountStatusClearAt": "清除时间",
+  "accountStatusNegative": "负面",
+  "accountStatusNeutral": "中性",
+  "accountStatusPositive": "正面"
 }
diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json
index 6edcaaa..93a4847 100644
--- a/assets/translations/zh-HK.json
+++ b/assets/translations/zh-HK.json
@@ -775,5 +775,19 @@
     "zero": "無連擊",
     "one": "連續簽到 {} 天",
     "other": "連續簽到 {} 天"
-  }
+  },
+  "accountChangeStatus": "修改狀態",
+  "accountStatusSilent": "請勿打擾",
+  "accountStatusSilentDesc": "將會暫停所有通知推送",
+  "accountStatusInvisible": "隱身",
+  "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
+  "accountCustomStatus": "自定義狀態",
+  "accountCustomStatusDescription": "客製化你的狀態。",
+  "accountClearStatus": "清除狀態",
+  "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
+  "fieldAccountStatusLabel": "狀態文字",
+  "fieldAccountStatusClearAt": "清除時間",
+  "accountStatusNegative": "負面",
+  "accountStatusNeutral": "中性",
+  "accountStatusPositive": "正面"
 }
diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json
index ce0dffb..7164139 100644
--- a/assets/translations/zh-TW.json
+++ b/assets/translations/zh-TW.json
@@ -775,5 +775,19 @@
     "zero": "無連擊",
     "one": "連續簽到 {} 天",
     "other": "連續簽到 {} 天"
-  }
+  },
+  "accountChangeStatus": "修改狀態",
+  "accountStatusSilent": "請勿打擾",
+  "accountStatusSilentDesc": "將會暫停所有通知推送",
+  "accountStatusInvisible": "隱身",
+  "accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
+  "accountCustomStatus": "自定義狀態",
+  "accountCustomStatusDescription": "客製化你的狀態。",
+  "accountClearStatus": "清除狀態",
+  "accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
+  "fieldAccountStatusLabel": "狀態文字",
+  "fieldAccountStatusClearAt": "清除時間",
+  "accountStatusNegative": "負面",
+  "accountStatusNeutral": "中性",
+  "accountStatusPositive": "正面"
 }
diff --git a/lib/screens/account.dart b/lib/screens/account.dart
index acd5dfa..1b1b466 100644
--- a/lib/screens/account.dart
+++ b/lib/screens/account.dart
@@ -11,7 +11,9 @@ import 'package:surface/providers/database.dart';
 import 'package:surface/providers/sn_network.dart';
 import 'package:surface/providers/userinfo.dart';
 import 'package:surface/providers/websocket.dart';
+import 'package:surface/types/account.dart';
 import 'package:surface/widgets/account/account_image.dart';
+import 'package:surface/widgets/account/account_status.dart';
 import 'package:surface/widgets/app_bar_leading.dart';
 import 'package:surface/widgets/dialog.dart';
 import 'package:surface/widgets/navigation/app_scaffold.dart';
@@ -112,7 +114,14 @@ class _AuthorizedAccountScreen extends StatelessWidget {
               child: Column(
                 crossAxisAlignment: CrossAxisAlignment.start,
                 children: [
-                  AccountImage(content: ua.user!.avatar, radius: 28),
+                  Row(
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      AccountImage(content: ua.user!.avatar, radius: 28),
+                      _AccountStatusWidget(account: ua.user!),
+                    ],
+                  ),
                   const Gap(8),
                   Row(
                     crossAxisAlignment: CrossAxisAlignment.baseline,
@@ -290,3 +299,81 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
     );
   }
 }
+
+class _AccountStatusWidget extends StatefulWidget {
+  final SnAccount account;
+  const _AccountStatusWidget({required this.account});
+
+  @override
+  State<_AccountStatusWidget> createState() => _AccountStatusWidgetState();
+}
+
+class _AccountStatusWidgetState extends State<_AccountStatusWidget> {
+  SnAccountStatusInfo? _status;
+
+  Future<void> _fetchStatus() async {
+    try {
+      final sn = context.read<SnNetworkProvider>();
+      final resp =
+          await sn.client.get('/cgi/id/users/${widget.account.name}/status');
+      setState(() {
+        _status = SnAccountStatusInfo.fromJson(resp.data);
+      });
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() {});
+    }
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    _fetchStatus();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return InkWell(
+      child: Row(
+        children: [
+          Text(
+            _status != null
+                ? (_status!.status?.label.isNotEmpty ?? false)
+                    ? _status!.status!.label
+                    : _status!.isOnline
+                        ? 'accountStatusOnline'.tr()
+                        : 'accountStatusOffline'.tr()
+                : 'loading'.tr(),
+          ),
+          const Gap(4),
+          Icon(
+            (_status?.isDisturbable ?? true)
+                ? Symbols.circle
+                : Symbols.do_not_disturb_on,
+            fill: (_status?.isOnline ?? false) ? 1 : 0,
+            size: 16,
+            color: (_status?.isOnline ?? false)
+                ? (_status?.isDisturbable ?? true)
+                    ? Colors.green
+                    : Colors.red
+                : Colors.grey,
+          ).padding(all: 4),
+        ],
+      ),
+      onTap: () {
+        showModalBottomSheet(
+          context: context,
+          builder: (context) => AccountStatusActionPopup(
+            currentStatus: _status,
+          ),
+        ).then((value) {
+          if (value == true && mounted) {
+            _fetchStatus();
+          }
+        });
+      },
+    );
+  }
+}
diff --git a/lib/screens/account/profile_page.dart b/lib/screens/account/profile_page.dart
index 5b3cd56..c59747a 100644
--- a/lib/screens/account/profile_page.dart
+++ b/lib/screens/account/profile_page.dart
@@ -451,19 +451,25 @@ class _UserScreenState extends State<UserScreen>
                     child: Row(
                       children: [
                         Icon(
-                          Symbols.circle,
-                          fill: 1,
+                          (_status?.isDisturbable ?? true)
+                              ? Symbols.circle
+                              : Symbols.do_not_disturb_on,
+                          fill: (_status?.isOnline ?? false) ? 1 : 0,
                           size: 16,
                           color: (_status?.isOnline ?? false)
-                              ? Colors.green
+                              ? (_status?.isDisturbable ?? true)
+                                  ? Colors.green
+                                  : Colors.red
                               : Colors.grey,
                         ).padding(all: 4),
                         const Gap(8),
                         Text(
                           _status != null
-                              ? _status!.isOnline
-                                  ? 'accountStatusOnline'.tr()
-                                  : 'accountStatusOffline'.tr()
+                              ? (_status!.status?.label.isNotEmpty ?? false)
+                                  ? _status!.status!.label
+                                  : _status!.isOnline
+                                      ? 'accountStatusOnline'.tr()
+                                      : 'accountStatusOffline'.tr()
                               : 'loading'.tr(),
                         ),
                         if (_status != null &&
diff --git a/lib/types/account.dart b/lib/types/account.dart
index 34df848..5ce6980 100644
--- a/lib/types/account.dart
+++ b/lib/types/account.dart
@@ -119,13 +119,33 @@ abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
     required bool isDisturbable,
     required bool isOnline,
     required DateTime? lastSeenAt,
-    required dynamic status,
+    required SnAccountStatus? status,
   }) = _SnAccountStatusInfo;
 
   factory SnAccountStatusInfo.fromJson(Map<String, Object?> json) =>
       _$SnAccountStatusInfoFromJson(json);
 }
 
+@freezed
+abstract class SnAccountStatus with _$SnAccountStatus {
+  const factory SnAccountStatus({
+    required int id,
+    required DateTime createdAt,
+    required DateTime updatedAt,
+    required DateTime? deletedAt,
+    required String type,
+    required String label,
+    required int attitude,
+    required bool isNoDisturb,
+    required bool isInvisible,
+    required DateTime? clearAt,
+    required int accountId,
+  }) = _SnAccountStatus;
+
+  factory SnAccountStatus.fromJson(Map<String, Object?> json) =>
+      _$SnAccountStatusFromJson(json);
+}
+
 @freezed
 abstract class SnAbuseReport with _$SnAbuseReport {
   const factory SnAbuseReport({
diff --git a/lib/types/account.freezed.dart b/lib/types/account.freezed.dart
index 70e57ca..6e3130d 100644
--- a/lib/types/account.freezed.dart
+++ b/lib/types/account.freezed.dart
@@ -2139,7 +2139,7 @@ mixin _$SnAccountStatusInfo {
   bool get isDisturbable;
   bool get isOnline;
   DateTime? get lastSeenAt;
-  dynamic get status;
+  SnAccountStatus? get status;
 
   /// Create a copy of SnAccountStatusInfo
   /// with the given fields replaced by the non-null parameter values.
@@ -2163,13 +2163,13 @@ mixin _$SnAccountStatusInfo {
                 other.isOnline == isOnline) &&
             (identical(other.lastSeenAt, lastSeenAt) ||
                 other.lastSeenAt == lastSeenAt) &&
-            const DeepCollectionEquality().equals(other.status, status));
+            (identical(other.status, status) || other.status == status));
   }
 
   @JsonKey(includeFromJson: false, includeToJson: false)
   @override
-  int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
-      lastSeenAt, const DeepCollectionEquality().hash(status));
+  int get hashCode =>
+      Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
 
   @override
   String toString() {
@@ -2187,7 +2187,9 @@ abstract mixin class $SnAccountStatusInfoCopyWith<$Res> {
       {bool isDisturbable,
       bool isOnline,
       DateTime? lastSeenAt,
-      dynamic status});
+      SnAccountStatus? status});
+
+  $SnAccountStatusCopyWith<$Res>? get status;
 }
 
 /// @nodoc
@@ -2224,9 +2226,23 @@ class _$SnAccountStatusInfoCopyWithImpl<$Res>
       status: freezed == status
           ? _self.status
           : status // ignore: cast_nullable_to_non_nullable
-              as dynamic,
+              as SnAccountStatus?,
     ));
   }
+
+  /// Create a copy of SnAccountStatusInfo
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @pragma('vm:prefer-inline')
+  $SnAccountStatusCopyWith<$Res>? get status {
+    if (_self.status == null) {
+      return null;
+    }
+
+    return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
+      return _then(_self.copyWith(status: value));
+    });
+  }
 }
 
 /// @nodoc
@@ -2247,7 +2263,7 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
   @override
   final DateTime? lastSeenAt;
   @override
-  final dynamic status;
+  final SnAccountStatus? status;
 
   /// Create a copy of SnAccountStatusInfo
   /// with the given fields replaced by the non-null parameter values.
@@ -2276,13 +2292,13 @@ class _SnAccountStatusInfo implements SnAccountStatusInfo {
                 other.isOnline == isOnline) &&
             (identical(other.lastSeenAt, lastSeenAt) ||
                 other.lastSeenAt == lastSeenAt) &&
-            const DeepCollectionEquality().equals(other.status, status));
+            (identical(other.status, status) || other.status == status));
   }
 
   @JsonKey(includeFromJson: false, includeToJson: false)
   @override
-  int get hashCode => Object.hash(runtimeType, isDisturbable, isOnline,
-      lastSeenAt, const DeepCollectionEquality().hash(status));
+  int get hashCode =>
+      Object.hash(runtimeType, isDisturbable, isOnline, lastSeenAt, status);
 
   @override
   String toString() {
@@ -2302,7 +2318,10 @@ abstract mixin class _$SnAccountStatusInfoCopyWith<$Res>
       {bool isDisturbable,
       bool isOnline,
       DateTime? lastSeenAt,
-      dynamic status});
+      SnAccountStatus? status});
+
+  @override
+  $SnAccountStatusCopyWith<$Res>? get status;
 }
 
 /// @nodoc
@@ -2339,7 +2358,386 @@ class __$SnAccountStatusInfoCopyWithImpl<$Res>
       status: freezed == status
           ? _self.status
           : status // ignore: cast_nullable_to_non_nullable
-              as dynamic,
+              as SnAccountStatus?,
+    ));
+  }
+
+  /// Create a copy of SnAccountStatusInfo
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @pragma('vm:prefer-inline')
+  $SnAccountStatusCopyWith<$Res>? get status {
+    if (_self.status == null) {
+      return null;
+    }
+
+    return $SnAccountStatusCopyWith<$Res>(_self.status!, (value) {
+      return _then(_self.copyWith(status: value));
+    });
+  }
+}
+
+/// @nodoc
+mixin _$SnAccountStatus {
+  int get id;
+  DateTime get createdAt;
+  DateTime get updatedAt;
+  DateTime? get deletedAt;
+  String get type;
+  String get label;
+  int get attitude;
+  bool get isNoDisturb;
+  bool get isInvisible;
+  DateTime? get clearAt;
+  int get accountId;
+
+  /// Create a copy of SnAccountStatus
+  /// with the given fields replaced by the non-null parameter values.
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @pragma('vm:prefer-inline')
+  $SnAccountStatusCopyWith<SnAccountStatus> get copyWith =>
+      _$SnAccountStatusCopyWithImpl<SnAccountStatus>(
+          this as SnAccountStatus, _$identity);
+
+  /// Serializes this SnAccountStatus to a JSON map.
+  Map<String, dynamic> toJson();
+
+  @override
+  bool operator ==(Object other) {
+    return identical(this, other) ||
+        (other.runtimeType == runtimeType &&
+            other is SnAccountStatus &&
+            (identical(other.id, id) || other.id == id) &&
+            (identical(other.createdAt, createdAt) ||
+                other.createdAt == createdAt) &&
+            (identical(other.updatedAt, updatedAt) ||
+                other.updatedAt == updatedAt) &&
+            (identical(other.deletedAt, deletedAt) ||
+                other.deletedAt == deletedAt) &&
+            (identical(other.type, type) || other.type == type) &&
+            (identical(other.label, label) || other.label == label) &&
+            (identical(other.attitude, attitude) ||
+                other.attitude == attitude) &&
+            (identical(other.isNoDisturb, isNoDisturb) ||
+                other.isNoDisturb == isNoDisturb) &&
+            (identical(other.isInvisible, isInvisible) ||
+                other.isInvisible == isInvisible) &&
+            (identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
+            (identical(other.accountId, accountId) ||
+                other.accountId == accountId));
+  }
+
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @override
+  int get hashCode => Object.hash(
+      runtimeType,
+      id,
+      createdAt,
+      updatedAt,
+      deletedAt,
+      type,
+      label,
+      attitude,
+      isNoDisturb,
+      isInvisible,
+      clearAt,
+      accountId);
+
+  @override
+  String toString() {
+    return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
+  }
+}
+
+/// @nodoc
+abstract mixin class $SnAccountStatusCopyWith<$Res> {
+  factory $SnAccountStatusCopyWith(
+          SnAccountStatus value, $Res Function(SnAccountStatus) _then) =
+      _$SnAccountStatusCopyWithImpl;
+  @useResult
+  $Res call(
+      {int id,
+      DateTime createdAt,
+      DateTime updatedAt,
+      DateTime? deletedAt,
+      String type,
+      String label,
+      int attitude,
+      bool isNoDisturb,
+      bool isInvisible,
+      DateTime? clearAt,
+      int accountId});
+}
+
+/// @nodoc
+class _$SnAccountStatusCopyWithImpl<$Res>
+    implements $SnAccountStatusCopyWith<$Res> {
+  _$SnAccountStatusCopyWithImpl(this._self, this._then);
+
+  final SnAccountStatus _self;
+  final $Res Function(SnAccountStatus) _then;
+
+  /// Create a copy of SnAccountStatus
+  /// 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? type = null,
+    Object? label = null,
+    Object? attitude = null,
+    Object? isNoDisturb = null,
+    Object? isInvisible = null,
+    Object? clearAt = freezed,
+    Object? accountId = null,
+  }) {
+    return _then(_self.copyWith(
+      id: null == id
+          ? _self.id
+          : id // ignore: cast_nullable_to_non_nullable
+              as int,
+      createdAt: null == createdAt
+          ? _self.createdAt
+          : createdAt // ignore: cast_nullable_to_non_nullable
+              as DateTime,
+      updatedAt: null == updatedAt
+          ? _self.updatedAt
+          : updatedAt // ignore: cast_nullable_to_non_nullable
+              as DateTime,
+      deletedAt: freezed == deletedAt
+          ? _self.deletedAt
+          : deletedAt // ignore: cast_nullable_to_non_nullable
+              as DateTime?,
+      type: null == type
+          ? _self.type
+          : type // ignore: cast_nullable_to_non_nullable
+              as String,
+      label: null == label
+          ? _self.label
+          : label // ignore: cast_nullable_to_non_nullable
+              as String,
+      attitude: null == attitude
+          ? _self.attitude
+          : attitude // ignore: cast_nullable_to_non_nullable
+              as int,
+      isNoDisturb: null == isNoDisturb
+          ? _self.isNoDisturb
+          : isNoDisturb // ignore: cast_nullable_to_non_nullable
+              as bool,
+      isInvisible: null == isInvisible
+          ? _self.isInvisible
+          : isInvisible // ignore: cast_nullable_to_non_nullable
+              as bool,
+      clearAt: freezed == clearAt
+          ? _self.clearAt
+          : clearAt // ignore: cast_nullable_to_non_nullable
+              as DateTime?,
+      accountId: null == accountId
+          ? _self.accountId
+          : accountId // ignore: cast_nullable_to_non_nullable
+              as int,
+    ));
+  }
+}
+
+/// @nodoc
+@JsonSerializable()
+class _SnAccountStatus implements SnAccountStatus {
+  const _SnAccountStatus(
+      {required this.id,
+      required this.createdAt,
+      required this.updatedAt,
+      required this.deletedAt,
+      required this.type,
+      required this.label,
+      required this.attitude,
+      required this.isNoDisturb,
+      required this.isInvisible,
+      required this.clearAt,
+      required this.accountId});
+  factory _SnAccountStatus.fromJson(Map<String, dynamic> json) =>
+      _$SnAccountStatusFromJson(json);
+
+  @override
+  final int id;
+  @override
+  final DateTime createdAt;
+  @override
+  final DateTime updatedAt;
+  @override
+  final DateTime? deletedAt;
+  @override
+  final String type;
+  @override
+  final String label;
+  @override
+  final int attitude;
+  @override
+  final bool isNoDisturb;
+  @override
+  final bool isInvisible;
+  @override
+  final DateTime? clearAt;
+  @override
+  final int accountId;
+
+  /// Create a copy of SnAccountStatus
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @pragma('vm:prefer-inline')
+  _$SnAccountStatusCopyWith<_SnAccountStatus> get copyWith =>
+      __$SnAccountStatusCopyWithImpl<_SnAccountStatus>(this, _$identity);
+
+  @override
+  Map<String, dynamic> toJson() {
+    return _$SnAccountStatusToJson(
+      this,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    return identical(this, other) ||
+        (other.runtimeType == runtimeType &&
+            other is _SnAccountStatus &&
+            (identical(other.id, id) || other.id == id) &&
+            (identical(other.createdAt, createdAt) ||
+                other.createdAt == createdAt) &&
+            (identical(other.updatedAt, updatedAt) ||
+                other.updatedAt == updatedAt) &&
+            (identical(other.deletedAt, deletedAt) ||
+                other.deletedAt == deletedAt) &&
+            (identical(other.type, type) || other.type == type) &&
+            (identical(other.label, label) || other.label == label) &&
+            (identical(other.attitude, attitude) ||
+                other.attitude == attitude) &&
+            (identical(other.isNoDisturb, isNoDisturb) ||
+                other.isNoDisturb == isNoDisturb) &&
+            (identical(other.isInvisible, isInvisible) ||
+                other.isInvisible == isInvisible) &&
+            (identical(other.clearAt, clearAt) || other.clearAt == clearAt) &&
+            (identical(other.accountId, accountId) ||
+                other.accountId == accountId));
+  }
+
+  @JsonKey(includeFromJson: false, includeToJson: false)
+  @override
+  int get hashCode => Object.hash(
+      runtimeType,
+      id,
+      createdAt,
+      updatedAt,
+      deletedAt,
+      type,
+      label,
+      attitude,
+      isNoDisturb,
+      isInvisible,
+      clearAt,
+      accountId);
+
+  @override
+  String toString() {
+    return 'SnAccountStatus(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, label: $label, attitude: $attitude, isNoDisturb: $isNoDisturb, isInvisible: $isInvisible, clearAt: $clearAt, accountId: $accountId)';
+  }
+}
+
+/// @nodoc
+abstract mixin class _$SnAccountStatusCopyWith<$Res>
+    implements $SnAccountStatusCopyWith<$Res> {
+  factory _$SnAccountStatusCopyWith(
+          _SnAccountStatus value, $Res Function(_SnAccountStatus) _then) =
+      __$SnAccountStatusCopyWithImpl;
+  @override
+  @useResult
+  $Res call(
+      {int id,
+      DateTime createdAt,
+      DateTime updatedAt,
+      DateTime? deletedAt,
+      String type,
+      String label,
+      int attitude,
+      bool isNoDisturb,
+      bool isInvisible,
+      DateTime? clearAt,
+      int accountId});
+}
+
+/// @nodoc
+class __$SnAccountStatusCopyWithImpl<$Res>
+    implements _$SnAccountStatusCopyWith<$Res> {
+  __$SnAccountStatusCopyWithImpl(this._self, this._then);
+
+  final _SnAccountStatus _self;
+  final $Res Function(_SnAccountStatus) _then;
+
+  /// Create a copy of SnAccountStatus
+  /// with the given fields replaced by the non-null parameter values.
+  @override
+  @pragma('vm:prefer-inline')
+  $Res call({
+    Object? id = null,
+    Object? createdAt = null,
+    Object? updatedAt = null,
+    Object? deletedAt = freezed,
+    Object? type = null,
+    Object? label = null,
+    Object? attitude = null,
+    Object? isNoDisturb = null,
+    Object? isInvisible = null,
+    Object? clearAt = freezed,
+    Object? accountId = null,
+  }) {
+    return _then(_SnAccountStatus(
+      id: null == id
+          ? _self.id
+          : id // ignore: cast_nullable_to_non_nullable
+              as int,
+      createdAt: null == createdAt
+          ? _self.createdAt
+          : createdAt // ignore: cast_nullable_to_non_nullable
+              as DateTime,
+      updatedAt: null == updatedAt
+          ? _self.updatedAt
+          : updatedAt // ignore: cast_nullable_to_non_nullable
+              as DateTime,
+      deletedAt: freezed == deletedAt
+          ? _self.deletedAt
+          : deletedAt // ignore: cast_nullable_to_non_nullable
+              as DateTime?,
+      type: null == type
+          ? _self.type
+          : type // ignore: cast_nullable_to_non_nullable
+              as String,
+      label: null == label
+          ? _self.label
+          : label // ignore: cast_nullable_to_non_nullable
+              as String,
+      attitude: null == attitude
+          ? _self.attitude
+          : attitude // ignore: cast_nullable_to_non_nullable
+              as int,
+      isNoDisturb: null == isNoDisturb
+          ? _self.isNoDisturb
+          : isNoDisturb // ignore: cast_nullable_to_non_nullable
+              as bool,
+      isInvisible: null == isInvisible
+          ? _self.isInvisible
+          : isInvisible // ignore: cast_nullable_to_non_nullable
+              as bool,
+      clearAt: freezed == clearAt
+          ? _self.clearAt
+          : clearAt // ignore: cast_nullable_to_non_nullable
+              as DateTime?,
+      accountId: null == accountId
+          ? _self.accountId
+          : accountId // ignore: cast_nullable_to_non_nullable
+              as int,
     ));
   }
 }
diff --git a/lib/types/account.g.dart b/lib/types/account.g.dart
index 07477a9..10ed4c8 100644
--- a/lib/types/account.g.dart
+++ b/lib/types/account.g.dart
@@ -210,7 +210,9 @@ _SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
       lastSeenAt: json['last_seen_at'] == null
           ? null
           : DateTime.parse(json['last_seen_at'] as String),
-      status: json['status'],
+      status: json['status'] == null
+          ? null
+          : SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
     );
 
 Map<String, dynamic> _$SnAccountStatusInfoToJson(
@@ -219,7 +221,41 @@ Map<String, dynamic> _$SnAccountStatusInfoToJson(
       'is_disturbable': instance.isDisturbable,
       'is_online': instance.isOnline,
       'last_seen_at': instance.lastSeenAt?.toIso8601String(),
-      'status': instance.status,
+      'status': instance.status?.toJson(),
+    };
+
+_SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
+    _SnAccountStatus(
+      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'] == null
+          ? null
+          : DateTime.parse(json['deleted_at'] as String),
+      type: json['type'] as String,
+      label: json['label'] as String,
+      attitude: (json['attitude'] as num).toInt(),
+      isNoDisturb: json['is_no_disturb'] as bool,
+      isInvisible: json['is_invisible'] as bool,
+      clearAt: json['clear_at'] == null
+          ? null
+          : DateTime.parse(json['clear_at'] as String),
+      accountId: (json['account_id'] as num).toInt(),
+    );
+
+Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
+    <String, dynamic>{
+      'id': instance.id,
+      'created_at': instance.createdAt.toIso8601String(),
+      'updated_at': instance.updatedAt.toIso8601String(),
+      'deleted_at': instance.deletedAt?.toIso8601String(),
+      'type': instance.type,
+      'label': instance.label,
+      'attitude': instance.attitude,
+      'is_no_disturb': instance.isNoDisturb,
+      'is_invisible': instance.isInvisible,
+      'clear_at': instance.clearAt?.toIso8601String(),
+      'account_id': instance.accountId,
     };
 
 _SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
diff --git a/lib/widgets/account/account_popover.dart b/lib/widgets/account/account_popover.dart
index 4ddf674..b950cc0 100644
--- a/lib/widgets/account/account_popover.dart
+++ b/lib/widgets/account/account_popover.dart
@@ -118,18 +118,25 @@ class AccountPopoverCard extends StatelessWidget {
             return Row(
               children: [
                 Icon(
-                  Symbols.circle,
-                  fill: 1,
+                  (status?.isDisturbable ?? true)
+                      ? Symbols.circle
+                      : Symbols.do_not_disturb_on,
+                  fill: (status?.isOnline ?? false) ? 1 : 0,
                   size: 16,
-                  color:
-                      (status?.isOnline ?? false) ? Colors.green : Colors.grey,
+                  color: (status?.isOnline ?? false)
+                      ? (status?.isDisturbable ?? true)
+                          ? Colors.green
+                          : Colors.red
+                      : Colors.grey,
                 ).padding(all: 4),
                 const Gap(8),
                 Text(
                   status != null
-                      ? status.isOnline
-                          ? 'accountStatusOnline'.tr()
-                          : 'accountStatusOffline'.tr()
+                      ? (status.status?.label.isNotEmpty ?? false)
+                          ? status.status!.label
+                          : status.isOnline
+                              ? 'accountStatusOnline'.tr()
+                              : 'accountStatusOffline'.tr()
                       : 'loading'.tr(),
                 ),
                 if (status != null &&
diff --git a/lib/widgets/account/account_status.dart b/lib/widgets/account/account_status.dart
new file mode 100644
index 0000000..772fccd
--- /dev/null
+++ b/lib/widgets/account/account_status.dart
@@ -0,0 +1,391 @@
+import 'package:dio/dio.dart';
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:gap/gap.dart';
+import 'package:material_symbols_icons/symbols.dart';
+import 'package:provider/provider.dart';
+import 'package:styled_widget/styled_widget.dart';
+import 'package:surface/providers/sn_network.dart';
+import 'package:surface/types/account.dart';
+import 'package:surface/widgets/dialog.dart';
+import 'package:surface/widgets/loading_indicator.dart';
+
+final Map<String, (Widget, String, String?)> kPresetStatus = {
+  'online': (
+    const Icon(Symbols.circle, color: Colors.green, fill: 1),
+    'accountStatusOnline'.tr(),
+    null,
+  ),
+  'silent': (
+    const Icon(Symbols.do_not_disturb_on, color: Colors.red),
+    'accountStatusSilent'.tr(),
+    'accountStatusSilentDesc'.tr(),
+  ),
+  'invisible': (
+    const Icon(Symbols.circle, color: Colors.grey),
+    'accountStatusInvisible'.tr(),
+    'accountStatusInvisibleDesc'.tr(),
+  ),
+};
+
+class AccountStatusActionPopup extends StatefulWidget {
+  final SnAccountStatusInfo? currentStatus;
+  const AccountStatusActionPopup({super.key, this.currentStatus});
+
+  @override
+  State<AccountStatusActionPopup> createState() =>
+      _AccountStatusActionPopupState();
+}
+
+class _AccountStatusActionPopupState extends State<AccountStatusActionPopup> {
+  bool _isBusy = false;
+
+  Future<void> setStatus(
+    String type,
+    String? label,
+    int attitude, {
+    bool isUpdate = false,
+    bool isSilent = false,
+    bool isInvisible = false,
+    DateTime? clearAt,
+  }) async {
+    setState(() => _isBusy = true);
+    final sn = context.read<SnNetworkProvider>();
+
+    final payload = {
+      'type': type,
+      'label': label,
+      'attitude': attitude,
+      'is_no_disturb': isSilent,
+      'is_invisible': isInvisible,
+      'clear_at': clearAt?.toUtc().toIso8601String()
+    };
+
+    try {
+      await sn.client.request(
+        '/cgi/id/users/me/status',
+        data: payload,
+        options: Options(method: isUpdate ? 'PUT' : 'POST'),
+      );
+      if (!mounted) return;
+      Navigator.pop(context, true);
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() => _isBusy = false);
+    }
+  }
+
+  Future<void> _clearStatus() async {
+    if (_isBusy) return;
+
+    setState(() => _isBusy = true);
+    try {
+      final sn = context.read<SnNetworkProvider>();
+      await sn.client.delete('/cgi/id/users/me/status');
+      if (!mounted) return;
+      Navigator.of(context).pop(true);
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() => _isBusy = false);
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          crossAxisAlignment: CrossAxisAlignment.center,
+          children: [
+            const Icon(Symbols.mood, size: 24),
+            const Gap(16),
+            Text('accountChangeStatus',
+                    style: Theme.of(context).textTheme.titleLarge)
+                .tr(),
+          ],
+        ).padding(horizontal: 20, top: 16, bottom: 12),
+        LoadingIndicator(isActive: _isBusy),
+        SizedBox(
+          height: 48,
+          child: ListView(
+            padding: EdgeInsets.symmetric(horizontal: 18),
+            scrollDirection: Axis.horizontal,
+            children: kPresetStatus.entries
+                .map(
+                  (x) => StyledWidget(ActionChip(
+                    avatar: x.value.$1,
+                    label: Text(x.value.$2),
+                    tooltip: x.value.$3,
+                    onPressed: _isBusy
+                        ? null
+                        : () {
+                            setStatus(
+                              x.key,
+                              x.value.$2,
+                              0,
+                              isInvisible: x.key == 'invisible',
+                              isSilent: x.key == 'silent',
+                            );
+                          },
+                  )).padding(right: 6),
+                )
+                .toList(),
+          ),
+        ),
+        const Gap(16),
+        const Divider(thickness: 0.3, height: 0.3),
+        ListTile(
+          contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+          leading: widget.currentStatus != null
+              ? const Icon(Icons.edit)
+              : const Icon(Icons.add),
+          title: Text('accountCustomStatus').tr(),
+          subtitle: Text('accountCustomStatusDescription').tr(),
+          onTap: _isBusy
+              ? null
+              : () async {
+                  final val = await showDialog(
+                    context: context,
+                    builder: (context) => _AccountStatusEditorDialog(
+                      currentStatus: widget.currentStatus,
+                    ),
+                  );
+                  if (val == true && context.mounted) {
+                    Navigator.of(context).pop(true);
+                  }
+                },
+        ),
+        if (widget.currentStatus != null)
+          ListTile(
+            contentPadding: const EdgeInsets.symmetric(horizontal: 24),
+            leading: const Icon(Icons.clear),
+            title: Text('accountClearStatus').tr(),
+            subtitle: Text('accountClearStatusDescription').tr(),
+            onTap: _isBusy
+                ? null
+                : () {
+                    _clearStatus();
+                  },
+          ),
+      ],
+    );
+  }
+}
+
+class _AccountStatusEditorDialog extends StatefulWidget {
+  final SnAccountStatusInfo? currentStatus;
+  const _AccountStatusEditorDialog({this.currentStatus});
+
+  @override
+  State<_AccountStatusEditorDialog> createState() =>
+      _AccountStatusEditorDialogState();
+}
+
+class _AccountStatusEditorDialogState
+    extends State<_AccountStatusEditorDialog> {
+  bool _isBusy = false;
+
+  final TextEditingController _labelController = TextEditingController();
+  final TextEditingController _clearAtController = TextEditingController();
+
+  int _attitude = 0;
+  bool _isSilent = false;
+  bool _isInvisible = false;
+  DateTime? _clearAt;
+
+  Future<void> _selectClearAt() async {
+    final DateTime? pickedDate = await showDatePicker(
+      context: context,
+      initialDate: _clearAt?.toLocal() ?? DateTime.now(),
+      firstDate: DateTime.now(),
+      lastDate: DateTime.now().add(const Duration(days: 365)),
+    );
+    if (pickedDate == null) return;
+    if (!mounted) return;
+    final TimeOfDay? pickedTime = await showTimePicker(
+      context: context,
+      initialTime: TimeOfDay.now(),
+    );
+    if (pickedTime == null) return;
+    if (!mounted) return;
+    final picked = pickedDate.copyWith(
+      hour: pickedTime.hour,
+      minute: pickedTime.minute,
+    );
+    setState(() {
+      _clearAt = picked;
+      _clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
+    });
+  }
+
+  Future<void> _applyStatus() async {
+    if (_isBusy) return;
+
+    setState(() => _isBusy = true);
+    try {
+      final sn = context.read<SnNetworkProvider>();
+      await sn.client.request(
+        '/cgi/id/users/me/status',
+        data: {
+          'type': 'custom',
+          'label': _labelController.text,
+          'attitude': _attitude,
+          'is_no_disturb': _isSilent,
+          'is_invisible': _isInvisible,
+          'clear_at': _clearAt?.toUtc().toIso8601String(),
+        },
+        options: Options(
+          method: widget.currentStatus?.status != null ? 'PUT' : 'POST',
+        ),
+      );
+      if (!mounted) return;
+      Navigator.of(context).pop(true);
+    } catch (err) {
+      if (!mounted) return;
+      context.showErrorDialog(err);
+    } finally {
+      setState(() => _isBusy = false);
+    }
+  }
+
+  void _syncWidget() {
+    if (widget.currentStatus?.status != null) {
+      _clearAt = widget.currentStatus!.status!.clearAt;
+      if (_clearAt != null) {
+        _clearAtController.text = DateFormat('y/M/d HH:mm').format(_clearAt!);
+      }
+
+      _labelController.text = widget.currentStatus!.status!.label;
+      _attitude = widget.currentStatus!.status!.attitude;
+      _isInvisible = widget.currentStatus!.status!.isInvisible;
+      _isSilent = widget.currentStatus!.status!.isNoDisturb;
+    }
+  }
+
+  @override
+  void initState() {
+    _syncWidget();
+    super.initState();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      title: Text('accountCustomStatus').tr(),
+      content: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          LoadingIndicator(isActive: _isBusy),
+          TextField(
+            controller: _labelController,
+            decoration: InputDecoration(
+              isDense: true,
+              prefixIcon: const Icon(Icons.label),
+              border: const OutlineInputBorder(),
+              labelText: 'fieldAccountStatusLabel'.tr(),
+            ),
+            onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
+          ),
+          const Gap(8),
+          TextField(
+            controller: _clearAtController,
+            readOnly: true,
+            decoration: InputDecoration(
+              isDense: true,
+              prefixIcon: const Icon(Icons.event_busy),
+              border: const OutlineInputBorder(),
+              labelText: 'fieldAccountStatusClearAt'.tr(),
+            ),
+            onTap: () => _selectClearAt(),
+          ),
+          const Gap(8),
+          SingleChildScrollView(
+            scrollDirection: Axis.horizontal,
+            child: Wrap(
+              spacing: 6,
+              runSpacing: 0,
+              children: [
+                ChoiceChip(
+                  avatar: Icon(
+                    Symbols.radio_button_unchecked,
+                    color: Theme.of(context).colorScheme.onSurfaceVariant,
+                  ),
+                  selected: _attitude == 2,
+                  label: Text('accountStatusNegative'.tr()),
+                  onSelected: (val) {
+                    if (val) setState(() => _attitude = 2);
+                  },
+                ),
+                ChoiceChip(
+                  avatar: Icon(
+                    Symbols.contrast,
+                    color: Theme.of(context).colorScheme.onSurfaceVariant,
+                  ),
+                  selected: _attitude == 0,
+                  label: Text('accountStatusNeutral'.tr()),
+                  onSelected: (val) {
+                    if (val) setState(() => _attitude = 0);
+                  },
+                ),
+                ChoiceChip(
+                  avatar: Icon(
+                    Symbols.circle,
+                    color: Theme.of(context).colorScheme.onSurfaceVariant,
+                  ),
+                  selected: _attitude == 1,
+                  label: Text('accountStatusPositive'.tr()),
+                  onSelected: (val) {
+                    if (val) setState(() => _attitude = 1);
+                  },
+                ),
+              ],
+            ),
+          ),
+          const Gap(4),
+          SingleChildScrollView(
+            scrollDirection: Axis.horizontal,
+            child: Wrap(
+              spacing: 6,
+              runSpacing: 0,
+              children: [
+                ChoiceChip(
+                  selected: _isSilent,
+                  label: Text('accountStatusSilent').tr(),
+                  onSelected: (val) {
+                    setState(() => _isSilent = val);
+                  },
+                ),
+                ChoiceChip(
+                  selected: _isInvisible,
+                  label: Text('accountStatusInvisible').tr(),
+                  onSelected: (val) {
+                    setState(() => _isInvisible = val);
+                  },
+                ),
+              ],
+            ),
+          ),
+        ],
+      ),
+      actions: <Widget>[
+        TextButton(
+          style: TextButton.styleFrom(
+            foregroundColor:
+                Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
+          ),
+          onPressed: _isBusy ? null : () => Navigator.pop(context),
+          child: Text('dialogCancel').tr(),
+        ),
+        TextButton(
+          onPressed: _isBusy ? null : () => _applyStatus(),
+          child: Text('dialogConfirm').tr(),
+        ),
+      ],
+    );
+  }
+}