Status system

This commit is contained in:
LittleSheep 2025-03-09 14:00:35 +08:00
parent da25fb9c29
commit 335318ae3f
11 changed files with 1034 additions and 33 deletions

View File

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

View File

@ -775,5 +775,19 @@
"zero": "无连击",
"one": "连续签到 {} 天",
"other": "连续签到 {} 天"
}
},
"accountChangeStatus": "修改状态",
"accountStatusSilent": "请勿打扰",
"accountStatusSilentDesc": "将会暂停所有通知推送",
"accountStatusInvisible": "隐身",
"accountStatusInvisibleDesc": "将会在他人界面显示离线,但不影响功能使用",
"accountCustomStatus": "自定义状态",
"accountCustomStatusDescription": "客制化你的状态。",
"accountClearStatus": "清除状态",
"accountClearStatusDescription": "清除你的状态,并让服务器决定你的状态。",
"fieldAccountStatusLabel": "状态文字",
"fieldAccountStatusClearAt": "清除时间",
"accountStatusNegative": "负面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

@ -775,5 +775,19 @@
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
}
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

@ -775,5 +775,19 @@
"zero": "無連擊",
"one": "連續簽到 {} 天",
"other": "連續簽到 {} 天"
}
},
"accountChangeStatus": "修改狀態",
"accountStatusSilent": "請勿打擾",
"accountStatusSilentDesc": "將會暫停所有通知推送",
"accountStatusInvisible": "隱身",
"accountStatusInvisibleDesc": "將會在他人界面顯示離線,但不影響功能使用",
"accountCustomStatus": "自定義狀態",
"accountCustomStatusDescription": "客製化你的狀態。",
"accountClearStatus": "清除狀態",
"accountClearStatusDescription": "清除你的狀態,並讓服務器決定你的狀態。",
"fieldAccountStatusLabel": "狀態文字",
"fieldAccountStatusClearAt": "清除時間",
"accountStatusNegative": "負面",
"accountStatusNeutral": "中性",
"accountStatusPositive": "正面"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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