Compare commits

...

15 Commits

Author SHA1 Message Date
0bbfa6ddde 🚀 Launch 3.0.0+104 2025-06-09 01:10:43 +08:00
1f46f89056 👽 Support new activity API 2025-06-09 01:06:52 +08:00
7740cf7830 🐛 Fixes members' loading bugs 2025-06-08 22:33:47 +08:00
d165e488ad 💄 Optimized image loading and error 2025-06-08 20:31:32 +08:00
37f5c61905 🐛 Fixes on video caching policy 2025-06-08 20:22:46 +08:00
723e17ff47 Account contact settings 2025-06-08 20:02:57 +08:00
20e6cc4283 Typing indicator 2025-06-08 01:16:48 +08:00
b2a118bbd0 🐛 Fixes on enter to send 2025-06-07 23:31:02 +08:00
3dd7c8a5b2 Account settings auth devices 2025-06-07 22:56:25 +08:00
d832729278 💄 Optimized the auth experience 2025-06-07 12:14:57 +08:00
dff8532229 💄 Better otp input 2025-06-07 02:50:39 +08:00
bf77bfce64 Auth factor settings 2025-06-06 01:21:22 +08:00
accb99bb24 Merge pull request #22 from Texas0295/v3 2025-06-05 18:54:03 +08:00
e67429e513 [FIX] fix: 🐛 fix workflow file icon location error 2025-06-05 13:16:15 +08:00
a87f5d61e6 [FIX] fix: 🐛 fix appimage build error 2025-06-05 12:51:20 +08:00
50 changed files with 3608 additions and 974 deletions

View File

