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 _fetchStatus() async { + try { + final sn = context.read(); + 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 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 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 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 get copyWith => + _$SnAccountStatusCopyWithImpl( + this as SnAccountStatus, _$identity); + + /// Serializes this SnAccountStatus to a JSON map. + Map 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 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 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 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), ); Map _$SnAccountStatusInfoToJson( @@ -219,7 +221,41 @@ Map _$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 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 _$SnAccountStatusToJson(_SnAccountStatus instance) => + { + '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 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 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 createState() => + _AccountStatusActionPopupState(); +} + +class _AccountStatusActionPopupState extends State { + bool _isBusy = false; + + Future 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(); + + 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 _clearStatus() async { + if (_isBusy) return; + + setState(() => _isBusy = true); + try { + final sn = context.read(); + 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 _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 _applyStatus() async { + if (_isBusy) return; + + setState(() => _isBusy = true); + try { + final sn = context.read(); + 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: [ + 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(), + ), + ], + ); + } +}