@ -72,7 +72,7 @@ jobs:
mkdir Solian.AppDir
cp -r build/linux/x64/release/bundle/* Solian.AppDir
cp -r buildtools/appimage_config/* Solian.AppDir
cp assets/icon/icon-light-radius.png Solian.AppDir
cp assets/icons/icon-padded.png Solian.AppDir
sudo chmod +x buildtools/appimagetool-x86_64.AppImage
sudo chmod +x Solian.AppDir/AppRun
./buildtools/appimagetool-x86_64.AppImage Solian.AppDir

View File

@ -165,7 +165,7 @@
"status": "Status",
"statusActivityTitle": "{} is {} {}",
"statusActivityEndedTitle": "{} is {} {} until {}",
"appSettings": "App settings",
"appSettings": "App Settings",
"accountSettings": "Account Settings",
"settings": "Settings",
"language": "Language",
@ -302,24 +302,14 @@
"accountDeletionHint": "Are you sure to delete your account? If you confirmed, we will send an confirmation email to your primary email address, you can continue the deletion process by follow the insturctions in the email.",
"accountDeletionSent": "Account deletion confirmation email sent, please check your email inbox.",
"accountSecurityTitle": "Security",
"accountPrivacyTitle": "Privacy",
"accountDangerZoneTitle": "Danger Zone",
"accountPassword": "Password",
"accountPasswordDescription": "Change your account password",
"accountPasswordChange": "Change Password",
"accountPasswordChangeSent": "Password reset link sent, please check your email inbox.",
"accountPasswordChangeDescription": "We will send an email to your primary email address to reset your password.",
"accountTwoFactor": "Two-Factor Authentication",
"accountTwoFactorDescription": "Add an extra layer of security to your account",
"accountTwoFactorSetup": "Set Up 2FA",
"accountTwoFactorSetupDescription": "Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in.",
"accountPrivacy": "Privacy Settings",
"accountPrivacyDescription": "Control who can see your profile and content",
"accountDataExport": "Export Your Data",
"accountDataExportDescription": "Download a copy of your data",
"accountDataExportConfirmation": "We'll prepare an export of your data which may take some time. You'll receive an email when it's ready to download.",
"accountDataExportConfirm": "Request Export",
"accountDataExportRequested": "Data export requested. You'll receive an email when it's ready.",
"accountAuthFactor": "Auth factors",
"accountAuthFactorDescription": "Multi-factor authentication to ensure safety and convience",
"accountDeletionDescription": "Permanently delete your account and all your data",
"accountSettingsHelp": "Account Settings Help",
"accountSettingsHelpContent": "This page allows you to manage your account security, privacy, and other settings. If you need assistance, please contact support.",
@ -330,5 +320,81 @@
"postVisibilityPublic": "Public",
"postVisibilityFriends": "Friends Only",
"postVisibilityUnlisted": "Unlisted",
"postVisibilityPrivate": "Private"
"postVisibilityPrivate": "Private",
"copyMessage": "Copy Message",
"authFactor": "Authentication Factor",
"authFactorDelete": "Delete the Factor",
"authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.",
"authFactorDisable": "Disable the Factor",
"authFactorDisableHint": "Are you sure you want to disable this authentication factor? You can enable it again later.",
"authFactorEnable": "Enable the Factor",
"authFactorEnableHint": "Please enter the code that generated by the factor to enable it.",
"authFactorNew": "Create Auth Factor",
"authFactorSecret": "Secret",
"authFactorSecretHint": "Create an secret for this factor.",
"authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication",
"authFactorNoQrCode": "No QR code available for this authentication factor",
"cancel": "Cancel",
"confirm": "Confirm",
"authFactorAdditional": "One more step",
"authFactorHint": "Contact method hint",
"authFactorHintHelper": "You need provide a part of your contact method and we will send the verification code to that contact method if it matched our records",
"authSessions": "Active Sessions",
"authSessionsDescription": "See devices you currently logged in.",
"authSessionsCount": {
"one": "{} session",
"other": "{} sessions"
},
"authDeviceCurrent": "Current device",
"lastActiveAt": "Last active at {}",
"authDeviceLogout": "Logout",
"authDeviceLogoutHint": "Are you sure you want to logout this device? This will also disable the push notification to this device.",
"authDeviceEditLabel": "Edit Label",
"authDeviceLabelTitle": "Edit Device Label",
"authDeviceLabelHint": "Enter a name for this device",
"authDeviceSwipeEditHint": "Swipe left to edit label",
"authDeviceSwipeLogoutHint": "Swipe right to logout device",
"typingHint": {
"one": "{} is typing...",
"other": "{} are typing..."
},
"settingsAppearance": "Appearance",
"settingsServer": "Server",
"settingsBehavior": "Behavior",
"settingsDesktop": "Desktop",
"settingsKeyboardShortcuts": "Keyboard Shortcuts",
"settingsEnterToSendDesktopHint": "Press Enter to send messages, use Shift+Enter for new line.",
"settingsHelp": "Settings Help",
"settingsHelpContent": "This page allows you to manage your app settings, appearance, and behavior. If you need assistance, please contact support.",
"settingsKeyboardShortcutSearch": "Search",
"settingsKeyboardShortcutSettings": "Settings",
"settingsKeyboardShortcutNewMessage": "New Message",
"settingsKeyboardShortcutCloseDialog": "Close Dialog",
"close": "Close",
"contactMethod": "Contact Method",
"contactMethodType": "Contact Type",
"contactMethodTypeEmail": "Email",
"contactMethodTypePhone": "Phone",
"contactMethodTypeAddress": "Address",
"contactMethodEmailHint": "Enter your email address",
"contactMethodPhoneHint": "Enter your phone number",
"contactMethodAddressHint": "Enter your physical address",
"contactMethodEmailDescription": "Your email will be used for account recovery and notifications",
"contactMethodPhoneDescription": "Your phone number will be used for account recovery and notifications",
"contactMethodAddressDescription": "Your physical address will be used for shipping and billing purposes.",
"contactMethodVerified": "Verified",
"contactMethodUnverified": "Unverified",
"contactMethodVerify": "Verify Contact",
"contactMethodDelete": "Delete Contact",
"contactMethodNew": "New Contact Method",
"contactMethodContentEmpty": "Contact content cannot be empty",
"contactMethodVerificationSent": "Verification code sent to your contact method",
"contactMethodVerificationNeeded": "The contact method is added, but not verified yet. You can verify it by tapping it and select verify.",
"accountContactMethod": "Contact Methods",
"accountContactMethodDescription": "Manage your contact methods for account recovery and notifications",
"authFactorVerificationNeeded": "The auth factor is added, but it is not enabled yet. You can enable it by tapping it and enter the verification code.",
"contactMethodPrimary": "Primary",
"contactMethodSetPrimary": "Set as Primary",
"contactMethodSetPrimaryHint": "Set this contact method as your primary contact method for account recovery and notifications",
"contactMethodDeleteHint": "Are you sure to delete this contact method? This action cannot be undone."
}

View File

@ -0,0 +1,4 @@
#!/bin/sh
cd "$(dirname "$0")"
exec ./island

View File

@ -0,0 +1,8 @@
[Desktop Entry]
Version=1.0
Type=Application
Terminal=false
Name=Solian
Exec=island %u
Icon=icon-padded
Categories=Network;

Binary file not shown.

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:island/database/drift_db.dart';
import 'package:island/database/message.dart';
@ -45,6 +48,13 @@ class MessageRepository {
},
);
for (final item in resp.data['changes']) {
if (item['message']['sender']['account'] == null)
log(jsonEncode(item['message']['sender']['account']));
// if (item['message'] != null &&
// item['message']['sender']['account'] == null) {
// }
}
final response = MessageSyncResponse.fromJson(resp.data);
for (final change in response.changes) {
switch (change.action) {
@ -299,7 +309,7 @@ class MessageRepository {
}
Future<LocalChatMessage> retryMessage(String pendingMessageId) async {
final message = pendingMessages[pendingMessageId];
final message = await getMessageById(pendingMessageId);
if (message == null) {
throw Exception('Message not found');
}

View File

@ -10,13 +10,10 @@ sealed class SnActivity with _$SnActivity {
required String id,
required String type,
required String resourceIdentifier,
required int visibility,
required String accountId,
required SnAccount account,
required dynamic data,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required DateTime? deletedAt,
}) = _SnActivity;
factory SnActivity.fromJson(Map<String, dynamic> json) =>

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnActivity {
String get id; String get type; String get resourceIdentifier; int get visibility; String get accountId; SnAccount get account; dynamic get data; DateTime get createdAt; DateTime get updatedAt; dynamic get deletedAt;
String get id; String get type; String get resourceIdentifier; dynamic get data; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -29,16 +29,16 @@ $SnActivityCopyWith<SnActivity> get copyWith => _$SnActivityCopyWithImpl<SnActiv
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other.deletedAt, deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,visibility,accountId,account,const DeepCollectionEquality().hash(data),createdAt,updatedAt,const DeepCollectionEquality().hash(deletedAt));
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const DeepCollectionEquality().hash(data),createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, visibility: $visibility, accountId: $accountId, account: $account, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@ -49,11 +49,11 @@ abstract mixin class $SnActivityCopyWith<$Res> {
factory $SnActivityCopyWith(SnActivity value, $Res Function(SnActivity) _then) = _$SnActivityCopyWithImpl;
@useResult
$Res call({
String id, String type, String resourceIdentifier, int visibility, String accountId, SnAccount account, dynamic data, DateTime createdAt, DateTime updatedAt, dynamic deletedAt
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
$SnAccountCopyWith<$Res> get account;
}
/// @nodoc
@ -66,31 +66,19 @@ class _$SnActivityCopyWithImpl<$Res>
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? visibility = null,Object? accountId = null,Object? account = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,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 dynamic,
as DateTime?,
));
}
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
}
@ -98,19 +86,16 @@ $SnAccountCopyWith<$Res> get account {
@JsonSerializable()
class _SnActivity implements SnActivity {
const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.visibility, required this.accountId, required this.account, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
const _SnActivity({required this.id, required this.type, required this.resourceIdentifier, required this.data, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnActivity.fromJson(Map<String, dynamic> json) => _$SnActivityFromJson(json);
@override final String id;
@override final String type;
@override final String resourceIdentifier;
@override final int visibility;
@override final String accountId;
@override final SnAccount account;
@override final dynamic data;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final dynamic deletedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@ -125,16 +110,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&const DeepCollectionEquality().equals(other.deletedAt, deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnActivity&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,visibility,accountId,account,const DeepCollectionEquality().hash(data),createdAt,updatedAt,const DeepCollectionEquality().hash(deletedAt));
int get hashCode => Object.hash(runtimeType,id,type,resourceIdentifier,const DeepCollectionEquality().hash(data),createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, visibility: $visibility, accountId: $accountId, account: $account, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnActivity(id: $id, type: $type, resourceIdentifier: $resourceIdentifier, data: $data, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@ -145,11 +130,11 @@ abstract mixin class _$SnActivityCopyWith<$Res> implements $SnActivityCopyWith<$
factory _$SnActivityCopyWith(_SnActivity value, $Res Function(_SnActivity) _then) = __$SnActivityCopyWithImpl;
@override @useResult
$Res call({
String id, String type, String resourceIdentifier, int visibility, String accountId, SnAccount account, dynamic data, DateTime createdAt, DateTime updatedAt, dynamic deletedAt
String id, String type, String resourceIdentifier, dynamic data, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@override $SnAccountCopyWith<$Res> get account;
}
/// @nodoc
@ -162,32 +147,20 @@ class __$SnActivityCopyWithImpl<$Res>
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? visibility = null,Object? accountId = null,Object? account = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? resourceIdentifier = null,Object? data = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnActivity(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,visibility: null == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,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 dynamic,
as DateTime?,
));
}
/// Create a copy of SnActivity
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res> get account {
return $SnAccountCopyWith<$Res>(_self.account, (value) {
return _then(_self.copyWith(account: value));
});
}
}

View File

@ -10,13 +10,13 @@ _SnActivity _$SnActivityFromJson(Map<String, dynamic> json) => _SnActivity(
id: json['id'] as String,
type: json['type'] as String,
resourceIdentifier: json['resource_identifier'] as String,
visibility: (json['visibility'] as num).toInt(),
accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
deletedAt:
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
@ -24,13 +24,10 @@ Map<String, dynamic> _$SnActivityToJson(_SnActivity instance) =>
'id': instance.id,
'type': instance.type,
'resource_identifier': instance.resourceIdentifier,
'visibility': instance.visibility,
'account_id': instance.accountId,
'account': instance.account.toJson(),
'data': instance.data,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnCheckInResult _$SnCheckInResultFromJson(Map<String, dynamic> json) =>

View File

@ -18,13 +18,18 @@ sealed class SnAuthChallenge with _$SnAuthChallenge {
required DateTime expiredAt,
required int stepRemain,
required int stepTotal,
required int failedAttempts,
required int platform,
required int type,
required List<String> blacklistFactors,
required List<String> audiences,
required List<String> scopes,
required List<dynamic> audiences,
required List<dynamic> scopes,
required String ipAddress,
required String userAgent,
required String? deviceId,
required String deviceId,
required String? nonce,
required String? location,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
@ -34,6 +39,25 @@ sealed class SnAuthChallenge with _$SnAuthChallenge {
_$SnAuthChallengeFromJson(json);
}
@freezed
sealed class SnAuthSession with _$SnAuthSession {
const factory SnAuthSession({
required String id,
required String? label,
required DateTime lastGrantedAt,
required DateTime expiredAt,
required String accountId,
required String challengeId,
required SnAuthChallenge challenge,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnAuthSession;
factory SnAuthSession.fromJson(Map<String, dynamic> json) =>
_$SnAuthSessionFromJson(json);
}
@freezed
sealed class SnAuthFactor with _$SnAuthFactor {
const factory SnAuthFactor({
@ -42,8 +66,28 @@ sealed class SnAuthFactor with _$SnAuthFactor {
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required DateTime? expiredAt,
required DateTime? enabledAt,
required int trustworthy,
required Map<String, dynamic>? createdResponse,
}) = _SnAuthFactor;
factory SnAuthFactor.fromJson(Map<String, dynamic> json) =>
_$SnAuthFactorFromJson(json);
}
@freezed
sealed class SnAuthDevice with _$SnAuthDevice {
const factory SnAuthDevice({
required dynamic label,
required String userAgent,
required String deviceId,
required int platform,
required List<SnAuthSession> sessions,
// Not from backend, used for UI
@Default(false) bool isCurrent,
}) = _SnAuthDevice;
factory SnAuthDevice.fromJson(Map<String, dynamic> json) =>
_$SnAuthDeviceFromJson(json);
}

View File

@ -149,7 +149,7 @@ as String,
/// @nodoc
mixin _$SnAuthChallenge {
String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; List<String> get blacklistFactors; List<String> get audiences; List<String> get scopes; String get ipAddress; String get userAgent; String? get deviceId; String? get nonce; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get platform; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String get deviceId; String? get nonce; String? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -162,16 +162,16 @@ $SnAuthChallengeCopyWith<SnAuthChallenge> get copyWith => _$SnAuthChallengeCopyW
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.blacklistFactors, blacklistFactors)&&const DeepCollectionEquality().equals(other.audiences, audiences)&&const DeepCollectionEquality().equals(other.scopes, scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(blacklistFactors),const DeepCollectionEquality().hash(audiences),const DeepCollectionEquality().hash(scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]);
@override
String toString() {
return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@ -182,7 +182,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res> {
factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl;
@useResult
$Res call({
String id, DateTime expiredAt, int stepRemain, int stepTotal, List<String> blacklistFactors, List<String> audiences, List<String> scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@ -199,20 +199,25 @@ class _$SnAuthChallengeCopyWithImpl<$Res>
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable
as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable
as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable
as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,blacklistFactors: null == blacklistFactors ? _self.blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable
as List<String>,audiences: null == audiences ? _self.audiences : audiences // ignore: cast_nullable_to_non_nullable
as List<String>,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable
as List<String>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable
as List<dynamic>,scopes: null == scopes ? _self.scopes : scopes // ignore: cast_nullable_to_non_nullable
as List<dynamic>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable
as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable
as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,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?,
@ -226,13 +231,16 @@ as DateTime?,
@JsonSerializable()
class _SnAuthChallenge implements SnAuthChallenge {
const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required final List<String> blacklistFactors, required final List<String> audiences, required final List<String> scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes;
const _SnAuthChallenge({required this.id, required this.expiredAt, required this.stepRemain, required this.stepTotal, required this.failedAttempts, required this.platform, required this.type, required final List<String> blacklistFactors, required final List<dynamic> audiences, required final List<dynamic> scopes, required this.ipAddress, required this.userAgent, required this.deviceId, required this.nonce, required this.location, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}): _blacklistFactors = blacklistFactors,_audiences = audiences,_scopes = scopes;
factory _SnAuthChallenge.fromJson(Map<String, dynamic> json) => _$SnAuthChallengeFromJson(json);
@override final String id;
@override final DateTime expiredAt;
@override final int stepRemain;
@override final int stepTotal;
@override final int failedAttempts;
@override final int platform;
@override final int type;
final List<String> _blacklistFactors;
@override List<String> get blacklistFactors {
if (_blacklistFactors is EqualUnmodifiableListView) return _blacklistFactors;
@ -240,15 +248,15 @@ class _SnAuthChallenge implements SnAuthChallenge {
return EqualUnmodifiableListView(_blacklistFactors);
}
final List<String> _audiences;
@override List<String> get audiences {
final List<dynamic> _audiences;
@override List<dynamic> get audiences {
if (_audiences is EqualUnmodifiableListView) return _audiences;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_audiences);
}
final List<String> _scopes;
@override List<String> get scopes {
final List<dynamic> _scopes;
@override List<dynamic> get scopes {
if (_scopes is EqualUnmodifiableListView) return _scopes;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_scopes);
@ -256,8 +264,10 @@ class _SnAuthChallenge implements SnAuthChallenge {
@override final String ipAddress;
@override final String userAgent;
@override final String? deviceId;
@override final String deviceId;
@override final String? nonce;
@override final String? location;
@override final String accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
@ -275,16 +285,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthChallenge&&(identical(other.id, id) || other.id == id)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.stepRemain, stepRemain) || other.stepRemain == stepRemain)&&(identical(other.stepTotal, stepTotal) || other.stepTotal == stepTotal)&&(identical(other.failedAttempts, failedAttempts) || other.failedAttempts == failedAttempts)&&(identical(other.platform, platform) || other.platform == platform)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._blacklistFactors, _blacklistFactors)&&const DeepCollectionEquality().equals(other._audiences, _audiences)&&const DeepCollectionEquality().equals(other._scopes, _scopes)&&(identical(other.ipAddress, ipAddress) || other.ipAddress == ipAddress)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.nonce, nonce) || other.nonce == nonce)&&(identical(other.location, location) || other.location == location)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,expiredAt,stepRemain,stepTotal,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hashAll([runtimeType,id,expiredAt,stepRemain,stepTotal,failedAttempts,platform,type,const DeepCollectionEquality().hash(_blacklistFactors),const DeepCollectionEquality().hash(_audiences),const DeepCollectionEquality().hash(_scopes),ipAddress,userAgent,deviceId,nonce,location,accountId,createdAt,updatedAt,deletedAt]);
@override
String toString() {
return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAuthChallenge(id: $id, expiredAt: $expiredAt, stepRemain: $stepRemain, stepTotal: $stepTotal, failedAttempts: $failedAttempts, platform: $platform, type: $type, blacklistFactors: $blacklistFactors, audiences: $audiences, scopes: $scopes, ipAddress: $ipAddress, userAgent: $userAgent, deviceId: $deviceId, nonce: $nonce, location: $location, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
@ -295,7 +305,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge
factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl;
@override @useResult
$Res call({
String id, DateTime expiredAt, int stepRemain, int stepTotal, List<String> blacklistFactors, List<String> audiences, List<String> scopes, String ipAddress, String userAgent, String? deviceId, String? nonce, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int platform, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String deviceId, String? nonce, String? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@ -312,20 +322,25 @@ class __$SnAuthChallengeCopyWithImpl<$Res>
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = freezed,Object? nonce = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? platform = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? deviceId = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAuthChallenge(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable
as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable
as int,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable
as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,blacklistFactors: null == blacklistFactors ? _self._blacklistFactors : blacklistFactors // ignore: cast_nullable_to_non_nullable
as List<String>,audiences: null == audiences ? _self._audiences : audiences // ignore: cast_nullable_to_non_nullable
as List<String>,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable
as List<String>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable
as List<dynamic>,scopes: null == scopes ? _self._scopes : scopes // ignore: cast_nullable_to_non_nullable
as List<dynamic>,ipAddress: null == ipAddress ? _self.ipAddress : ipAddress // ignore: cast_nullable_to_non_nullable
as String,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable
as String,deviceId: freezed == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String?,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String?,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,nonce: freezed == nonce ? _self.nonce : nonce // ignore: cast_nullable_to_non_nullable
as String?,location: freezed == location ? _self.location : location // ignore: cast_nullable_to_non_nullable
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,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?,
@ -336,10 +351,188 @@ as DateTime?,
}
/// @nodoc
mixin _$SnAuthSession {
String get id; String? get label; DateTime get lastGrantedAt; DateTime get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnAuthSessionCopyWith<SnAuthSession> get copyWith => _$SnAuthSessionCopyWithImpl<SnAuthSession>(this as SnAuthSession, _$identity);
/// Serializes this SnAuthSession to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthSession&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.lastGrantedAt, lastGrantedAt) || other.lastGrantedAt == lastGrantedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.challengeId, challengeId) || other.challengeId == challengeId)&&(identical(other.challenge, challenge) || other.challenge == challenge)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,label,lastGrantedAt,expiredAt,accountId,challengeId,challenge,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnAuthSession(id: $id, label: $label, lastGrantedAt: $lastGrantedAt, expiredAt: $expiredAt, accountId: $accountId, challengeId: $challengeId, challenge: $challenge, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $SnAuthSessionCopyWith<$Res> {
factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl;
@useResult
$Res call({
String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
$SnAuthChallengeCopyWith<$Res> get challenge;
}
/// @nodoc
class _$SnAuthSessionCopyWithImpl<$Res>
implements $SnAuthSessionCopyWith<$Res> {
_$SnAuthSessionCopyWithImpl(this._self, this._then);
final SnAuthSession _self;
final $Res Function(SnAuthSession) _then;
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable
as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable
as SnAuthChallenge,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?,
));
}
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAuthChallengeCopyWith<$Res> get challenge {
return $SnAuthChallengeCopyWith<$Res>(_self.challenge, (value) {
return _then(_self.copyWith(challenge: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _SnAuthSession implements SnAuthSession {
const _SnAuthSession({required this.id, required this.label, required this.lastGrantedAt, required this.expiredAt, required this.accountId, required this.challengeId, required this.challenge, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnAuthSession.fromJson(Map<String, dynamic> json) => _$SnAuthSessionFromJson(json);
@override final String id;
@override final String? label;
@override final DateTime lastGrantedAt;
@override final DateTime expiredAt;
@override final String accountId;
@override final String challengeId;
@override final SnAuthChallenge challenge;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnAuthSessionCopyWith<_SnAuthSession> get copyWith => __$SnAuthSessionCopyWithImpl<_SnAuthSession>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnAuthSessionToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthSession&&(identical(other.id, id) || other.id == id)&&(identical(other.label, label) || other.label == label)&&(identical(other.lastGrantedAt, lastGrantedAt) || other.lastGrantedAt == lastGrantedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.challengeId, challengeId) || other.challengeId == challengeId)&&(identical(other.challenge, challenge) || other.challenge == challenge)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,label,lastGrantedAt,expiredAt,accountId,challengeId,challenge,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnAuthSession(id: $id, label: $label, lastGrantedAt: $lastGrantedAt, expiredAt: $expiredAt, accountId: $accountId, challengeId: $challengeId, challenge: $challenge, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$SnAuthSessionCopyWith<$Res> implements $SnAuthSessionCopyWith<$Res> {
factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl;
@override @useResult
$Res call({
String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@override $SnAuthChallengeCopyWith<$Res> get challenge;
}
/// @nodoc
class __$SnAuthSessionCopyWithImpl<$Res>
implements _$SnAuthSessionCopyWith<$Res> {
__$SnAuthSessionCopyWithImpl(this._self, this._then);
final _SnAuthSession _self;
final $Res Function(_SnAuthSession) _then;
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAuthSession(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable
as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable
as SnAuthChallenge,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?,
));
}
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAuthChallengeCopyWith<$Res> get challenge {
return $SnAuthChallengeCopyWith<$Res>(_self.challenge, (value) {
return _then(_self.copyWith(challenge: value));
});
}
}
/// @nodoc
mixin _$SnAuthFactor {
String get id; int get type; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; int get type; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; DateTime? get expiredAt; DateTime? get enabledAt; int get trustworthy; Map<String, dynamic>? get createdResponse;
/// Create a copy of SnAuthFactor
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -352,16 +545,16 @@ $SnAuthFactorCopyWith<SnAuthFactor> get copyWith => _$SnAuthFactorCopyWithImpl<S
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.enabledAt, enabledAt) || other.enabledAt == enabledAt)&&(identical(other.trustworthy, trustworthy) || other.trustworthy == trustworthy)&&const DeepCollectionEquality().equals(other.createdResponse, createdResponse));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt,expiredAt,enabledAt,trustworthy,const DeepCollectionEquality().hash(createdResponse));
@override
String toString() {
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, enabledAt: $enabledAt, trustworthy: $trustworthy, createdResponse: $createdResponse)';
}
@ -372,7 +565,7 @@ abstract mixin class $SnAuthFactorCopyWith<$Res> {
factory $SnAuthFactorCopyWith(SnAuthFactor value, $Res Function(SnAuthFactor) _then) = _$SnAuthFactorCopyWithImpl;
@useResult
$Res call({
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, DateTime? expiredAt, DateTime? enabledAt, int trustworthy, Map<String, dynamic>? createdResponse
});
@ -389,14 +582,18 @@ class _$SnAuthFactorCopyWithImpl<$Res>
/// Create a copy of SnAuthFactor
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? expiredAt = freezed,Object? enabledAt = freezed,Object? trustworthy = null,Object? createdResponse = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // 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?,
as DateTime?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,enabledAt: freezed == enabledAt ? _self.enabledAt : enabledAt // ignore: cast_nullable_to_non_nullable
as DateTime?,trustworthy: null == trustworthy ? _self.trustworthy : trustworthy // ignore: cast_nullable_to_non_nullable
as int,createdResponse: freezed == createdResponse ? _self.createdResponse : createdResponse // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
));
}
@ -407,7 +604,7 @@ as DateTime?,
@JsonSerializable()
class _SnAuthFactor implements SnAuthFactor {
const _SnAuthFactor({required this.id, required this.type, required this.createdAt, required this.updatedAt, required this.deletedAt});
const _SnAuthFactor({required this.id, required this.type, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.expiredAt, required this.enabledAt, required this.trustworthy, required final Map<String, dynamic>? createdResponse}): _createdResponse = createdResponse;
factory _SnAuthFactor.fromJson(Map<String, dynamic> json) => _$SnAuthFactorFromJson(json);
@override final String id;
@ -415,6 +612,18 @@ class _SnAuthFactor implements SnAuthFactor {
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
@override final DateTime? expiredAt;
@override final DateTime? enabledAt;
@override final int trustworthy;
final Map<String, dynamic>? _createdResponse;
@override Map<String, dynamic>? get createdResponse {
final value = _createdResponse;
if (value == null) return null;
if (_createdResponse is EqualUnmodifiableMapView) return _createdResponse;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
/// Create a copy of SnAuthFactor
/// with the given fields replaced by the non-null parameter values.
@ -429,16 +638,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.enabledAt, enabledAt) || other.enabledAt == enabledAt)&&(identical(other.trustworthy, trustworthy) || other.trustworthy == trustworthy)&&const DeepCollectionEquality().equals(other._createdResponse, _createdResponse));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt);
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt,expiredAt,enabledAt,trustworthy,const DeepCollectionEquality().hash(_createdResponse));
@override
String toString() {
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, enabledAt: $enabledAt, trustworthy: $trustworthy, createdResponse: $createdResponse)';
}
@ -449,7 +658,7 @@ abstract mixin class _$SnAuthFactorCopyWith<$Res> implements $SnAuthFactorCopyWi
factory _$SnAuthFactorCopyWith(_SnAuthFactor value, $Res Function(_SnAuthFactor) _then) = __$SnAuthFactorCopyWithImpl;
@override @useResult
$Res call({
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, DateTime? expiredAt, DateTime? enabledAt, int trustworthy, Map<String, dynamic>? createdResponse
});
@ -466,14 +675,174 @@ class __$SnAuthFactorCopyWithImpl<$Res>
/// Create a copy of SnAuthFactor
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? expiredAt = freezed,Object? enabledAt = freezed,Object? trustworthy = null,Object? createdResponse = freezed,}) {
return _then(_SnAuthFactor(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // 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?,
as DateTime?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,enabledAt: freezed == enabledAt ? _self.enabledAt : enabledAt // ignore: cast_nullable_to_non_nullable
as DateTime?,trustworthy: null == trustworthy ? _self.trustworthy : trustworthy // ignore: cast_nullable_to_non_nullable
as int,createdResponse: freezed == createdResponse ? _self._createdResponse : createdResponse // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
));
}
}
/// @nodoc
mixin _$SnAuthDevice {
dynamic get label; String get userAgent; String get deviceId; int get platform; List<SnAuthSession> get sessions;// Not from backend, used for UI
bool get isCurrent;
/// Create a copy of SnAuthDevice
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnAuthDeviceCopyWith<SnAuthDevice> get copyWith => _$SnAuthDeviceCopyWithImpl<SnAuthDevice>(this as SnAuthDevice, _$identity);
/// Serializes this SnAuthDevice to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other.sessions, sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(sessions),isCurrent);
@override
String toString() {
return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)';
}
}
/// @nodoc
abstract mixin class $SnAuthDeviceCopyWith<$Res> {
factory $SnAuthDeviceCopyWith(SnAuthDevice value, $Res Function(SnAuthDevice) _then) = _$SnAuthDeviceCopyWithImpl;
@useResult
$Res call({
dynamic label, String userAgent, String deviceId, int platform, List<SnAuthSession> sessions, bool isCurrent
});
}
/// @nodoc
class _$SnAuthDeviceCopyWithImpl<$Res>
implements $SnAuthDeviceCopyWith<$Res> {
_$SnAuthDeviceCopyWithImpl(this._self, this._then);
final SnAuthDevice _self;
final $Res Function(SnAuthDevice) _then;
/// Create a copy of SnAuthDevice
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) {
return _then(_self.copyWith(
label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable
as int,sessions: null == sessions ? _self.sessions : sessions // ignore: cast_nullable_to_non_nullable
as List<SnAuthSession>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnAuthDevice implements SnAuthDevice {
const _SnAuthDevice({required this.label, required this.userAgent, required this.deviceId, required this.platform, required final List<SnAuthSession> sessions, this.isCurrent = false}): _sessions = sessions;
factory _SnAuthDevice.fromJson(Map<String, dynamic> json) => _$SnAuthDeviceFromJson(json);
@override final dynamic label;
@override final String userAgent;
@override final String deviceId;
@override final int platform;
final List<SnAuthSession> _sessions;
@override List<SnAuthSession> get sessions {
if (_sessions is EqualUnmodifiableListView) return _sessions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_sessions);
}
// Not from backend, used for UI
@override@JsonKey() final bool isCurrent;
/// Create a copy of SnAuthDevice
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnAuthDeviceCopyWith<_SnAuthDevice> get copyWith => __$SnAuthDeviceCopyWithImpl<_SnAuthDevice>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnAuthDeviceToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthDevice&&const DeepCollectionEquality().equals(other.label, label)&&(identical(other.userAgent, userAgent) || other.userAgent == userAgent)&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.platform, platform) || other.platform == platform)&&const DeepCollectionEquality().equals(other._sessions, _sessions)&&(identical(other.isCurrent, isCurrent) || other.isCurrent == isCurrent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(label),userAgent,deviceId,platform,const DeepCollectionEquality().hash(_sessions),isCurrent);
@override
String toString() {
return 'SnAuthDevice(label: $label, userAgent: $userAgent, deviceId: $deviceId, platform: $platform, sessions: $sessions, isCurrent: $isCurrent)';
}
}
/// @nodoc
abstract mixin class _$SnAuthDeviceCopyWith<$Res> implements $SnAuthDeviceCopyWith<$Res> {
factory _$SnAuthDeviceCopyWith(_SnAuthDevice value, $Res Function(_SnAuthDevice) _then) = __$SnAuthDeviceCopyWithImpl;
@override @useResult
$Res call({
dynamic label, String userAgent, String deviceId, int platform, List<SnAuthSession> sessions, bool isCurrent
});
}
/// @nodoc
class __$SnAuthDeviceCopyWithImpl<$Res>
implements _$SnAuthDeviceCopyWith<$Res> {
__$SnAuthDeviceCopyWithImpl(this._self, this._then);
final _SnAuthDevice _self;
final $Res Function(_SnAuthDevice) _then;
/// Create a copy of SnAuthDevice
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? label = freezed,Object? userAgent = null,Object? deviceId = null,Object? platform = null,Object? sessions = null,Object? isCurrent = null,}) {
return _then(_SnAuthDevice(
label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as dynamic,userAgent: null == userAgent ? _self.userAgent : userAgent // ignore: cast_nullable_to_non_nullable
as String,deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,platform: null == platform ? _self.platform : platform // ignore: cast_nullable_to_non_nullable
as int,sessions: null == sessions ? _self._sessions : sessions // ignore: cast_nullable_to_non_nullable
as List<SnAuthSession>,isCurrent: null == isCurrent ? _self.isCurrent : isCurrent // ignore: cast_nullable_to_non_nullable
as bool,
));
}

View File

@ -19,18 +19,21 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
expiredAt: DateTime.parse(json['expired_at'] as String),
stepRemain: (json['step_remain'] as num).toInt(),
stepTotal: (json['step_total'] as num).toInt(),
failedAttempts: (json['failed_attempts'] as num).toInt(),
platform: (json['platform'] as num).toInt(),
type: (json['type'] as num).toInt(),
blacklistFactors:
(json['blacklist_factors'] as List<dynamic>)
.map((e) => e as String)
.toList(),
audiences:
(json['audiences'] as List<dynamic>).map((e) => e as String).toList(),
scopes:
(json['scopes'] as List<dynamic>).map((e) => e as String).toList(),
audiences: json['audiences'] as List<dynamic>,
scopes: json['scopes'] as List<dynamic>,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
deviceId: json['device_id'] as String?,
deviceId: json['device_id'] as String,
nonce: json['nonce'] as String?,
location: json['location'] as String?,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
@ -45,6 +48,9 @@ Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
'expired_at': instance.expiredAt.toIso8601String(),
'step_remain': instance.stepRemain,
'step_total': instance.stepTotal,
'failed_attempts': instance.failedAttempts,
'platform': instance.platform,
'type': instance.type,
'blacklist_factors': instance.blacklistFactors,
'audiences': instance.audiences,
'scopes': instance.scopes,
@ -52,6 +58,41 @@ Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
'user_agent': instance.userAgent,
'device_id': instance.deviceId,
'nonce': instance.nonce,
'location': instance.location,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) =>
_SnAuthSession(
id: json['id'] as String,
label: json['label'] as String?,
lastGrantedAt: DateTime.parse(json['last_granted_at'] as String),
expiredAt: DateTime.parse(json['expired_at'] as String),
accountId: json['account_id'] as String,
challengeId: json['challenge_id'] as String,
challenge: SnAuthChallenge.fromJson(
json['challenge'] as Map<String, dynamic>,
),
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),
);
Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) =>
<String, dynamic>{
'id': instance.id,
'label': instance.label,
'last_granted_at': instance.lastGrantedAt.toIso8601String(),
'expired_at': instance.expiredAt.toIso8601String(),
'account_id': instance.accountId,
'challenge_id': instance.challengeId,
'challenge': instance.challenge.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
@ -67,6 +108,16 @@ _SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
enabledAt:
json['enabled_at'] == null
? null
: DateTime.parse(json['enabled_at'] as String),
trustworthy: (json['trustworthy'] as num).toInt(),
createdResponse: json['created_response'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
@ -76,4 +127,31 @@ Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'expired_at': instance.expiredAt?.toIso8601String(),
'enabled_at': instance.enabledAt?.toIso8601String(),
'trustworthy': instance.trustworthy,
'created_response': instance.createdResponse,
};
_SnAuthDevice _$SnAuthDeviceFromJson(Map<String, dynamic> json) =>
_SnAuthDevice(
label: json['label'],
userAgent: json['user_agent'] as String,
deviceId: json['device_id'] as String,
platform: (json['platform'] as num).toInt(),
sessions:
(json['sessions'] as List<dynamic>)
.map((e) => SnAuthSession.fromJson(e as Map<String, dynamic>))
.toList(),
isCurrent: json['is_current'] as bool? ?? false,
);
Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) =>
<String, dynamic>{
'label': instance.label,
'user_agent': instance.userAgent,
'device_id': instance.deviceId,
'platform': instance.platform,
'sessions': instance.sessions.map((e) => e.toJson()).toList(),
'is_current': instance.isCurrent,
};

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';
@ -88,6 +91,7 @@ sealed class SnChatMember with _$SnChatMember {
required int notify,
required DateTime? joinedAt,
required bool isBot,
DateTime? lastTyped,
}) = _SnChatMember;
factory SnChatMember.fromJson(Map<String, dynamic> json) =>

View File

@ -663,7 +663,7 @@ $SnChatMemberCopyWith<$Res> get sender {
/// @nodoc
mixin _$SnChatMember {
DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; bool get isBot;
DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get chatRoomId; SnChatRoom? get chatRoom; String get accountId; SnAccount get account; String? get nick; int get role; int get notify; DateTime? get joinedAt; bool get isBot; DateTime? get lastTyped;
/// Create a copy of SnChatMember
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -676,16 +676,16 @@ $SnChatMemberCopyWith<SnChatMember> get copyWith => _$SnChatMemberCopyWithImpl<S
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot));
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot);
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped);
@override
String toString() {
return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot)';
return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)';
}
@ -696,7 +696,7 @@ abstract mixin class $SnChatMemberCopyWith<$Res> {
factory $SnChatMemberCopyWith(SnChatMember value, $Res Function(SnChatMember) _then) = _$SnChatMemberCopyWithImpl;
@useResult
$Res call({
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped
});
@ -713,7 +713,7 @@ class _$SnChatMemberCopyWithImpl<$Res>
/// Create a copy of SnChatMember
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) {
return _then(_self.copyWith(
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
@ -728,7 +728,8 @@ as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_no
as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable
as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable
as bool,
as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
/// Create a copy of SnChatMember
@ -760,7 +761,7 @@ $SnAccountCopyWith<$Res> get account {
@JsonSerializable()
class _SnChatMember implements SnChatMember {
const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.isBot});
const _SnChatMember({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.chatRoomId, required this.chatRoom, required this.accountId, required this.account, required this.nick, required this.role, required this.notify, required this.joinedAt, required this.isBot, this.lastTyped});
factory _SnChatMember.fromJson(Map<String, dynamic> json) => _$SnChatMemberFromJson(json);
@override final DateTime createdAt;
@ -776,6 +777,7 @@ class _SnChatMember implements SnChatMember {
@override final int notify;
@override final DateTime? joinedAt;
@override final bool isBot;
@override final DateTime? lastTyped;
/// Create a copy of SnChatMember
/// with the given fields replaced by the non-null parameter values.
@ -790,16 +792,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnChatMember&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.chatRoomId, chatRoomId) || other.chatRoomId == chatRoomId)&&(identical(other.chatRoom, chatRoom) || other.chatRoom == chatRoom)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.role, role) || other.role == role)&&(identical(other.notify, notify) || other.notify == notify)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.isBot, isBot) || other.isBot == isBot)&&(identical(other.lastTyped, lastTyped) || other.lastTyped == lastTyped));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot);
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,chatRoomId,chatRoom,accountId,account,nick,role,notify,joinedAt,isBot,lastTyped);
@override
String toString() {
return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot)';
return 'SnChatMember(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, chatRoomId: $chatRoomId, chatRoom: $chatRoom, accountId: $accountId, account: $account, nick: $nick, role: $role, notify: $notify, joinedAt: $joinedAt, isBot: $isBot, lastTyped: $lastTyped)';
}
@ -810,7 +812,7 @@ abstract mixin class _$SnChatMemberCopyWith<$Res> implements $SnChatMemberCopyWi
factory _$SnChatMemberCopyWith(_SnChatMember value, $Res Function(_SnChatMember) _then) = __$SnChatMemberCopyWithImpl;
@override @useResult
$Res call({
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String chatRoomId, SnChatRoom? chatRoom, String accountId, SnAccount account, String? nick, int role, int notify, DateTime? joinedAt, bool isBot, DateTime? lastTyped
});
@ -827,7 +829,7 @@ class __$SnChatMemberCopyWithImpl<$Res>
/// Create a copy of SnChatMember
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? chatRoomId = null,Object? chatRoom = freezed,Object? accountId = null,Object? account = null,Object? nick = freezed,Object? role = null,Object? notify = null,Object? joinedAt = freezed,Object? isBot = null,Object? lastTyped = freezed,}) {
return _then(_SnChatMember(
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
@ -842,7 +844,8 @@ as String?,role: null == role ? _self.role : role // ignore: cast_nullable_to_no
as int,notify: null == notify ? _self.notify : notify // ignore: cast_nullable_to_non_nullable
as int,joinedAt: freezed == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isBot: null == isBot ? _self.isBot : isBot // ignore: cast_nullable_to_non_nullable
as bool,
as bool,lastTyped: freezed == lastTyped ? _self.lastTyped : lastTyped // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}

View File

@ -167,6 +167,10 @@ _SnChatMember _$SnChatMemberFromJson(Map<String, dynamic> json) =>
? null
: DateTime.parse(json['joined_at'] as String),
isBot: json['is_bot'] as bool,
lastTyped:
json['last_typed'] == null
? null
: DateTime.parse(json['last_typed'] as String),
);
Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
@ -184,6 +188,7 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
'notify': instance.notify,
'joined_at': instance.joinedAt?.toIso8601String(),
'is_bot': instance.isBot,
'last_typed': instance.lastTyped?.toIso8601String(),
};
_SnChatSummary _$SnChatSummaryFromJson(Map<String, dynamic> json) =>

View File

@ -85,6 +85,24 @@ sealed class SnAccountBadge with _$SnAccountBadge {
_$SnAccountBadgeFromJson(json);
}
@freezed
sealed class SnContactMethod with _$SnContactMethod {
const factory SnContactMethod({
required String id,
required int type,
required DateTime? verifiedAt,
required bool isPrimary,
required String content,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnContactMethod;
factory SnContactMethod.fromJson(Map<String, dynamic> json) =>
_$SnContactMethodFromJson(json);
}
@freezed
sealed class SnNotification with _$SnNotification {
const factory SnNotification({

View File

@ -746,6 +746,163 @@ as DateTime?,
}
/// @nodoc
mixin _$SnContactMethod {
String get id; int get type; DateTime? get verifiedAt; bool get isPrimary; String get content; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnContactMethod
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnContactMethodCopyWith<SnContactMethod> get copyWith => _$SnContactMethodCopyWithImpl<SnContactMethod>(this as SnContactMethod, _$identity);
/// Serializes this SnContactMethod to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnContactMethod&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.verifiedAt, verifiedAt) || other.verifiedAt == verifiedAt)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.content, content) || other.content == content)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,verifiedAt,isPrimary,content,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnContactMethod(id: $id, type: $type, verifiedAt: $verifiedAt, isPrimary: $isPrimary, content: $content, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $SnContactMethodCopyWith<$Res> {
factory $SnContactMethodCopyWith(SnContactMethod value, $Res Function(SnContactMethod) _then) = _$SnContactMethodCopyWithImpl;
@useResult
$Res call({
String id, int type, DateTime? verifiedAt, bool isPrimary, String content, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
}
/// @nodoc
class _$SnContactMethodCopyWithImpl<$Res>
implements $SnContactMethodCopyWith<$Res> {
_$SnContactMethodCopyWithImpl(this._self, this._then);
final SnContactMethod _self;
final $Res Function(SnContactMethod) _then;
/// Create a copy of SnContactMethod
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? verifiedAt = freezed,Object? isPrimary = null,Object? content = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,verifiedAt: freezed == verifiedAt ? _self.verifiedAt : verifiedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable
as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,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?,
));
}
}
/// @nodoc
@JsonSerializable()
class _SnContactMethod implements SnContactMethod {
const _SnContactMethod({required this.id, required this.type, required this.verifiedAt, required this.isPrimary, required this.content, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnContactMethod.fromJson(Map<String, dynamic> json) => _$SnContactMethodFromJson(json);
@override final String id;
@override final int type;
@override final DateTime? verifiedAt;
@override final bool isPrimary;
@override final String content;
@override final String accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnContactMethod
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnContactMethodCopyWith<_SnContactMethod> get copyWith => __$SnContactMethodCopyWithImpl<_SnContactMethod>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnContactMethodToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnContactMethod&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.verifiedAt, verifiedAt) || other.verifiedAt == verifiedAt)&&(identical(other.isPrimary, isPrimary) || other.isPrimary == isPrimary)&&(identical(other.content, content) || other.content == content)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,type,verifiedAt,isPrimary,content,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnContactMethod(id: $id, type: $type, verifiedAt: $verifiedAt, isPrimary: $isPrimary, content: $content, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$SnContactMethodCopyWith<$Res> implements $SnContactMethodCopyWith<$Res> {
factory _$SnContactMethodCopyWith(_SnContactMethod value, $Res Function(_SnContactMethod) _then) = __$SnContactMethodCopyWithImpl;
@override @useResult
$Res call({
String id, int type, DateTime? verifiedAt, bool isPrimary, String content, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
}
/// @nodoc
class __$SnContactMethodCopyWithImpl<$Res>
implements _$SnContactMethodCopyWith<$Res> {
__$SnContactMethodCopyWithImpl(this._self, this._then);
final _SnContactMethod _self;
final $Res Function(_SnContactMethod) _then;
/// Create a copy of SnContactMethod
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? verifiedAt = freezed,Object? isPrimary = null,Object? content = null,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnContactMethod(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,verifiedAt: freezed == verifiedAt ? _self.verifiedAt : verifiedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,isPrimary: null == isPrimary ? _self.isPrimary : isPrimary // ignore: cast_nullable_to_non_nullable
as bool,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,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?,
));
}
}
/// @nodoc
mixin _$SnNotification {

View File

@ -157,6 +157,38 @@ Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnContactMethod _$SnContactMethodFromJson(Map<String, dynamic> json) =>
_SnContactMethod(
id: json['id'] as String,
type: (json['type'] as num).toInt(),
verifiedAt:
json['verified_at'] == null
? null
: DateTime.parse(json['verified_at'] as String),
isPrimary: json['is_primary'] as bool,
content: json['content'] as String,
accountId: json['account_id'] as String,
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),
);
Map<String, dynamic> _$SnContactMethodToJson(_SnContactMethod instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'verified_at': instance.verifiedAt?.toIso8601String(),
'is_primary': instance.isPrimary,
'content': instance.content,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) =>
_SnNotification(
createdAt: DateTime.parse(json['created_at'] as String),

View File

@ -329,7 +329,7 @@ class ChatListRouteArgs {
/// [_i7.ChatRoomScreen]
class ChatRoomRoute extends _i27.PageRouteInfo<ChatRoomRouteArgs> {
ChatRoomRoute({
_i28.Key? key,
_i29.Key? key,
required String id,
List<_i27.PageRouteInfo>? children,
}) : super(
@ -356,7 +356,7 @@ class ChatRoomRoute extends _i27.PageRouteInfo<ChatRoomRouteArgs> {
class ChatRoomRouteArgs {
const ChatRoomRouteArgs({this.key, required this.id});
final _i28.Key? key;
final _i29.Key? key;
final String id;

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:auto_route/annotations.dart';
@ -5,16 +6,45 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/auth.dart';
import 'package:island/models/user.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/captcha.dart';
import 'package:island/screens/auth/login.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_session_sheet.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'settings.g.dart';
@riverpod
Future<List<SnAuthFactor>> authFactors(Ref ref) async {
final client = ref.read(apiClientProvider);
final res = await client.get('/accounts/me/factors');
return res.data.map<SnAuthFactor>((e) => SnAuthFactor.fromJson(e)).toList();
}
@riverpod
Future<List<SnContactMethod>> contactMethods(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/accounts/me/contacts');
return resp.data
.map<SnContactMethod>((e) => SnContactMethod.fromJson(e))
.toList();
}
@RoutePage()
class AccountSettingsScreen extends HookConsumerWidget {
const AccountSettingsScreen({super.key});
@ -32,6 +62,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/accounts/me');
if (context.mounted) {
@ -39,13 +70,15 @@ class AccountSettingsScreen extends HookConsumerWidget {
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> requestResetPassword() async {
final confirm = await showConfirmAlert(
'accountPasswordChangeDescription'.tr(),
'accountPassword'.tr(),
'accountPasswordChange'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await Navigator.of(
@ -53,6 +86,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
if (captchaTk == null) return;
try {
showLoadingModal(context);
final userInfo = ref.read(userInfoProvider);
final client = ref.read(apiClientProvider);
await client.post(
@ -64,84 +98,237 @@ class AccountSettingsScreen extends HookConsumerWidget {
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
final authFactors = ref.watch(authFactorsProvider);
// Group settings into categories for better organization
final securitySettings = [
ListTile(
minLeadingWidth: 48,
title: Text('accountPassword').tr(),
subtitle: Text('accountPasswordDescription').tr().fontSize(12),
leading: const Icon(Symbols.devices),
title: Text('authSessions').tr(),
subtitle: Text('authSessionsDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.password),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
requestResetPassword();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const AccountSessionSheet(),
);
},
),
ListTile(
minLeadingWidth: 48,
title: Text('accountTwoFactor').tr(),
subtitle: Text('accountTwoFactorDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.security),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
// Navigate to two-factor authentication settings
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text('accountTwoFactor').tr(),
content: Text('accountTwoFactorSetupDescription').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
// Add navigation to 2FA setup screen
ExpansionTile(
leading: const Icon(
Symbols.security,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountAuthFactor').tr(),
subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
authFactors.when(
data:
(factors) => Column(
children: [
for (final factor in factors)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 16,
right: 17,
top: 2,
bottom: 4,
),
title:
Text(
kFactorTypes[factor.type]!.$1,
style:
factor.enabledAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
).tr(),
subtitle:
Text(
kFactorTypes[factor.type]!.$2,
style:
factor.enabledAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
).tr(),
leading: CircleAvatar(
backgroundColor:
factor.enabledAt == null
? Theme.of(
context,
).colorScheme.secondaryContainer
: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(kFactorTypes[factor.type]!.$3),
).padding(top: 4),
trailing: const Icon(Symbols.chevron_right),
isThreeLine: true,
onTap: () {
if (factor.type == 0) {
requestResetPassword();
return;
}
showModalBottomSheet(
context: context,
builder:
(context) => _AuthFactorSheet(factor: factor),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
),
if (factors.isNotEmpty) Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
title: Text('authFactorNew').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => const _AuthFactorNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
child: Text('accountTwoFactorSetup').tr(),
),
],
),
);
},
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(authFactorsProvider),
),
loading: () => ResponseLoadingWidget(),
),
],
),
];
final privacySettings = [
// ListTile(
// minLeadingWidth: 48,
// title: Text('accountPrivacy').tr(),
// subtitle: Text('accountPrivacyDescription').tr().fontSize(12),
// contentPadding: const EdgeInsets.only(left: 24, right: 17),
// leading: const Icon(Symbols.visibility),
// trailing: const Icon(Symbols.chevron_right),
// onTap: () {
// // Navigate to privacy settings
// },
// ),
ListTile(
minLeadingWidth: 48,
title: Text('accountDataExport').tr(),
subtitle: Text('accountDataExportDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.download),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final confirm = await showConfirmAlert(
'accountDataExportConfirmation'.tr(),
'accountDataExport'.tr(),
);
if (!confirm || !context.mounted) return;
// Add data export logic
showSnackBar(context, 'accountDataExportRequested'.tr());
},
ExpansionTile(
leading: const Icon(
Symbols.contact_mail,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountContactMethod').tr(),
subtitle: Text('accountContactMethodDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
ref
.watch(contactMethodsProvider)
.when(
data:
(contacts) => Column(
children: [
for (final contact in contacts)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 16,
right: 17,
top: 2,
bottom: 4,
),
title: Text(
contact.content,
style:
contact.verifiedAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
),
subtitle: Text(
contact.type == 0
? 'contactMethodTypeEmail'.tr()
: 'contactMethodTypePhone'.tr(),
style:
contact.verifiedAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
),
leading: CircleAvatar(
backgroundColor:
contact.verifiedAt == null
? Theme.of(
context,
).colorScheme.secondaryContainer
: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(
contact.type == 0
? Symbols.mail
: Symbols.phone,
),
).padding(top: 4),
trailing: const Icon(Symbols.chevron_right),
isThreeLine: false,
onTap: () {
showModalBottomSheet(
context: context,
builder:
(context) =>
_ContactMethodSheet(contact: contact),
).then((value) {
if (value == true) {
ref.invalidate(contactMethodsProvider);
}
});
},
),
if (contacts.isNotEmpty) const Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
title: Text('contactMethodNew').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder:
(context) => const _ContactMethodNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(contactMethodsProvider);
}
});
},
),
],
),
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(contactMethodsProvider),
),
loading: () => const ResponseLoadingWidget(),
),
],
),
];
@ -172,10 +359,6 @@ class AccountSettingsScreen extends HookConsumerWidget {
title: 'accountSecurityTitle',
children: securitySettings,
),
_SettingsSection(
title: 'accountPrivacyTitle',
children: privacySettings,
),
],
),
),
@ -201,10 +384,6 @@ class AccountSettingsScreen extends HookConsumerWidget {
title: 'accountSecurityTitle',
children: securitySettings,
),
_SettingsSection(
title: 'accountPrivacyTitle',
children: privacySettings,
),
_SettingsSection(
title: 'accountDangerZoneTitle',
children: dangerZoneSettings,
@ -292,3 +471,599 @@ class _SettingsSection extends StatelessWidget {
);
}
}
class _AuthFactorSheet extends HookConsumerWidget {
final SnAuthFactor factor;
const _AuthFactorSheet({required this.factor});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> deleteFactor() async {
final confirm = await showConfirmAlert(
'authFactorDeleteHint'.tr(),
'authFactorDelete'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/accounts/me/factors/${factor.id}');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> disableFactor() async {
final confirm = await showConfirmAlert(
'authFactorDisableHint'.tr(),
'authFactorDisable'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/accounts/me/factors/${factor.id}/disable');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> enableFactor() async {
String? password;
if ([3].contains(factor.type)) {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('authFactorEnable').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('authFactorEnableHint').tr(),
const SizedBox(height: 16),
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
onSubmit: (String verificationCode) {
password = verificationCode;
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('confirm').tr(),
),
],
),
);
if (confirmed == false ||
(password?.isEmpty ?? true) ||
!context.mounted) {
return;
}
}
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post(
'/accounts/me/factors/${factor.id}/enable',
data: jsonEncode(password),
);
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'authFactor'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(kFactorTypes[factor.type]!.$3, size: 32),
const Gap(8),
Text(kFactorTypes[factor.type]!.$1).tr(),
const Gap(4),
Text(
kFactorTypes[factor.type]!.$2,
style: Theme.of(context).textTheme.bodySmall,
).tr(),
const Gap(10),
Row(
children: [
if (factor.enabledAt == null)
Badge(
label: Text('authFactorDisabled'.tr()),
textColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
)
else
Badge(
label: Text('authFactorEnabled'.tr()),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
],
),
],
).padding(all: 20),
const Divider(height: 1),
if (factor.enabledAt != null)
ListTile(
leading: const Icon(Symbols.disabled_by_default),
title: Text('authFactorDisable').tr(),
onTap: disableFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
)
else
ListTile(
leading: const Icon(Symbols.check_circle),
title: Text('authFactorEnable').tr(),
onTap: enableFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
ListTile(
leading: const Icon(Symbols.delete),
title: Text('authFactorDelete').tr(),
onTap: deleteFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
],
),
);
}
}
class _AuthFactorNewSheet extends HookConsumerWidget {
const _AuthFactorNewSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final factorType = useState<int>(0);
final secretController = useTextEditingController();
Future<void> addFactor() async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
final resp = await apiClient.post(
'/accounts/me/factors',
data: {'type': factorType.value, 'secret': secretController.text},
);
final factor = SnAuthFactor.fromJson(resp.data);
if (!context.mounted) return;
hideLoadingModal(context);
if (factor.type == 3) {
showModalBottomSheet(
context: context,
builder: (context) => _AuthFactorNewAdditonalSheet(factor: factor),
).then((_) {
if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
}
if (context.mounted) Navigator.pop(context, true);
});
} else {
Navigator.pop(context, true);
}
} catch (err) {
showErrorAlert(err);
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'authFactorNew'.tr(),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
value: factorType.value,
decoration: InputDecoration(
labelText: 'authFactor'.tr(),
border: const OutlineInputBorder(),
),
items:
kFactorTypes.entries.map((entry) {
return DropdownMenuItem<int>(
value: entry.key,
child: Row(
children: [
Icon(entry.value.$3),
const Gap(8),
Text(entry.value.$1).tr(),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
factorType.value = value;
}
},
),
if (factorType.value == 0)
TextField(
controller: secretController,
decoration: InputDecoration(
prefixIcon: const Icon(Symbols.password_2),
labelText: 'authFactorSecret'.tr(),
hintText: 'authFactorSecretHint'.tr(),
border: const OutlineInputBorder(),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(kFactorTypes[factorType.value]!.$2).tr(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: addFactor,
icon: Icon(Symbols.add),
label: Text('create').tr(),
),
],
),
],
).padding(horizontal: 20, vertical: 24),
);
}
}
class _AuthFactorNewAdditonalSheet extends StatelessWidget {
final SnAuthFactor factor;
const _AuthFactorNewAdditonalSheet({required this.factor});
@override
Widget build(BuildContext context) {
final uri = factor.createdResponse?['uri'];
return SheetScaffold(
titleText: 'authFactorAdditional'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (uri != null) ...[
const SizedBox(height: 16),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: QrImageView(
data: uri,
version: QrVersions.auto,
size: 200,
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
const Gap(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'authFactorQrCodeScan'.tr(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
] else ...[
const SizedBox(height: 16),
Center(
child: Text(
'authFactorNoQrCode'.tr(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
const Gap(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Symbols.check),
label: Text('next'.tr()),
),
),
],
),
);
}
}
class _ContactMethodSheet extends HookConsumerWidget {
final SnContactMethod contact;
const _ContactMethodSheet({required this.contact});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> deleteContactMethod() async {
final confirm = await showConfirmAlert(
'contactMethodDeleteHint'.tr(),
'contactMethodDelete'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/accounts/me/contacts/${contact.id}');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> verifyContactMethod() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/accounts/me/contacts/${contact.id}/verify');
if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationSent'.tr());
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> setContactMethodAsPrimary() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/accounts/me/contacts/${contact.id}/primary');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'contactMethod'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(switch (contact.type) {
0 => Symbols.mail,
1 => Symbols.phone,
_ => Symbols.home,
}, size: 32),
const Gap(8),
Text(switch (contact.type) {
0 => 'contactMethodTypeEmail'.tr(),
1 => 'contactMethodTypePhone'.tr(),
_ => 'contactMethodTypeAddress'.tr(),
}),
const Gap(4),
Text(
contact.content,
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(10),
Row(
children: [
if (contact.verifiedAt == null)
Badge(
label: Text('contactMethodUnverified'.tr()),
textColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
)
else
Badge(
label: Text('contactMethodVerified'.tr()),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
if (contact.isPrimary)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Badge(
label: Text('contactMethodPrimary'.tr()),
textColor: Theme.of(context).colorScheme.onTertiary,
backgroundColor: Theme.of(context).colorScheme.tertiary,
),
),
],
),
],
).padding(all: 20),
const Divider(height: 1),
if (contact.verifiedAt == null)
ListTile(
leading: const Icon(Symbols.verified),
title: Text('contactMethodVerify').tr(),
onTap: verifyContactMethod,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
if (contact.verifiedAt != null && !contact.isPrimary)
ListTile(
leading: const Icon(Symbols.star),
title: Text('contactMethodSetPrimary').tr(),
onTap: setContactMethodAsPrimary,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
ListTile(
leading: const Icon(Symbols.delete),
title: Text('contactMethodDelete').tr(),
onTap: deleteContactMethod,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
],
),
);
}
}
class _ContactMethodNewSheet extends HookConsumerWidget {
const _ContactMethodNewSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final contactType = useState<int>(0);
final contentController = useTextEditingController();
Future<void> addContactMethod() async {
if (contentController.text.isEmpty) {
showSnackBar(context, 'contactMethodContentEmpty'.tr());
return;
}
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
await apiClient.post(
'/accounts/me/contacts',
data: {'type': contactType.value, 'content': contentController.text},
);
if (context.mounted) {
showSnackBar(context, 'contactMethodVerificationNeeded'.tr());
Navigator.pop(context, true);
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'contactMethodNew'.tr(),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
value: contactType.value,
decoration: InputDecoration(
labelText: 'contactMethodType'.tr(),
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<int>(
value: 0,
child: Row(
children: [
Icon(Symbols.mail),
const Gap(8),
Text('contactMethodTypeEmail'.tr()),
],
),
),
DropdownMenuItem<int>(
value: 1,
child: Row(
children: [
Icon(Symbols.phone),
const Gap(8),
Text('contactMethodTypePhone'.tr()),
],
),
),
DropdownMenuItem<int>(
value: 2,
child: Row(
children: [
Icon(Symbols.home),
const Gap(8),
Text('contactMethodTypeAddress'.tr()),
],
),
),
],
onChanged: (value) {
if (value != null) {
contactType.value = value;
}
},
),
TextField(
controller: contentController,
decoration: InputDecoration(
prefixIcon: Icon(switch (contactType.value) {
0 => Symbols.mail,
1 => Symbols.phone,
_ => Symbols.home,
}),
labelText: switch (contactType.value) {
0 => 'contactMethodTypeEmail'.tr(),
1 => 'contactMethodTypePhone'.tr(),
_ => 'contactMethodTypeAddress'.tr(),
},
hintText: switch (contactType.value) {
0 => 'contactMethodEmailHint'.tr(),
1 => 'contactMethodPhoneHint'.tr(),
_ => 'contactMethodAddressHint'.tr(),
},
border: const OutlineInputBorder(),
),
keyboardType: switch (contactType.value) {
0 => TextInputType.emailAddress,
1 => TextInputType.phone,
_ => TextInputType.multiline,
},
maxLines: switch (contactType.value) {
2 => 3,
_ => 1,
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child:
Text(switch (contactType.value) {
0 => 'contactMethodEmailDescription',
1 => 'contactMethodPhoneDescription',
_ => 'contactMethodAddressDescription',
}).tr(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: addContactMethod,
icon: Icon(Symbols.add),
label: Text('create').tr(),
),
],
),
],
).padding(horizontal: 20, vertical: 24),
);
}
}

View File

@ -0,0 +1,48 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authFactorsHash() => r'4bb65bc0c065c4091c209ee81e57ddef41051ae2';
/// See also [authFactors].
@ProviderFor(authFactors)
final authFactorsProvider =
AutoDisposeFutureProvider<List<SnAuthFactor>>.internal(
authFactors,
name: r'authFactorsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$authFactorsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthFactorsRef = AutoDisposeFutureProviderRef<List<SnAuthFactor>>;
String _$contactMethodsHash() => r'4d7952fc196dce4dc646314565a49c115fd1d292';
/// See also [contactMethods].
@ProviderFor(contactMethods)
final contactMethodsProvider =
AutoDisposeFutureProvider<List<SnContactMethod>>.internal(
contactMethods,
name: r'contactMethodsProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$contactMethodsHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ContactMethodsRef = AutoDisposeFutureProviderRef<List<SnContactMethod>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,9 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:animations/animations.dart';
import 'package:auto_route/auto_route.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gap/gap.dart';
import 'package:island/models/auth.dart';
@ -24,12 +31,12 @@ import 'captcha.dart';
final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
3: (
2: (
'authFactorInAppNotify',
'authFactorInAppNotifyDescription',
Symbols.notifications_active,
),
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
};
@RoutePage()
@ -38,10 +45,13 @@ class LoginScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final period = useState(0);
final currentTicket = useState<SnAuthChallenge?>(null);
final factors = useState<List<SnAuthFactor>>([]);
final factorPicked = useState<SnAuthFactor?>(null);
return AppScaffold(
noBackground: false,
appBar: AppBar(
@ -50,54 +60,87 @@ class LoginScreen extends HookConsumerWidget {
),
body: Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
ticket: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor: (SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value++,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) => factors.value = p0 ?? [],
onNext: () => period.value++,
),
},
).padding(all: 24),
).center(),
child: Column(
children: [
if (isBusy.value)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
)
else if (currentTicket.value != null)
LinearProgressIndicator(
minHeight: 4,
borderRadius: BorderRadius.zero,
trackGap: 0,
stopIndicatorRadius: 0,
value:
1 -
(currentTicket.value!.stepRemain /
currentTicket.value!.stepTotal),
)
else
const Gap(4),
Expanded(
child:
SingleChildScrollView(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: Container(
constraints: BoxConstraints(maxWidth: 380),
child: child,
),
);
},
child: switch (period.value % 3) {
1 => _LoginPickerScreen(
key: const ValueKey(1),
ticket: currentTicket.value,
factors: factors.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onPickFactor:
(SnAuthFactor p0) => factorPicked.value = p0,
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
2 => _LoginCheckScreen(
key: const ValueKey(2),
challenge: currentTicket.value,
factor: factorPicked.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onNext: () => period.value = 1,
onBusy: (value) => isBusy.value = value,
),
_ => _LoginLookupScreen(
key: const ValueKey(0),
ticket: currentTicket.value,
onChallenge:
(SnAuthChallenge? p0) => currentTicket.value = p0,
onFactor:
(List<SnAuthFactor>? p0) =>
factors.value = p0 ?? [],
onNext: () => period.value++,
onBusy: (value) => isBusy.value = value,
),
},
).padding(all: 24),
).center(),
),
const Gap(4),
],
),
),
);
}
@ -107,7 +150,8 @@ class _LoginCheckScreen extends HookConsumerWidget {
final SnAuthChallenge? challenge;
final SnAuthFactor? factor;
final Function(SnAuthChallenge?) onChallenge;
final Function onNext;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginCheckScreen({
super.key,
@ -115,6 +159,7 @@ class _LoginCheckScreen extends HookConsumerWidget {
required this.factor,
required this.onChallenge,
required this.onNext,
required this.onBusy,
});
@override
@ -122,11 +167,17 @@ class _LoginCheckScreen extends HookConsumerWidget {
final isBusy = useState(false);
final passwordController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> performCheckTicket() async {
final pwd = passwordController.value.text;
if (pwd.isEmpty) return;
isBusy.value = true;
try {
// Pass challenge
final client = ref.watch(apiClientProvider);
final resp = await client.patch(
'/auth/challenge/${challenge!.id}',
@ -138,6 +189,8 @@ class _LoginCheckScreen extends HookConsumerWidget {
onNext();
return;
}
// Get token if challenge is completed
final tokenResp = await client.post(
'/auth/token',
data: {'grant_type': 'authorization_code', 'code': result.id},
@ -146,6 +199,8 @@ class _LoginCheckScreen extends HookConsumerWidget {
setToken(ref.watch(sharedPreferencesProvider), token);
ref.invalidate(tokenProvider);
if (!context.mounted) return;
// Do post login tasks
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) {
final apiClient = ref.read(apiClientProvider);
@ -154,6 +209,31 @@ class _LoginCheckScreen extends HookConsumerWidget {
wsNotifier.connect();
if (context.mounted) Navigator.pop(context, true);
});
// Update the sessions' device name is available
if (!kIsWeb) {
String? name;
if (Platform.isIOS) {
return;
// TODO waiting for apple to respond to grant my access to com.apple.developer.device-information.user-assigned-device-name
// ignore: dead_code
final deviceInfo = await DeviceInfoPlugin().iosInfo;
name = deviceInfo.name;
} else if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
name = deviceInfo.name;
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
name = deviceInfo.computerName;
}
if (name != null) {
final client = ref.watch(apiClientProvider);
await client.patch(
'/accounts/me/sessions/current/label',
data: jsonEncode(name),
);
}
}
} catch (err) {
showErrorAlert(err);
return;
@ -162,6 +242,8 @@ class _LoginCheckScreen extends HookConsumerWidget {
}
}
final width = math.min(380, MediaQuery.of(context).size.width);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -176,24 +258,49 @@ class _LoginCheckScreen extends HookConsumerWidget {
'loginEnterPassword'.tr(),
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
).padding(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
border: const UnderlineInputBorder(),
labelText: 'password'.tr(),
if ([0].contains(factor!.type))
TextField(
autocorrect: false,
enableSuggestions: false,
controller: passwordController,
obscureText: true,
autofillHints: [
factor!.type == 0
? AutofillHints.password
: AutofillHints.oneTimeCode,
],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr(),
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7)
else
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
onSubmit: (value) {
passwordController.text = value;
performCheckTicket();
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
).padding(horizontal: 7),
const Gap(12),
Card(
child: ListTile(
leading: Icon(
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
),
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@ -220,7 +327,8 @@ class _LoginPickerScreen extends HookConsumerWidget {
final List<SnAuthFactor>? factors;
final Function(SnAuthChallenge?) onChallenge;
final Function(SnAuthFactor) onPickFactor;
final Function onNext;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginPickerScreen({
super.key,
@ -229,17 +337,25 @@ class _LoginPickerScreen extends HookConsumerWidget {
required this.onChallenge,
required this.onPickFactor,
required this.onNext,
required this.onBusy,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBusy = useState(false);
final factorPicked = useState<String?>(null);
final factorPicked = useState<SnAuthFactor?>(null);
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
final unfocusColor = Theme.of(
context,
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
final hintController = useTextEditingController();
void performGetFactorCode() async {
if (factorPicked.value == null) return;
@ -247,13 +363,24 @@ class _LoginPickerScreen extends HookConsumerWidget {
final client = ref.watch(apiClientProvider);
try {
// Request one-time-password code
await client.post(
'/auth/challenge/${ticket!.id}/factors/${factorPicked.value}',
'/auth/challenge/${ticket!.id}/factors/${factorPicked.value!.id}',
data:
hintController.text.isNotEmpty
? jsonEncode(hintController.text)
: null,
);
onPickFactor(factors!.where((x) => x.id == factorPicked.value).first);
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
} catch (err) {
if (err is DioException && err.response?.statusCode == 400) {
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
onNext();
if (context.mounted) {
showSnackBar(context, err.response!.data.toString());
}
return;
}
showErrorAlert(err);
return;
} finally {
@ -261,6 +388,20 @@ class _LoginPickerScreen extends HookConsumerWidget {
}
}
useEffect(() {
if (ticket == null || (factors?.isEmpty ?? true)) return;
if (ticket!.blacklistFactors.isEmpty) {
Future(() {
var password = factors!.where((x) => x.type == 0).firstOrNull;
if (password != null) {
factorPicked.value = password;
performGetFactorCode();
}
});
}
return null;
}, [ticket, factors]);
return Column(
key: const ValueKey<int>(1),
crossAxisAlignment: CrossAxisAlignment.start,
@ -292,10 +433,10 @@ class _LoginPickerScreen extends HookConsumerWidget {
),
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
enabled: !ticket!.blacklistFactors.contains(x.id),
value: factorPicked.value == x.id,
value: factorPicked.value == x,
onChanged: (value) {
if (value == true) {
factorPicked.value = x.id;
factorPicked.value = x;
}
},
),
@ -304,6 +445,16 @@ class _LoginPickerScreen extends HookConsumerWidget {
List.empty(),
),
),
if ([1].contains(factorPicked.value?.type))
TextField(
controller: hintController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'authFactorHint'.tr(),
helperText: 'authFactorHintHelper'.tr(),
),
).padding(top: 12, bottom: 4, horizontal: 4),
const Gap(8),
Text(
'loginMultiFactor'.plural(ticket!.stepRemain),
@ -334,7 +485,8 @@ class _LoginLookupScreen extends HookConsumerWidget {
final SnAuthChallenge? ticket;
final Function(SnAuthChallenge?) onChallenge;
final Function(List<SnAuthFactor>?) onFactor;
final Function onNext;
final VoidCallback onNext;
final Function(bool) onBusy;
const _LoginLookupScreen({
super.key,
@ -342,6 +494,7 @@ class _LoginLookupScreen extends HookConsumerWidget {
required this.onChallenge,
required this.onFactor,
required this.onNext,
required this.onBusy,
});
@override
@ -349,6 +502,11 @@ class _LoginLookupScreen extends HookConsumerWidget {
final isBusy = useState(false);
final usernameController = useTextEditingController();
useEffect(() {
onBusy.call(isBusy.value);
return null;
}, [isBusy]);
Future<void> requestResetPassword() async {
final uname = usernameController.value.text;
if (uname.isEmpty) {

View File

@ -24,6 +24,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/realms/selection_dropdown.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -346,7 +347,11 @@ class ChatListScreen extends HookConsumerWidget {
builder: (context, ref, _) {
final summaryState = ref.watch(chatSummaryProvider);
return summaryState.maybeWhen(
loading: () => const LinearProgressIndicator(),
loading:
() => const LinearProgressIndicator(
minHeight: 2,
borderRadius: BorderRadius.zero,
),
orElse: () => const SizedBox.shrink(),
);
},
@ -714,109 +719,77 @@ class _ChatInvitesSheet extends HookConsumerWidget {
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'invites'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(chatroomInvitesProvider);
},
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: invites.when(
data:
(items) =>
items.isEmpty
? Center(
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ChatRoomListTile(
room: invite.chatRoom!,
isDirect: invite.chatRoom!.type == 1,
subtitle: Row(
spacing: 6,
children: [
Flexible(
child:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
if (invite.chatRoom!.type == 1)
Badge(
label: Text('directMessage').tr(),
backgroundColor:
Theme.of(
context,
).colorScheme.primary,
textColor:
Theme.of(
context,
).colorScheme.onPrimary,
),
],
return SheetScaffold(
titleText: 'invites'.tr(),
actions: [
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(realmInvitesProvider);
},
),
],
child: invites.when(
data:
(items) =>
items.isEmpty
? Center(
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ChatRoomListTile(
room: invite.chatRoom!,
isDirect: invite.chatRoom!.type == 1,
subtitle: Row(
spacing: 6,
children: [
Flexible(
child:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
),
if (invite.chatRoom!.type == 1)
Badge(
label: Text('directMessage').tr(),
backgroundColor:
Theme.of(context).colorScheme.primary,
textColor:
Theme.of(context).colorScheme.onPrimary,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
],
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
),
],
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
);
}

View File

@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -319,6 +321,46 @@ class ChatRoomScreen extends HookConsumerWidget {
);
}
// Members who are typing
final typingStatuses = useState<List<SnChatMember>>([]);
final typingDebouncer = useState<Timer?>(null);
void sendTypingStatus() {
// Don't send if we're already in a cooldown period
if (typingDebouncer.value != null) return;
// Send typing status immediately
final wsState = ref.read(websocketStateProvider.notifier);
wsState.sendMessage(
jsonEncode(
WebSocketPacket(type: 'messages.typing', data: {'chat_room_id': id}),
),
);
typingDebouncer.value = Timer(const Duration(milliseconds: 1000), () {
typingDebouncer.value = null;
});
}
// Add timer to remove typing status after inactivity
useEffect(() {
final removeTypingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
if (typingStatuses.value.isNotEmpty) {
// Remove typing statuses older than 5 seconds
final now = DateTime.now();
typingStatuses.value =
typingStatuses.value.where((member) {
final lastTyped =
member.lastTyped ??
DateTime.now().subtract(const Duration(milliseconds: 1350));
return now.difference(lastTyped).inSeconds < 5;
}).toList();
}
});
return () => removeTypingTimer.cancel();
}, []);
var isLoading = false;
// Add scroll listener for pagination
@ -341,6 +383,28 @@ class ChatRoomScreen extends HookConsumerWidget {
void onMessage(WebSocketPacket pkt) {
if (!pkt.type.startsWith('messages')) return;
if (['messages.read'].contains(pkt.type)) return;
if (pkt.type == 'messages.typing') {
final sender = SnChatMember.fromJson(
pkt.data!['sender'],
).copyWith(lastTyped: DateTime.now());
// Check if the sender is already in the typing list
final existingIndex = typingStatuses.value.indexWhere(
(member) => member.id == sender.id,
);
if (existingIndex >= 0) {
// Update the existing entry with new timestamp
final updatedList = [...typingStatuses.value];
updatedList[existingIndex] = sender;
typingStatuses.value = updatedList;
} else {
// Add new typing status
typingStatuses.value = [...typingStatuses.value, sender];
}
return;
}
final message = SnChatMessage.fromJson(pkt.data!);
if (message.chatRoomId != chatRoom.value?.id) return;
switch (pkt.type) {
@ -414,8 +478,22 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
// Add listener to message controller for typing status
useEffect(() {
void onTextChange() {
if (messageController.text.isNotEmpty) {
sendTypingStatus();
}
}
messageController.addListener(onTextChange);
return () => messageController.removeListener(onTextChange);
}, [messageController]);
final compactHeader = isWideScreen(context);
final listController = useMemoized(() => ListController(), []);
return AppScaffold(
appBar: AppBar(
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
@ -541,11 +619,19 @@ class ChatRoomScreen extends HookConsumerWidget {
messageList.isEmpty
? Center(child: Text('No messages yet'.tr()))
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController,
reverse:
true, // Show newest messages at the bottom
itemCount: messageList.length,
findChildIndexCallback: (key) {
final valueKey = key as ValueKey;
final messageId = valueKey.value as String;
return messageList.indexWhere(
(m) => m.id == messageId,
);
},
itemBuilder: (context, index) {
final message = messageList[index];
final nextMessage =
@ -602,6 +688,18 @@ class ChatRoomScreen extends HookConsumerWidget {
message.toRemoteMessage();
}
},
onJump: (messageId) {
final messageIndex = messageList
.indexWhere(
(m) => m.id == messageId,
);
listController.jumpToItem(
index: messageIndex,
scrollController:
scrollController,
alignment: 0.5,
);
},
progress:
attachmentProgress.value[message
.id],
@ -614,6 +712,7 @@ class ChatRoomScreen extends HookConsumerWidget {
onAction: null,
progress: null,
showAvatar: false,
onJump: (_) {},
),
error: (_, _) => const SizedBox.shrink(),
);
@ -630,55 +729,133 @@ class ChatRoomScreen extends HookConsumerWidget {
),
chatRoom.when(
data:
(room) => _ChatInput(
messageController: messageController,
chatRoom: room!,
onSend: sendMessage,
onClear: () {
if (messageEditingTo.value != null) {
attachments.value.clear();
messageController.clear();
}
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();
} else {
pickVideoMedia();
}
},
attachments: attachments.value,
onUploadAttachment: (_) {
// not going to do anything, only upload when send the message
},
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud) {
final client = ref.watch(apiClientProvider);
await client.delete('/files/${attachment.data.id}');
}
final clone = List.of(attachments.value);
clone.removeAt(index);
attachments.value = clone;
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= attachments.value.length) {
return;
}
final clone = List.of(attachments.value);
clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone;
},
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
(room) => Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.fastEaseInToSlowEaseOut,
switchOutCurve: Curves.fastEaseInToSlowEaseOut,
transitionBuilder: (
Widget child,
Animation<double> animation,
) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.3),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
),
),
child: SizeTransition(
sizeFactor: animation,
axisAlignment: -1.0,
child: FadeTransition(
opacity: animation,
child: child,
),
),
);
},
child:
typingStatuses.value.isNotEmpty
? Container(
key: const ValueKey('typing-indicator'),
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
children: [
const Icon(
Symbols.more_horiz,
size: 16,
).padding(horizontal: 8),
const Gap(8),
Expanded(
child: Text(
'typingHint'.plural(
typingStatuses.value.length,
args: [
typingStatuses.value
.map(
(x) =>
x.nick ??
x.account.nick,
)
.join(', '),
],
),
style:
Theme.of(
context,
).textTheme.bodySmall,
),
),
],
),
)
: const SizedBox.shrink(
key: ValueKey('no_typing'),
),
),
_ChatInput(
messageController: messageController,
chatRoom: room!,
onSend: sendMessage,
onClear: () {
if (messageEditingTo.value != null) {
attachments.value.clear();
messageController.clear();
}
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();
} else {
pickVideoMedia();
}
},
attachments: attachments.value,
onUploadAttachment: (_) {
// not going to do anything, only upload when send the message
},
onDeleteAttachment: (index) async {
final attachment = attachments.value[index];
if (attachment.isOnCloud) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/files/${attachment.data.id}',
);
}
final clone = List.of(attachments.value);
clone.removeAt(index);
attachments.value = clone;
},
onMoveAttachment: (idx, delta) {
if (idx + delta < 0 ||
idx + delta >= attachments.value.length) {
return;
}
final clone = List.of(attachments.value);
clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone;
},
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
),
],
),
error: (_, _) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
@ -697,7 +874,7 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
class _ChatInput extends ConsumerWidget {
class _ChatInput extends HookConsumerWidget {
final TextEditingController messageController;
final SnChatRoom chatRoom;
final VoidCallback onSend;
@ -728,46 +905,59 @@ class _ChatInput extends ConsumerWidget {
required this.onAttachmentsChanged,
});
void _handleKeyPress(BuildContext context, WidgetRef ref, RawKeyEvent event) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isPaste && isModifierPressed) {
_handlePaste();
return;
}
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
if (isEnter) {
if (enterToSend && !isModifierPressed) {
onSend();
} else if (!enterToSend && isModifierPressed) {
onSend();
}
}
}
Future<void> _handlePaste() async {
final clipboard = await Pasteboard.image;
if (clipboard == null) return;
onAttachmentsChanged([
...attachments,
UniversalFile(
data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
type: UniversalFileType.image,
),
]);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode();
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
void send() {
inputFocusNode.requestFocus();
onSend.call();
}
Future<void> handlePaste() async {
final clipboard = await Pasteboard.image;
if (clipboard == null) return;
onAttachmentsChanged([
...attachments,
UniversalFile(
data: XFile.fromData(clipboard, mimeType: "image/jpeg"),
type: UniversalFileType.image,
),
]);
}
void handleKeyPress(
BuildContext context,
WidgetRef ref,
RawKeyEvent event,
) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isPaste && isModifierPressed) {
handlePaste();
return;
}
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
if (isEnter) {
if (enterToSend && !isModifierPressed) {
send();
} else if (!enterToSend && isModifierPressed) {
send();
}
}
}
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
@ -869,12 +1059,23 @@ class _ChatInput extends ConsumerWidget {
Expanded(
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) => _handleKeyPress(context, ref, event),
onKey: (event) => handleKeyPress(context, ref, event),
child: TextField(
focusNode: inputFocusNode,
controller: messageController,
onSubmitted: enterToSend ? (_) => onSend() : null,
onSubmitted:
(enterToSend && isMobile)
? (_) {
send();
}
: null,
keyboardType:
(enterToSend && isMobile)
? TextInputType.text
: TextInputType.multiline,
textInputAction: TextInputAction.send,
inputFormatters: [
if (enterToSend)
if (enterToSend && !isMobile)
TextInputFormatter.withFunction((oldValue, newValue) {
if (newValue.text.endsWith('\n')) {
return oldValue;
@ -909,7 +1110,7 @@ class _ChatInput extends ConsumerWidget {
IconButton(
icon: const Icon(Icons.send),
color: Theme.of(context).colorScheme.primary,
onPressed: onSend,
onPressed: send,
),
],
).padding(bottom: MediaQuery.of(context).padding.bottom),

View File

@ -14,10 +14,14 @@ import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/paging_helper_ext.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'room_detail.freezed.dart';
part 'room_detail.g.dart';
@RoutePage()
class ChatDetailScreen extends HookConsumerWidget {
@ -287,12 +291,51 @@ class ChatMemberNotifier extends StateNotifier<ChatRoomMemberState> {
}
}
@riverpod
class ChatMemberListNotifier extends _$ChatMemberListNotifier
with CursorPagingNotifierMixin<SnChatMember> {
@override
Future<CursorPagingData<SnChatMember>> build(String roomId) {
return fetch();
}
@override
Future<CursorPagingData<SnChatMember>> fetch({String? cursor}) async {
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final apiClient = ref.watch(apiClientProvider);
final response = await apiClient.get(
'/chat/$roomId/members',
queryParameters: {'offset': offset, 'take': take},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnChatMember.fromJson(e)).toList();
// Calculate next cursor based on total count
final nextOffset = offset + members.length;
final String? nextCursor =
nextOffset < total ? nextOffset.toString() : null;
return CursorPagingData(
items: members,
nextCursor: nextCursor,
hasMore: members.length < total,
);
}
}
class _ChatMemberListSheet extends HookConsumerWidget {
final String roomId;
const _ChatMemberListSheet({required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memberListProvider = chatMemberListNotifierProvider(roomId);
// For backward compatibility and to show total count in the header
final memberState = ref.watch(chatMemberStateProvider(roomId));
final memberNotifier = ref.read(chatMemberStateProvider(roomId).notifier);
@ -318,8 +361,10 @@ class _ChatMemberListSheet extends HookConsumerWidget {
'/chat/invites/$roomId',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
memberNotifier.reset();
await memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
@ -351,8 +396,10 @@ class _ChatMemberListSheet extends HookConsumerWidget {
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
},
),
IconButton(
@ -365,108 +412,103 @@ class _ChatMemberListSheet extends HookConsumerWidget {
),
const Divider(height: 1),
Expanded(
child:
memberState.error != null
? Center(child: Text(memberState.error!))
: ListView.builder(
itemCount: memberState.members.length + 1,
itemBuilder: (context, index) {
if (index == memberState.members.length) {
if (memberState.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
if (memberState.members.length < memberState.total) {
memberNotifier.loadMore(
offset: memberState.members.length,
);
}
return const SizedBox.shrink();
}
child: PagingHelperView(
provider: memberListProvider,
futureRefreshable: memberListProvider.future,
notifierRefreshable: memberListProvider.notifier,
contentBuilder: (data, widgetCount, endItemView) {
return ListView.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == data.items.length) {
return endItemView;
}
final member = memberState.members[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _ChatMemberRoleSheet(
roomId: roomId,
member: member,
),
).then((value) {
if (value != null) {
memberNotifier.reset();
memberNotifier.loadMore();
}
});
},
),
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeChatMemberHint'.tr(),
'removeChatMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/chat/$roomId/members/${member.accountId}',
);
memberNotifier.reset();
memberNotifier.loadMore();
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
),
final member = data.items[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _ChatMemberRoleSheet(
roomId: roomId,
member: member,
),
).then((value) {
if (value != null) {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
}
});
},
),
if ((roomIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeChatMemberHint'.tr(),
'removeChatMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/chat/$roomId/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
);
},
),
),
],
),

View File

@ -0,0 +1,180 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'room_detail.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$chatMemberListNotifierHash() =>
r'f2191a631ba00ae3de39ccac10e4cdd065ffee17';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$ChatMemberListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnChatMember>> {
late final String roomId;
FutureOr<CursorPagingData<SnChatMember>> build(String roomId);
}
/// See also [ChatMemberListNotifier].
@ProviderFor(ChatMemberListNotifier)
const chatMemberListNotifierProvider = ChatMemberListNotifierFamily();
/// See also [ChatMemberListNotifier].
class ChatMemberListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnChatMember>>> {
/// See also [ChatMemberListNotifier].
const ChatMemberListNotifierFamily();
/// See also [ChatMemberListNotifier].
ChatMemberListNotifierProvider call(String roomId) {
return ChatMemberListNotifierProvider(roomId);
}
@override
ChatMemberListNotifierProvider getProviderOverride(
covariant ChatMemberListNotifierProvider provider,
) {
return call(provider.roomId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'chatMemberListNotifierProvider';
}
/// See also [ChatMemberListNotifier].
class ChatMemberListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
> {
/// See also [ChatMemberListNotifier].
ChatMemberListNotifierProvider(String roomId)
: this._internal(
() => ChatMemberListNotifier()..roomId = roomId,
from: chatMemberListNotifierProvider,
name: r'chatMemberListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatMemberListNotifierHash,
dependencies: ChatMemberListNotifierFamily._dependencies,
allTransitiveDependencies:
ChatMemberListNotifierFamily._allTransitiveDependencies,
roomId: roomId,
);
ChatMemberListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.roomId,
}) : super.internal();
final String roomId;
@override
FutureOr<CursorPagingData<SnChatMember>> runNotifierBuild(
covariant ChatMemberListNotifier notifier,
) {
return notifier.build(roomId);
}
@override
Override overrideWith(ChatMemberListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: ChatMemberListNotifierProvider._internal(
() => create()..roomId = roomId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
roomId: roomId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
>
createElement() {
return _ChatMemberListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is ChatMemberListNotifierProvider && other.roomId == roomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, roomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ChatMemberListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnChatMember>> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _ChatMemberListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
ChatMemberListNotifier,
CursorPagingData<SnChatMember>
>
with ChatMemberListNotifierRef {
_ChatMemberListNotifierProviderElement(super.provider);
@override
String get roomId => (origin as ChatMemberListNotifierProvider).roomId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -148,7 +148,7 @@ class _StickerPackProviderElement
}
String _$stickerPacksNotifierHash() =>
r'2feff50a7896eb8759fe91e9626b0409354d9fee';
r'dc0cc4ec27fdd6d5da28f982ff10c852f8107a18';
abstract class _$StickerPacksNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnStickerPack>> {

View File

@ -168,12 +168,6 @@ class _ActivityListView extends HookConsumerWidget {
);
}
break;
case 'accounts.check-in':
itemWidget = CheckInActivityWidget(item: item);
break;
case 'accounts.status':
itemWidget = StatusActivityWidget(item: item);
break;
default:
itemWidget = const Placeholder();
}
@ -196,12 +190,14 @@ class ActivityListNotifier extends _$ActivityListNotifier
@override
Future<CursorPagingData<SnActivity>> fetch({required String? cursor}) async {
final client = ref.read(apiClientProvider);
final offset = cursor == null ? 0 : int.parse(cursor);
final take = 20;
final response = await client.get(
'/activities',
queryParameters: {'offset': offset, 'take': take},
queryParameters: {
if (cursor != null) 'reading_cursor': cursor,
'take': take,
},
);
final List<SnActivity> items =
@ -209,9 +205,9 @@ class ActivityListNotifier extends _$ActivityListNotifier
.map((e) => SnActivity.fromJson(e as Map<String, dynamic>))
.toList();
final total = int.tryParse(response.headers['x-total']?.first ?? '') ?? 0;
final hasMore = offset + items.length < total;
final nextCursor = hasMore ? (offset + items.length).toString() : null;
final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty';
final nextCursor =
items.map((x) => x.createdAt).lastOrNull?.toIso8601String().toString();
return CursorPagingData(
items: items,

View File

@ -7,7 +7,7 @@ part of 'explore.dart';
// **************************************************************************
String _$activityListNotifierHash() =>
r'8a67d302e828408c7c4cf724d84c2c5958f2dc7e';
r'1baf0bb961bc02bfc8a5b5f515981072c6ce1750';
/// See also [ActivityListNotifier].
@ProviderFor(ActivityListNotifier)

View File

@ -15,6 +15,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:styled_widget/styled_widget.dart';
part 'detail.g.dart';
@ -250,6 +251,42 @@ class _RealmActionMenu extends HookConsumerWidget {
}
}
@riverpod
class RealmMemberListNotifier extends _$RealmMemberListNotifier
with CursorPagingNotifierMixin<SnRealmMember> {
static const int _pageSize = 20;
@override
Future<CursorPagingData<SnRealmMember>> build(String realmSlug) async {
return fetch();
}
@override
Future<CursorPagingData<SnRealmMember>> fetch({String? cursor}) async {
final apiClient = ref.read(apiClientProvider);
final offset = cursor != null ? int.parse(cursor) : 0;
final response = await apiClient.get(
'/realms/$realmSlug/members',
queryParameters: {'offset': offset, 'take': _pageSize},
);
final total = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
final members = data.map((e) => SnRealmMember.fromJson(e)).toList();
final hasMore = offset + members.length < total;
final nextCursor = hasMore ? (offset + members.length).toString() : null;
return CursorPagingData(
items: members,
hasMore: hasMore,
nextCursor: nextCursor,
);
}
}
// Keep the old provider for backward compatibility
final realmMemberStateProvider =
StateNotifierProvider.family<RealmMemberNotifier, RealmMemberState, String>(
(ref, realmSlug) {
@ -302,13 +339,15 @@ class _RealmMemberListSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
final memberListProvider = realmMemberListNotifierProvider(realmSlug);
// For backward compatibility and to show total count in the header
final memberState = ref.watch(realmMemberStateProvider(realmSlug));
final memberNotifier = ref.read(
realmMemberStateProvider(realmSlug).notifier,
);
final realmIdentity = ref.watch(realmIdentityProvider(realmSlug));
useEffect(() {
Future(() {
memberNotifier.loadMore();
@ -329,8 +368,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
'/realms/invites/$realmSlug',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
memberNotifier.reset();
await memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
@ -362,8 +403,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
IconButton(
icon: const Icon(Symbols.refresh),
onPressed: () {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
},
),
IconButton(
@ -376,108 +419,103 @@ class _RealmMemberListSheet extends HookConsumerWidget {
),
const Divider(height: 1),
Expanded(
child:
memberState.error != null
? Center(child: Text(memberState.error!))
: ListView.builder(
itemCount: memberState.members.length + 1,
itemBuilder: (context, index) {
if (index == memberState.members.length) {
if (memberState.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
if (memberState.members.length < memberState.total) {
memberNotifier.loadMore(
offset: memberState.members.length,
);
}
return const SizedBox.shrink();
}
child: PagingHelperView(
provider: memberListProvider,
futureRefreshable: memberListProvider.future,
notifierRefreshable: memberListProvider.notifier,
contentBuilder: (data, widgetCount, endItemView) {
return ListView.builder(
itemCount: widgetCount,
itemBuilder: (context, index) {
if (index == data.items.length) {
return endItemView;
}
final member = memberState.members[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account!.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account!.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account!.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
).then((value) {
if (value != null) {
memberNotifier.reset();
memberNotifier.loadMore();
}
});
},
),
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeRealmMemberHint'.tr(),
'removeRealmMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/realms/$realmSlug/members/${member.accountId}',
);
memberNotifier.reset();
memberNotifier.loadMore();
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
),
final member = data.items[index];
return ListTile(
contentPadding: EdgeInsets.only(left: 16, right: 12),
leading: ProfilePictureWidget(
fileId: member.account!.profile.picture?.id,
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(member.account!.nick)),
if (member.joinedAt == null)
const Icon(Symbols.pending_actions, size: 20),
],
),
subtitle: Row(
children: [
Text(
member.role >= 100
? 'permissionOwner'
: member.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
Text('·').bold().padding(horizontal: 6),
Expanded(child: Text("@${member.account!.name}")),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.edit),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberRoleSheet(
realmSlug: realmSlug,
member: member,
),
).then((value) {
if (value != null) {
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
}
});
},
),
if ((realmIdentity.value?.role ?? 0) >= 50)
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
showConfirmAlert(
'removeRealmMemberHint'.tr(),
'removeRealmMember'.tr(),
).then((confirm) async {
if (confirm != true) return;
try {
final apiClient = ref.watch(
apiClientProvider,
);
await apiClient.delete(
'/realms/$realmSlug/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
memberNotifier.loadMore();
ref.invalidate(memberListProvider);
} catch (err) {
showErrorAlert(err);
}
});
},
),
],
),
);
},
);
},
),
),
],
),

View File

@ -148,5 +148,155 @@ class _RealmIdentityProviderElement
String get realmSlug => (origin as RealmIdentityProvider).realmSlug;
}
String _$realmMemberListNotifierHash() =>
r'b2e3eefc62a597f45df9470b2058fdda62f8853f';
abstract class _$RealmMemberListNotifier
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnRealmMember>> {
late final String realmSlug;
FutureOr<CursorPagingData<SnRealmMember>> build(String realmSlug);
}
/// See also [RealmMemberListNotifier].
@ProviderFor(RealmMemberListNotifier)
const realmMemberListNotifierProvider = RealmMemberListNotifierFamily();
/// See also [RealmMemberListNotifier].
class RealmMemberListNotifierFamily
extends Family<AsyncValue<CursorPagingData<SnRealmMember>>> {
/// See also [RealmMemberListNotifier].
const RealmMemberListNotifierFamily();
/// See also [RealmMemberListNotifier].
RealmMemberListNotifierProvider call(String realmSlug) {
return RealmMemberListNotifierProvider(realmSlug);
}
@override
RealmMemberListNotifierProvider getProviderOverride(
covariant RealmMemberListNotifierProvider provider,
) {
return call(provider.realmSlug);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'realmMemberListNotifierProvider';
}
/// See also [RealmMemberListNotifier].
class RealmMemberListNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
> {
/// See also [RealmMemberListNotifier].
RealmMemberListNotifierProvider(String realmSlug)
: this._internal(
() => RealmMemberListNotifier()..realmSlug = realmSlug,
from: realmMemberListNotifierProvider,
name: r'realmMemberListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$realmMemberListNotifierHash,
dependencies: RealmMemberListNotifierFamily._dependencies,
allTransitiveDependencies:
RealmMemberListNotifierFamily._allTransitiveDependencies,
realmSlug: realmSlug,
);
RealmMemberListNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.realmSlug,
}) : super.internal();
final String realmSlug;
@override
FutureOr<CursorPagingData<SnRealmMember>> runNotifierBuild(
covariant RealmMemberListNotifier notifier,
) {
return notifier.build(realmSlug);
}
@override
Override overrideWith(RealmMemberListNotifier Function() create) {
return ProviderOverride(
origin: this,
override: RealmMemberListNotifierProvider._internal(
() => create()..realmSlug = realmSlug,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
realmSlug: realmSlug,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
>
createElement() {
return _RealmMemberListNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is RealmMemberListNotifierProvider &&
other.realmSlug == realmSlug;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, realmSlug.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin RealmMemberListNotifierRef
on AutoDisposeAsyncNotifierProviderRef<CursorPagingData<SnRealmMember>> {
/// The parameter `realmSlug` of this provider.
String get realmSlug;
}
class _RealmMemberListNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
RealmMemberListNotifier,
CursorPagingData<SnRealmMember>
>
with RealmMemberListNotifierRef {
_RealmMemberListNotifierProviderElement(super.provider);
@override
String get realmSlug => (origin as RealmMemberListNotifierProvider).realmSlug;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -16,6 +16,7 @@ import 'package:island/services/file.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -397,97 +398,69 @@ class _RealmInviteSheet extends HookConsumerWidget {
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'invites'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(realmInvitesProvider);
},
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: invites.when(
data:
(items) =>
items.isEmpty
? Center(
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: invite.realm!.picture?.id,
fallbackIcon: Symbols.group,
),
title: Text(invite.realm!.name),
subtitle:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
return SheetScaffold(
titleText: 'invites'.tr(),
actions: [
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(realmInvitesProvider);
},
),
],
child: invites.when(
data:
(items) =>
items.isEmpty
? Center(
child:
Text(
'invitesEmpty',
textAlign: TextAlign.center,
).tr(),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final invite = items[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: invite.realm!.picture?.id,
fallbackIcon: Symbols.group,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(realmInvitesProvider),
),
title: Text(invite.realm!.name),
subtitle:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(realmInvitesProvider),
),
),
],
),
);
}

View File

@ -147,6 +147,7 @@ class SettingsScreen extends HookConsumerWidget {
title: Text('settingsColorScheme').tr(),
content: SingleChildScrollView(
child: ColorPicker(
paletteType: PaletteType.rgbWithBlue,
enableAlpha: false,
pickerColor: selectedColor,
onColorChanged: (color) {
@ -157,7 +158,7 @@ class SettingsScreen extends HookConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Cancel').tr(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
@ -166,7 +167,7 @@ class SettingsScreen extends HookConsumerWidget {
.setAppColorScheme(selectedColor.value);
Navigator.of(context).pop();
},
child: Text('Confirm').tr(),
child: Text('confirm').tr(),
),
],
);
@ -174,8 +175,9 @@ class SettingsScreen extends HookConsumerWidget {
);
},
child: Container(
width: 40,
height: 40,
width: 24,
height: 24,
margin: EdgeInsets.symmetric(horizontal: 2, vertical: 8),
decoration: BoxDecoration(
color:
settings.appColorScheme != null
@ -198,18 +200,7 @@ class SettingsScreen extends HookConsumerWidget {
title: Text('settingsBackgroundImage').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.image),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isDesktop)
Tooltip(
message: 'settingsBackgroundImageTooltip'.tr(),
padding: EdgeInsets.only(left: 8),
child: const Icon(Symbols.info, size: 18),
),
const Icon(Symbols.chevron_right),
],
),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final imagePicker = ref.read(imagePickerProvider);
final image = await imagePicker.pickImage(
@ -411,19 +402,19 @@ class SettingsScreen extends HookConsumerWidget {
children: [
_ShortcutRow(
shortcut: 'Ctrl+F',
description: 'Search',
description: 'settingsKeyboardShortcutSearch'.tr(),
),
_ShortcutRow(
shortcut: 'Ctrl+,',
description: 'Settings',
description: 'settingsKeyboardShortcutSettings'.tr(),
),
_ShortcutRow(
shortcut: 'Ctrl+N',
description: 'New Message',
description: 'settingsKeyboardShortcutNewMessage'.tr(),
),
_ShortcutRow(
shortcut: 'Esc',
description: 'Close Dialog',
description: 'settingsKeyboardShortcutCloseDialog'.tr(),
),
// Add more shortcuts as needed
],
@ -432,7 +423,7 @@ class SettingsScreen extends HookConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
child: Text('close').tr(),
),
],
),
@ -454,10 +445,10 @@ class SettingsScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'Appearance',
title: 'settingsAppearance'.tr(),
children: appearanceSettings,
),
_SettingsSection(title: 'Server', children: serverSettings),
_SettingsSection(title: 'settingsServer'.tr(), children: serverSettings),
],
),
),
@ -466,12 +457,12 @@ class SettingsScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'Behavior',
title: 'settingsBehavior'.tr(),
children: behaviorSettings,
),
if (desktopSettings.isNotEmpty)
_SettingsSection(
title: 'Desktop',
title: 'settingsDesktop'.tr(),
children: desktopSettings,
),
],
@ -484,11 +475,11 @@ class SettingsScreen extends HookConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(title: 'Appearance', children: appearanceSettings),
_SettingsSection(title: 'Server', children: serverSettings),
_SettingsSection(title: 'Behavior', children: behaviorSettings),
_SettingsSection(title: 'settingsAppearance'.tr(), children: appearanceSettings),
_SettingsSection(title: 'settingsServer'.tr(), children: serverSettings),
_SettingsSection(title: 'settingsBehavior'.tr(), children: behaviorSettings),
if (desktopSettings.isNotEmpty)
_SettingsSection(title: 'Desktop', children: desktopSettings),
_SettingsSection(title: 'settingsDesktop'.tr(), children: desktopSettings),
],
);
}
@ -497,7 +488,7 @@ class SettingsScreen extends HookConsumerWidget {
return AppScaffold(
noBackground: false,
appBar: AppBar(
title: Text('Settings').tr(),
title: Text('settings').tr(),
actions:
isDesktop
? [
@ -514,7 +505,7 @@ class SettingsScreen extends HookConsumerWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
child: Text('close').tr(),
),
],
),

View File

@ -0,0 +1,246 @@
import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/auth.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'account_session_sheet.g.dart';
@riverpod
Future<List<SnAuthDevice>> authDevices(Ref ref) async {
final resp = await ref.watch(apiClientProvider).get('/accounts/me/devices');
final sessionId = resp.headers.value('x-auth-session');
final data =
resp.data.map<SnAuthDevice>((e) {
final ele = SnAuthDevice.fromJson(e);
return ele.copyWith(isCurrent: ele.sessions.first.id == sessionId);
}).toList();
return data;
}
class _DeviceListTile extends StatelessWidget {
final SnAuthDevice device;
final Function(String) updateDeviceLabel;
final Function(String) logoutDevice;
const _DeviceListTile({
required this.device,
required this.updateDeviceLabel,
required this.logoutDevice,
});
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Icon(switch (device.platform) {
0 => Icons.device_unknown, // Unidentified
1 => Icons.web, // Web
2 => Icons.phone_iphone, // iOS
3 => Icons.phone_android, // Android
4 => Icons.laptop_mac, // macOS
5 => Icons.window, // Windows
6 => Icons.computer, // Linux
_ => Icons.device_unknown, // fallback
}).padding(top: 4),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('authSessionsCount'.plural(device.sessions.length)),
Text(
'lastActiveAt'.tr(
args: [
DateFormat().format(
device.sessions.first.lastGrantedAt.toLocal(),
),
],
),
),
Text(device.sessions.first.challenge.ipAddress),
if (device.isCurrent)
Row(
children: [
Badge(
backgroundColor: Theme.of(context).colorScheme.primary,
label: Text(
'authDeviceCurrent'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
),
],
).padding(top: 4),
],
),
title: Text(device.label ?? device.sessions.first.challenge.userAgent),
trailing:
isWideScreen(context)
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit),
tooltip: 'authDeviceEditLabel'.tr(),
onPressed:
() => updateDeviceLabel(device.sessions.first.id),
),
if (!device.isCurrent)
IconButton(
icon: Icon(Icons.logout),
tooltip: 'authDeviceLogout'.tr(),
onPressed: () => logoutDevice(device.sessions.first.id),
),
],
)
: null,
);
}
}
class AccountSessionSheet extends HookConsumerWidget {
const AccountSessionSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authDevices = ref.watch(authDevicesProvider);
void logoutDevice(String sessionId) async {
final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(),
);
if (!confirm || !context.mounted) return;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.delete('/accounts/me/sessions/$sessionId');
ref.invalidate(authDevicesProvider);
} catch (err) {
showErrorAlert(err);
}
}
void updateDeviceLabel(String sessionId) async {
final controller = TextEditingController();
final label = await showDialog<String>(
context: context,
builder:
(context) => AlertDialog(
title: Text('authDeviceLabelTitle'.tr()),
content: TextField(
controller: controller,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: 'authDeviceLabelHint'.tr(),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.pop(context, controller.text),
child: Text('confirm'.tr()),
),
],
),
);
if (label == null || label.isEmpty || !context.mounted) return;
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.patch(
'/accounts/me/sessions/$sessionId/label',
data: jsonEncode(label),
);
ref.invalidate(authDevicesProvider);
} catch (err) {
showErrorAlert(err);
}
}
final wideScreen = isWideScreen(context);
return SheetScaffold(
titleText: 'authSessions'.tr(),
child: authDevices.when(
data:
(data) => RefreshIndicator(
onRefresh:
() => Future.sync(() => ref.invalidate(authDevicesProvider)),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (context, index) {
final device = data[index];
if (wideScreen) {
return _DeviceListTile(
device: device,
updateDeviceLabel: updateDeviceLabel,
logoutDevice: logoutDevice,
);
} else {
return Dismissible(
key: Key('device-${device.sessions.first.id}'),
direction:
device.isCurrent
? DismissDirection.startToEnd
: DismissDirection.horizontal,
background: Container(
color: Colors.blue,
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.edit, color: Colors.white),
),
secondaryBackground: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: EdgeInsets.symmetric(horizontal: 20),
child: Icon(Icons.logout, color: Colors.white),
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
updateDeviceLabel(device.sessions.first.id);
return false;
} else {
final confirm = await showConfirmAlert(
'authDeviceLogoutHint'.tr(),
'authDeviceLogout'.tr(),
);
if (confirm && context.mounted) {
logoutDevice(device.sessions.first.id);
}
return false; // Don't dismiss
}
},
child: _DeviceListTile(
device: device,
updateDeviceLabel: updateDeviceLabel,
logoutDevice: logoutDevice,
),
);
}
},
),
),
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(authDevicesProvider),
),
loading: () => ResponseLoadingWidget(),
),
);
}
}

View File

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_session_sheet.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$authDevicesHash() => r'19807110962206a9637075d03cd372233cae2f49';
/// See also [authDevices].
@ProviderFor(authDevices)
final authDevicesProvider =
AutoDisposeFutureProvider<List<SnAuthDevice>>.internal(
authDevices,
name: r'authDevicesProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$authDevicesHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AuthDevicesRef = AutoDisposeFutureProviderRef<List<SnAuthDevice>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -133,54 +133,3 @@ class AccountStatusWidget extends HookConsumerWidget {
).opacity((userStatus.value?.isCustomized ?? false) ? 1 : 0.85);
}
}
class StatusActivityWidget extends StatelessWidget {
final SnActivity item;
const StatusActivityWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
final result = SnAccountStatus.fromJson(item.data);
return Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(
fileId: item.account.profile.picture?.id,
radius: 12,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Symbols.circle, size: 12).padding(top: 1, left: 2),
const Gap(4),
Text('status').fontSize(11).tr(),
],
).opacity(0.85),
Text(
result.clearedAt == null
? 'statusActivityTitle'
: 'statusActivityEndedTitle',
)
.tr(
args: [
item.account.nick,
result.label,
RelativeTime(context).format(result.createdAt),
if (result.clearedAt != null)
RelativeTime(context).format(result.clearedAt!),
],
)
.fontSize(13)
.padding(left: 2),
],
),
),
],
).padding(horizontal: 16, vertical: 12);
}
}

View File

@ -290,7 +290,9 @@ class _WebSocketIndicator extends HookConsumerWidget {
return AnimatedPositioned(
duration: Duration(milliseconds: 1850),
top:
!user.hasValue || websocketState == WebSocketState.connected()
!user.hasValue ||
user.value == null ||
websocketState == WebSocketState.connected()
? -indicatorHeight
: 0,
curve: Curves.fastLinearToSlowEaseIn,

View File

@ -1,5 +1,9 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart';
@ -27,6 +31,7 @@ class MessageItem extends HookConsumerWidget {
final Function(String action)? onAction;
final Map<int, double>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
const MessageItem({
super.key,
@ -35,6 +40,7 @@ class MessageItem extends HookConsumerWidget {
required this.onAction,
required this.progress,
required this.showAvatar,
required this.onJump,
});
@override
@ -54,6 +60,8 @@ class MessageItem extends HookConsumerWidget {
final remoteMessage = message.toRemoteMessage();
final sender = remoteMessage.sender;
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
return ContextMenuWidget(
menuProvider: (_) {
if (onAction == null) return Menu(children: []);
@ -90,6 +98,17 @@ class MessageItem extends HookConsumerWidget {
onAction!.call(MessageItemAction.forward);
},
),
if (isMobile) MenuSeparator(),
if (isMobile)
MenuAction(
title: 'copyMessage'.tr(),
image: MenuImage.icon(Symbols.copy_all),
callback: () {
Clipboard.setData(
ClipboardData(text: remoteMessage.content ?? ''),
);
},
),
],
);
},
@ -355,39 +374,67 @@ class MessageQuoteWidget extends HookConsumerWidget {
if (remoteMessage != null) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
color: Theme.of(
context,
).colorScheme.primaryFixedDim.withOpacity(0.4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isReply)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Icon(Symbols.reply, size: 16, color: textColor),
Text(
'Replying to ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8)
else
Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Icon(Symbols.forward, size: 16, color: textColor),
Text(
'Forwarded from ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8),
if (_MessageItemContent.hasContent(remoteMessage))
_MessageItemContent(item: remoteMessage),
],
child: GestureDetector(
onTap: () {
final messageId =
isReply
? message.toRemoteMessage().repliedMessageId!
: message.toRemoteMessage().forwardedMessageId!;
// Find the nearest MessageItem ancestor and call its onJump method
final MessageItem? ancestor =
context.findAncestorWidgetOfExactType<MessageItem>();
if (ancestor != null) {
ancestor.onJump(messageId);
}
},
child: Container(
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
color: Theme.of(
context,
).colorScheme.primaryFixedDim.withOpacity(0.4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isReply)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Icon(Symbols.reply, size: 16, color: textColor),
Text(
'${'repliedTo'.tr()} ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8)
else
Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Icon(Symbols.forward, size: 16, color: textColor),
Text(
'${'forwarded'.tr()} ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8),
if (_MessageItemContent.hasContent(remoteMessage))
_MessageItemContent(item: remoteMessage),
if (remoteMessage.attachments.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Symbols.attach_file, size: 12, color: textColor),
const SizedBox(width: 4),
Text(
'hasAttachments'.plural(
remoteMessage.attachments.length,
),
style: TextStyle(color: textColor, fontSize: 12),
),
],
).padding(vertical: 2),
],
),
),
),
).padding(bottom: 4);

View File

@ -20,6 +20,9 @@ String _parseRemoteError(DioException err) {
}
void showErrorAlert(dynamic err) async {
if (err is Error) {
log('${err.stackTrace}');
}
final text = switch (err) {
String _ => err,
DioException _ => _parseRemoteError(err),

View File

@ -119,7 +119,7 @@ class ProfilePictureWidget extends ConsumerWidget {
fallbackColor ??
Theme.of(context).colorScheme.onPrimaryContainer,
).center()
: CachedNetworkImage(imageUrl: uri, fit: BoxFit.cover),
: UniversalImage(uri: uri, fit: BoxFit.cover),
),
);
}
@ -298,7 +298,7 @@ class SplitAvatarWidget extends ConsumerWidget {
return SizedBox(
width: radius,
height: radius,
child: CachedNetworkImage(imageUrl: uri, fit: BoxFit.cover),
child: UniversalImage(uri: uri, fit: BoxFit.cover),
);
}
}

View File

@ -45,6 +45,16 @@ class UniversalImage extends StatelessWidget {
height: height,
memCacheHeight: cacheHeight,
memCacheWidth: cacheWidth,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: CircularProgressIndicator(value: progress.progress),
);
},
errorWidget: (context, url, error) {
return const Center(
child: Icon(Icons.broken_image, color: Colors.white, size: 16),
);
},
),
],
),

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class SheetScaffold extends StatelessWidget {
final Widget? title;
final String? titleText;
final List<Widget> actions;
final Widget child;
final double heightFactor;
const SheetScaffold({
super.key,
this.title,
this.titleText,
required this.child,
this.actions = const [],
this.heightFactor = 0.8,
});
@override
Widget build(BuildContext context) {
assert(title != null || titleText != null);
var titleWidget =
title ??
Text(
titleText!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * heightFactor,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
titleWidget,
const Spacer(),
...actions,
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(child: child),
],
),
);
}
}

View File

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
@ -37,26 +36,16 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url');
final token = await getToken(ref.watch(tokenProvider));
final fileStream = DefaultCacheManager().getFileStream(
final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile(
url,
headers: {'Authorization': 'Bearer $token'},
withProgress: true,
authHeaders: {'Authorization': 'AtField $token'},
);
await for (var fileInfo in fileStream) {
if (fileInfo is FileInfo) {
uri = fileInfo.file.path;
break;
}
}
uri = url;
} else {
uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url');
}
if (uri == null) {
showErrorAlert('Failed to open media... $url');
return;
}
_player!.open(Media(uri), play: false);
}

View File

@ -16,7 +16,6 @@ class PostListNotifier extends _$PostListNotifier
@override
Future<CursorPagingData<SnPost>> build(String? pubName) {
this.pubName = pubName;
return fetch(cursor: null);
}

View File

@ -6,7 +6,7 @@ part of 'post_list.dart';
// RiverpodGenerator
// **************************************************************************
String _$postListNotifierHash() => r'58a2d5d9a8f742f0a3a3e224a51a811d43903e0d';
String _$postListNotifierHash() => r'a2a273cbf96393a84a66bd6ae8e88058704f3195';
/// Copied from Dart SDK
class _SystemHash {

View File

@ -9,7 +9,11 @@ class ResponseErrorWidget extends StatelessWidget {
final dynamic error;
final VoidCallback onRetry;
const ResponseErrorWidget({super.key, required this.error, required this.onRetry});
const ResponseErrorWidget({
super.key,
required this.error,
required this.onRetry,
});
@override
Widget build(BuildContext context) {
@ -36,6 +40,15 @@ class ResponseErrorWidget extends StatelessWidget {
],
),
).center()
else if (error is DioException && error.response != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Text(
error.response.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF757575)),
),
).center()
else
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
class TechicalReviewIntroWidget extends StatelessWidget {
@ -8,55 +8,26 @@ class TechicalReviewIntroWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
child: Row(
children: [
Text(
'技术性预览',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('👋').fontSize(32),
Text('你好呀~').fontSize(24),
Text('欢迎来使用 Solar Network 3.0 的技术性预览版。'),
const Gap(24),
Text('技术性预览的初衷是让我们更顺滑的将 3.0 发布出来,帮助我们一点一点的迁移数据。'),
const Gap(24),
Text('同时,既然是测试版,肯定有一系列的 Bug 和问题,请多多包涵,也欢迎积极反馈到 GitHub 上。'),
Text('目前帐号数据已经迁移完毕,其他数据将在未来逐渐迁移。还请耐心等待,不要重复创建以免未来数据冲突。'),
const Gap(24),
Text('最后,感谢你愿意参与技术性预览,祝你使用愉快!'),
const Gap(16),
Text('关掉这个对话框就开始探索吧!').fontSize(11),
],
).padding(horizontal: 20, vertical: 24),
),
),
],
return SheetScaffold(
titleText: '技术性预览',
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('👋').fontSize(32),
Text('你好呀~').fontSize(24),
Text('欢迎来使用 Solar Network 3.0 的技术性预览版。'),
const Gap(24),
Text('技术性预览的初衷是让我们更顺滑的将 3.0 发布出来,帮助我们一点一点的迁移数据。'),
const Gap(24),
Text('同时,既然是测试版,肯定有一系列的 Bug 和问题,请多多包涵,也欢迎积极反馈到 GitHub 上。'),
Text('目前帐号数据已经迁移完毕,其他数据将在未来逐渐迁移。还请耐心等待,不要重复创建以免未来数据冲突。'),
const Gap(24),
Text('最后,感谢你愿意参与技术性预览,祝你使用愉快!'),
const Gap(16),
Text('关掉这个对话框就开始探索吧!').fontSize(11),
],
).padding(horizontal: 20, vertical: 24),
),
);
}

View File

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>Solian</string>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>

View File

@ -827,6 +827,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.6"
flutter_otp_text_field:
dependency: "direct main"
description:
name: flutter_otp_text_field
sha256: e7e589dc51cde120d63da6db55f3cef618f5d013d12adba76137ca1a51ce1390
url: "https://pub.dev"
source: hosted
version: "1.5.1+1"
flutter_platform_alert:
dependency: "direct main"
description:
@ -1550,6 +1558,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
recase:
dependency: transitive
description:

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 3.0.0+103
version: 3.0.0+104
environment:
sdk: ^3.7.2
@ -106,6 +106,8 @@ dependencies:
pasteboard: ^0.4.0
flutter_colorpicker: ^1.1.0
record: ^6.0.0
qr_flutter: ^4.1.0
flutter_otp_text_field: ^1.5.1+1
dev_dependencies:
flutter_test: