Compare commits

...

10 Commits

Author SHA1 Message Date
010a49251c 🚀 Launch 3.0.0+96 2025-05-29 01:45:51 +08:00
bdc13978c3 👽 Support the new Dyson Token 2025-05-28 23:21:13 +08:00
5d8c73e468 🐛 Fix replies activities didn't rendered 2025-05-28 23:08:53 +08:00
360572d7d1 🚀 Launch 3.0.0+95 2025-05-28 02:02:17 +08:00
9c1a983466 Reactive notification unread count 2025-05-28 01:52:44 +08:00
bfa97dcd11 💄 Optimized compose page 2025-05-28 01:13:49 +08:00
aaa505e83e Accoun settings 2025-05-28 01:08:18 +08:00
552bdfa58f 🐛 Fix publisher page renders error when publisher name didn't match username 2025-05-27 23:03:56 +08:00
ebde4eeed5 Reset password 2025-05-27 22:41:27 +08:00
688f035f85 Better chat overlay 2025-05-27 02:35:08 +08:00
39 changed files with 1749 additions and 1030 deletions

View File

@ -278,5 +278,35 @@
"settingsHideBottomNav": "Hide Bottom Navigation", "settingsHideBottomNav": "Hide Bottom Navigation",
"settingsSoundEffects": "Sound Effects", "settingsSoundEffects": "Sound Effects",
"settingsAprilFoolFeatures": "April Fool Features", "settingsAprilFoolFeatures": "April Fool Features",
"settingsEnterToSend": "Enter to Send" "settingsEnterToSend": "Enter to Send",
"postTitle": "Title",
"postDescription": "Description",
"call": "Call",
"done": "Done",
"loginResetPasswordSent": "Password reset link sent, please check your email inbox.",
"accountDeletion": "Delete Account",
"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.",
"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."
} }

View File

@ -186,7 +186,7 @@ class MessageRepository {
} }
Future<LocalChatMessage> sendMessage( Future<LocalChatMessage> sendMessage(
String atk, String token,
String baseUrl, String baseUrl,
String roomId, String roomId,
String content, String content,
@ -232,7 +232,7 @@ class MessageRepository {
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: attachments[idx].data, fileData: attachments[idx].data,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: attachments[idx].data.name ?? 'Post media', filename: attachments[idx].data.name ?? 'Post media',
mimetype: mimetype:

View File

@ -4,14 +4,11 @@ part 'auth.freezed.dart';
part 'auth.g.dart'; part 'auth.g.dart';
@freezed @freezed
sealed class AppTokenPair with _$AppTokenPair { sealed class AppToken with _$AppToken {
const factory AppTokenPair({ const factory AppToken({required String token}) = _AppToken;
required String accessToken,
required String refreshToken,
}) = _AppTokenPair;
factory AppTokenPair.fromJson(Map<String, dynamic> json) => factory AppToken.fromJson(Map<String, dynamic> json) =>
_$AppTokenPairFromJson(json); _$AppTokenFromJson(json);
} }
@freezed @freezed

View File

@ -14,42 +14,42 @@ part of 'auth.dart';
T _$identity<T>(T value) => value; T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$AppTokenPair { mixin _$AppToken {
String get accessToken; String get refreshToken; String get token;
/// Create a copy of AppTokenPair /// Create a copy of AppToken
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
$AppTokenPairCopyWith<AppTokenPair> get copyWith => _$AppTokenPairCopyWithImpl<AppTokenPair>(this as AppTokenPair, _$identity); $AppTokenCopyWith<AppToken> get copyWith => _$AppTokenCopyWithImpl<AppToken>(this as AppToken, _$identity);
/// Serializes this AppTokenPair to a JSON map. /// Serializes this AppToken to a JSON map.
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppTokenPair&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)); return identical(this, other) || (other.runtimeType == runtimeType&&other is AppToken&&(identical(other.token, token) || other.token == token));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,accessToken,refreshToken); int get hashCode => Object.hash(runtimeType,token);
@override @override
String toString() { String toString() {
return 'AppTokenPair(accessToken: $accessToken, refreshToken: $refreshToken)'; return 'AppToken(token: $token)';
} }
} }
/// @nodoc /// @nodoc
abstract mixin class $AppTokenPairCopyWith<$Res> { abstract mixin class $AppTokenCopyWith<$Res> {
factory $AppTokenPairCopyWith(AppTokenPair value, $Res Function(AppTokenPair) _then) = _$AppTokenPairCopyWithImpl; factory $AppTokenCopyWith(AppToken value, $Res Function(AppToken) _then) = _$AppTokenCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String accessToken, String refreshToken String token
}); });
@ -57,19 +57,18 @@ $Res call({
} }
/// @nodoc /// @nodoc
class _$AppTokenPairCopyWithImpl<$Res> class _$AppTokenCopyWithImpl<$Res>
implements $AppTokenPairCopyWith<$Res> { implements $AppTokenCopyWith<$Res> {
_$AppTokenPairCopyWithImpl(this._self, this._then); _$AppTokenCopyWithImpl(this._self, this._then);
final AppTokenPair _self; final AppToken _self;
final $Res Function(AppTokenPair) _then; final $Res Function(AppToken) _then;
/// Create a copy of AppTokenPair /// Create a copy of AppToken
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? refreshToken = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? token = null,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));
} }
@ -80,47 +79,46 @@ as String,
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _AppTokenPair implements AppTokenPair { class _AppToken implements AppToken {
const _AppTokenPair({required this.accessToken, required this.refreshToken}); const _AppToken({required this.token});
factory _AppTokenPair.fromJson(Map<String, dynamic> json) => _$AppTokenPairFromJson(json); factory _AppToken.fromJson(Map<String, dynamic> json) => _$AppTokenFromJson(json);
@override final String accessToken; @override final String token;
@override final String refreshToken;
/// Create a copy of AppTokenPair /// Create a copy of AppToken
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false) @override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline') @pragma('vm:prefer-inline')
_$AppTokenPairCopyWith<_AppTokenPair> get copyWith => __$AppTokenPairCopyWithImpl<_AppTokenPair>(this, _$identity); _$AppTokenCopyWith<_AppToken> get copyWith => __$AppTokenCopyWithImpl<_AppToken>(this, _$identity);
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$AppTokenPairToJson(this, ); return _$AppTokenToJson(this, );
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppTokenPair&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.refreshToken, refreshToken) || other.refreshToken == refreshToken)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppToken&&(identical(other.token, token) || other.token == token));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,accessToken,refreshToken); int get hashCode => Object.hash(runtimeType,token);
@override @override
String toString() { String toString() {
return 'AppTokenPair(accessToken: $accessToken, refreshToken: $refreshToken)'; return 'AppToken(token: $token)';
} }
} }
/// @nodoc /// @nodoc
abstract mixin class _$AppTokenPairCopyWith<$Res> implements $AppTokenPairCopyWith<$Res> { abstract mixin class _$AppTokenCopyWith<$Res> implements $AppTokenCopyWith<$Res> {
factory _$AppTokenPairCopyWith(_AppTokenPair value, $Res Function(_AppTokenPair) _then) = __$AppTokenPairCopyWithImpl; factory _$AppTokenCopyWith(_AppToken value, $Res Function(_AppToken) _then) = __$AppTokenCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String accessToken, String refreshToken String token
}); });
@ -128,19 +126,18 @@ $Res call({
} }
/// @nodoc /// @nodoc
class __$AppTokenPairCopyWithImpl<$Res> class __$AppTokenCopyWithImpl<$Res>
implements _$AppTokenPairCopyWith<$Res> { implements _$AppTokenCopyWith<$Res> {
__$AppTokenPairCopyWithImpl(this._self, this._then); __$AppTokenCopyWithImpl(this._self, this._then);
final _AppTokenPair _self; final _AppToken _self;
final $Res Function(_AppTokenPair) _then; final $Res Function(_AppToken) _then;
/// Create a copy of AppTokenPair /// Create a copy of AppToken
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? refreshToken = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? token = null,}) {
return _then(_AppTokenPair( return _then(_AppToken(
accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable token: null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable
as String,refreshToken: null == refreshToken ? _self.refreshToken : refreshToken // ignore: cast_nullable_to_non_nullable
as String, as String,
)); ));
} }

View File

@ -6,17 +6,12 @@ part of 'auth.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
_AppTokenPair _$AppTokenPairFromJson(Map<String, dynamic> json) => _AppToken _$AppTokenFromJson(Map<String, dynamic> json) =>
_AppTokenPair( _AppToken(token: json['token'] as String);
accessToken: json['access_token'] as String,
refreshToken: json['refresh_token'] as String,
);
Map<String, dynamic> _$AppTokenPairToJson(_AppTokenPair instance) => Map<String, dynamic> _$AppTokenToJson(_AppToken instance) => <String, dynamic>{
<String, dynamic>{ 'token': instance.token,
'access_token': instance.accessToken, };
'refresh_token': instance.refreshToken,
};
_SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
_SnAuthChallenge( _SnAuthChallenge(

View File

@ -1,5 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/user.dart';
part 'post.freezed.dart'; part 'post.freezed.dart';
part 'post.g.dart'; part 'post.g.dart';
@ -54,6 +55,7 @@ sealed class SnPublisher with _$SnPublisher {
required SnCloudFile? picture, required SnCloudFile? picture,
required String? backgroundId, required String? backgroundId,
required SnCloudFile? background, required SnCloudFile? background,
required SnAccount? account,
required String? accountId, required String? accountId,
required DateTime createdAt, required DateTime createdAt,
required DateTime updatedAt, required DateTime updatedAt,

View File

@ -370,7 +370,7 @@ $SnPublisherCopyWith<$Res> get publisher {
/// @nodoc /// @nodoc
mixin _$SnPublisher { mixin _$SnPublisher {
String get id; int get type; String get name; String get nick; String get bio; String? get pictureId; SnCloudFile? get picture; String? get backgroundId; SnCloudFile? get background; String? get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get realmId; String get id; int get type; String get name; String get nick; String get bio; String? get pictureId; SnCloudFile? get picture; String? get backgroundId; SnCloudFile? get background; SnAccount? get account; String? get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String? get realmId;
/// Create a copy of SnPublisher /// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -383,16 +383,16 @@ $SnPublisherCopyWith<SnPublisher> get copyWith => _$SnPublisherCopyWithImpl<SnPu
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.pictureId, pictureId) || other.pictureId == pictureId)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.backgroundId, backgroundId) || other.backgroundId == backgroundId)&&(identical(other.background, background) || other.background == background)&&(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)&&(identical(other.realmId, realmId) || other.realmId == realmId)); return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.pictureId, pictureId) || other.pictureId == pictureId)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.backgroundId, backgroundId) || other.backgroundId == backgroundId)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(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)&&(identical(other.realmId, realmId) || other.realmId == realmId));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,pictureId,picture,backgroundId,background,accountId,createdAt,updatedAt,deletedAt,realmId); int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,pictureId,picture,backgroundId,background,account,accountId,createdAt,updatedAt,deletedAt,realmId);
@override @override
String toString() { String toString() {
return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, pictureId: $pictureId, picture: $picture, backgroundId: $backgroundId, background: $background, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)'; return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, pictureId: $pictureId, picture: $picture, backgroundId: $backgroundId, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)';
} }
@ -403,11 +403,11 @@ abstract mixin class $SnPublisherCopyWith<$Res> {
factory $SnPublisherCopyWith(SnPublisher value, $Res Function(SnPublisher) _then) = _$SnPublisherCopyWithImpl; factory $SnPublisherCopyWith(SnPublisher value, $Res Function(SnPublisher) _then) = _$SnPublisherCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String id, int type, String name, String nick, String bio, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId String id, int type, String name, String nick, String bio, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId
}); });
$SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background; $SnCloudFileCopyWith<$Res>? get picture;$SnCloudFileCopyWith<$Res>? get background;$SnAccountCopyWith<$Res>? get account;
} }
/// @nodoc /// @nodoc
@ -420,7 +420,7 @@ class _$SnPublisherCopyWithImpl<$Res>
/// Create a copy of SnPublisher /// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values. /// 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? name = null,Object? nick = null,Object? bio = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@ -431,7 +431,8 @@ as String,pictureId: freezed == pictureId ? _self.pictureId : pictureId // ignor
as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,backgroundId: freezed == backgroundId ? _self.backgroundId : backgroundId // ignore: cast_nullable_to_non_nullable as SnCloudFile?,backgroundId: freezed == backgroundId ? _self.backgroundId : backgroundId // ignore: cast_nullable_to_non_nullable
as String?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable as String?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable as SnCloudFile?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,accountId: freezed == 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 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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
@ -463,6 +464,18 @@ $SnCloudFileCopyWith<$Res>? get background {
return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { return $SnCloudFileCopyWith<$Res>(_self.background!, (value) {
return _then(_self.copyWith(background: value)); return _then(_self.copyWith(background: value));
}); });
}/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
} }
} }
@ -471,7 +484,7 @@ $SnCloudFileCopyWith<$Res>? get background {
@JsonSerializable() @JsonSerializable()
class _SnPublisher implements SnPublisher { class _SnPublisher implements SnPublisher {
const _SnPublisher({required this.id, required this.type, required this.name, required this.nick, this.bio = '', required this.pictureId, required this.picture, required this.backgroundId, required this.background, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.realmId}); const _SnPublisher({required this.id, required this.type, required this.name, required this.nick, this.bio = '', required this.pictureId, required this.picture, required this.backgroundId, required this.background, required this.account, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.realmId});
factory _SnPublisher.fromJson(Map<String, dynamic> json) => _$SnPublisherFromJson(json); factory _SnPublisher.fromJson(Map<String, dynamic> json) => _$SnPublisherFromJson(json);
@override final String id; @override final String id;
@ -483,6 +496,7 @@ class _SnPublisher implements SnPublisher {
@override final SnCloudFile? picture; @override final SnCloudFile? picture;
@override final String? backgroundId; @override final String? backgroundId;
@override final SnCloudFile? background; @override final SnCloudFile? background;
@override final SnAccount? account;
@override final String? accountId; @override final String? accountId;
@override final DateTime createdAt; @override final DateTime createdAt;
@override final DateTime updatedAt; @override final DateTime updatedAt;
@ -502,16 +516,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.pictureId, pictureId) || other.pictureId == pictureId)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.backgroundId, backgroundId) || other.backgroundId == backgroundId)&&(identical(other.background, background) || other.background == background)&&(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)&&(identical(other.realmId, realmId) || other.realmId == realmId)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPublisher&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.name, name) || other.name == name)&&(identical(other.nick, nick) || other.nick == nick)&&(identical(other.bio, bio) || other.bio == bio)&&(identical(other.pictureId, pictureId) || other.pictureId == pictureId)&&(identical(other.picture, picture) || other.picture == picture)&&(identical(other.backgroundId, backgroundId) || other.backgroundId == backgroundId)&&(identical(other.background, background) || other.background == background)&&(identical(other.account, account) || other.account == account)&&(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)&&(identical(other.realmId, realmId) || other.realmId == realmId));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,pictureId,picture,backgroundId,background,accountId,createdAt,updatedAt,deletedAt,realmId); int get hashCode => Object.hash(runtimeType,id,type,name,nick,bio,pictureId,picture,backgroundId,background,account,accountId,createdAt,updatedAt,deletedAt,realmId);
@override @override
String toString() { String toString() {
return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, pictureId: $pictureId, picture: $picture, backgroundId: $backgroundId, background: $background, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)'; return 'SnPublisher(id: $id, type: $type, name: $name, nick: $nick, bio: $bio, pictureId: $pictureId, picture: $picture, backgroundId: $backgroundId, background: $background, account: $account, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, realmId: $realmId)';
} }
@ -522,11 +536,11 @@ abstract mixin class _$SnPublisherCopyWith<$Res> implements $SnPublisherCopyWith
factory _$SnPublisherCopyWith(_SnPublisher value, $Res Function(_SnPublisher) _then) = __$SnPublisherCopyWithImpl; factory _$SnPublisherCopyWith(_SnPublisher value, $Res Function(_SnPublisher) _then) = __$SnPublisherCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String id, int type, String name, String nick, String bio, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId String id, int type, String name, String nick, String bio, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, SnAccount? account, String? accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String? realmId
}); });
@override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background; @override $SnCloudFileCopyWith<$Res>? get picture;@override $SnCloudFileCopyWith<$Res>? get background;@override $SnAccountCopyWith<$Res>? get account;
} }
/// @nodoc /// @nodoc
@ -539,7 +553,7 @@ class __$SnPublisherCopyWithImpl<$Res>
/// Create a copy of SnPublisher /// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values. /// 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? name = null,Object? nick = null,Object? bio = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? name = null,Object? nick = null,Object? bio = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? account = freezed,Object? accountId = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? realmId = freezed,}) {
return _then(_SnPublisher( return _then(_SnPublisher(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@ -550,7 +564,8 @@ as String,pictureId: freezed == pictureId ? _self.pictureId : pictureId // ignor
as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,backgroundId: freezed == backgroundId ? _self.backgroundId : backgroundId // ignore: cast_nullable_to_non_nullable as SnCloudFile?,backgroundId: freezed == backgroundId ? _self.backgroundId : backgroundId // ignore: cast_nullable_to_non_nullable
as String?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable as String?,background: freezed == background ? _self.background : background // ignore: cast_nullable_to_non_nullable
as SnCloudFile?,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable as SnCloudFile?,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,accountId: freezed == 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 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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
@ -583,6 +598,18 @@ $SnCloudFileCopyWith<$Res>? get background {
return $SnCloudFileCopyWith<$Res>(_self.background!, (value) { return $SnCloudFileCopyWith<$Res>(_self.background!, (value) {
return _then(_self.copyWith(background: value)); return _then(_self.copyWith(background: value));
}); });
}/// Create a copy of SnPublisher
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
} }
} }

View File

@ -110,6 +110,10 @@ _SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher(
json['background'] == null json['background'] == null
? null ? null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>), : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
account:
json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: json['account_id'] as String?, accountId: json['account_id'] as String?,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
@ -131,6 +135,7 @@ Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
'picture': instance.picture?.toJson(), 'picture': instance.picture?.toJson(),
'background_id': instance.backgroundId, 'background_id': instance.backgroundId,
'background': instance.background?.toJson(), 'background': instance.background?.toJson(),
'account': instance.account?.toJson(),
'account_id': instance.accountId, 'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(), 'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(), 'updated_at': instance.updatedAt.toIso8601String(),

View File

@ -1,5 +1,6 @@
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:island/widgets/chat/call_button.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async'; import 'dart:async';
@ -11,6 +12,14 @@ import 'package:island/pods/websocket.dart';
part 'call.g.dart'; part 'call.g.dart';
part 'call.freezed.dart'; part 'call.freezed.dart';
String formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : '';
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs());
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs());
return "$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
}
@freezed @freezed
sealed class CallState with _$CallState { sealed class CallState with _$CallState {
const factory CallState({ const factory CallState({
@ -18,6 +27,7 @@ sealed class CallState with _$CallState {
required bool isMicrophoneEnabled, required bool isMicrophoneEnabled,
required bool isCameraEnabled, required bool isCameraEnabled,
required bool isScreenSharing, required bool isScreenSharing,
@Default(Duration(seconds: 0)) Duration duration,
String? error, String? error,
}) = _CallState; }) = _CallState;
} }
@ -54,6 +64,8 @@ class CallNotifier extends _$CallNotifier {
List.unmodifiable(_participants); List.unmodifiable(_participants);
LocalParticipant? get localParticipant => _localParticipant; LocalParticipant? get localParticipant => _localParticipant;
Timer? _durationTimer;
@override @override
CallState build() { CallState build() {
// Subscribe to websocket updates // Subscribe to websocket updates
@ -219,8 +231,16 @@ class CallNotifier extends _$CallNotifier {
Future<void> joinRoom(String roomId) async { Future<void> joinRoom(String roomId) async {
_roomId = roomId; _roomId = roomId;
if (_room != null) {
await _room!.disconnect();
await _room!.dispose();
_room = null;
_localParticipant = null;
_participants = [];
}
try { try {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
final response = await apiClient.get('/chat/realtime/$roomId/join'); final response = await apiClient.get('/chat/realtime/$roomId/join');
if (response.statusCode == 200 && response.data != null) { if (response.statusCode == 200 && response.data != null) {
final data = response.data; final data = response.data;
@ -229,6 +249,19 @@ class CallNotifier extends _$CallNotifier {
final participants = joinResponse.participants; final participants = joinResponse.participants;
final String endpoint = joinResponse.endpoint; final String endpoint = joinResponse.endpoint;
final String token = joinResponse.token; final String token = joinResponse.token;
// Setup duration timer
_durationTimer?.cancel();
_durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
state = state.copyWith(
duration: Duration(
milliseconds:
(DateTime.now().millisecondsSinceEpoch -
(ongoingCall?.createdAt.millisecondsSinceEpoch ?? 0)),
),
);
});
// Connect to LiveKit // Connect to LiveKit
_room = Room(); _room = Room();
@ -314,5 +347,6 @@ class CallNotifier extends _$CallNotifier {
_roomListener?.dispose(); _roomListener?.dispose();
_room?.removeListener(_onRoomChange); _room?.removeListener(_onRoomChange);
_room?.dispose(); _room?.dispose();
_durationTimer?.cancel();
} }
} }

View File

@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CallState { mixin _$CallState {
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; String? get error; bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; Duration get duration; String? get error;
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@ -26,16 +26,16 @@ $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error)); return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
} }
@override @override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error); int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
@override @override
String toString() { String toString() {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)'; return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)';
} }
@ -46,7 +46,7 @@ abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error
}); });
@ -63,13 +63,14 @@ class _$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?, as String?,
)); ));
} }
@ -81,13 +82,14 @@ as String?,
class _CallState implements CallState { class _CallState implements CallState {
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.error}); const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.duration = const Duration(seconds: 0), this.error});
@override final bool isConnected; @override final bool isConnected;
@override final bool isMicrophoneEnabled; @override final bool isMicrophoneEnabled;
@override final bool isCameraEnabled; @override final bool isCameraEnabled;
@override final bool isScreenSharing; @override final bool isScreenSharing;
@override@JsonKey() final Duration duration;
@override final String? error; @override final String? error;
/// Create a copy of CallState /// Create a copy of CallState
@ -100,16 +102,16 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error));
} }
@override @override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error); int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
@override @override
String toString() { String toString() {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)'; return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)';
} }
@ -120,7 +122,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error
}); });
@ -137,13 +139,14 @@ class __$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) {
return _then(_CallState( return _then(_CallState(
isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?, as String?,
)); ));
} }

View File

@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'5512070f943d98e999d97549c73e4d5f6e7b3ddd'; String _$callNotifierHash() => r'2082a572b5cfb4bf929dc1ed492c52cd2735452e';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -68,16 +67,9 @@ final apiClientProvider = Provider<Dio>((ref) {
RequestInterceptorHandler handler, RequestInterceptorHandler handler,
) async { ) async {
try { try {
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token != null) {
ref.watch(serverUrlProvider), options.headers['Authorization'] = 'AtField $token';
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
} }
} catch (err) { } catch (err) {
// ignore // ignore
@ -95,105 +87,21 @@ final apiClientProvider = Provider<Dio>((ref) {
return dio; return dio;
}); });
final tokenPairProvider = Provider<AppTokenPair?>((ref) { final tokenProvider = Provider<AppToken?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider); final prefs = ref.watch(sharedPreferencesProvider);
final tkPairString = prefs.getString(kTokenPairStoreKey); final tokenString = prefs.getString(kTokenPairStoreKey);
if (tkPairString == null) return null; if (tokenString == null) return null;
return AppTokenPair.fromJson(jsonDecode(tkPairString)); return AppToken.fromJson(jsonDecode(tokenString));
}); });
Future<(String, String)?> refreshToken(String baseUrl, String? rtk) async { // Token refresh functionality removed as per backend changes
if (rtk == null) return null;
final dio = Dio(); Future<String?> getToken(AppToken? token) async {
dio.options.baseUrl = baseUrl; return token?.token;
final resp = await dio.post(
'/auth/token',
data: {'grant_type': 'refresh_token', 'refresh_token': rtk},
);
final String atk = resp.data['access_token'];
final String nRtk = resp.data['refresh_token'];
return (atk, nRtk);
} }
Completer<String?>? _refreshCompleter; Future<void> setToken(SharedPreferences prefs, String token) async {
final appToken = AppToken(token: token);
Future<String?> getFreshAtk( final tokenString = jsonEncode(appToken);
AppTokenPair? tkPair, prefs.setString(kTokenPairStoreKey, tokenString);
String baseUrl, {
Function(String, String)? onRefreshed,
}) async {
var atk = tkPair?.accessToken;
var rtk = tkPair?.refreshToken;
if (_refreshCompleter != null) {
return await _refreshCompleter!.future;
} else {
_refreshCompleter = Completer<String?>();
}
try {
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('[Auth] Access token need refresh, doing it at ${DateTime.now()}');
final result = await refreshToken(baseUrl, rtk);
if (result == null) {
atk = null;
} else {
onRefreshed?.call(result.$1, result.$2);
atk = result.$1;
}
}
if (atk != null) {
_refreshCompleter!.complete(atk);
return atk;
} else {
log('[Auth] Access token refresh failed...');
_refreshCompleter!.complete(null);
}
}
} catch (err) {
log('[Auth] Failed to authenticate user... $err');
_refreshCompleter!.completeError(err);
} finally {
_refreshCompleter = null;
}
return null;
}
Future<void> setTokenPair(
SharedPreferences prefs,
String atk,
String rtk,
) async {
final tkPair = AppTokenPair(accessToken: atk, refreshToken: rtk);
final tkPairString = jsonEncode(tkPair);
prefs.setString(kTokenPairStoreKey, tkPairString);
} }

View File

@ -13,7 +13,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
Future<String?> getAccessToken() async { Future<String?> getAccessToken() async {
final prefs = _ref.read(sharedPreferencesProvider); final prefs = _ref.read(sharedPreferencesProvider);
return prefs.getString('dyn_user_atk'); return prefs.getString(kTokenPairStoreKey);
} }
Future<void> fetchUser() async { Future<void> fetchUser() async {

View File

@ -51,27 +51,21 @@ class WebSocketService {
Future<void> connect(Ref ref) async { Future<void> connect(Ref ref) async {
_ref = ref; _ref = ref;
_statusStreamController.sink.add(WebSocketState.connecting());
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider),
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
final url = '$baseUrl/ws'.replaceFirst('http', 'ws'); final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
log('[WebSocket] Trying connecting to $url'); log('[WebSocket] Trying connecting to $url');
try { try {
if (kIsWeb) { if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$atk')); _channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
} else { } else {
_channel = IOWebSocketChannel.connect( _channel = IOWebSocketChannel.connect(
Uri.parse(url), Uri.parse(url),
headers: {'Authorization': 'Bearer $atk'}, headers: {'Authorization': 'Bearer $token'},
); );
} }
await _channel!.ready; await _channel!.ready;
@ -140,23 +134,10 @@ class WebSocketStateNotifier extends StateNotifier<WebSocketState> {
state = const WebSocketState.connecting(); state = const WebSocketState.connecting();
try { try {
final service = ref.read(websocketProvider); final service = ref.read(websocketProvider);
final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk(
ref.watch(tokenPairProvider),
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) {
state = const WebSocketState.error('Unauthorized');
return;
}
await service.connect(ref); await service.connect(ref);
state = const WebSocketState.connected(); state = const WebSocketState.connected();
service.statusStream.listen((event) { service.statusStream.listen((event) {
state = event; if (mounted) state = event;
}); });
} catch (err) { } catch (err) {
state = WebSocketState.error('Failed to connect: $err'); state = WebSocketState.error('Failed to connect: $err');

View File

@ -235,6 +235,16 @@ class AccountScreen extends HookConsumerWidget {
context.router.push(SettingsRoute()); context.router.push(SettingsRoute());
}, },
), ),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.manage_accounts),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('accountSettings').tr(),
onTap: () {
context.router.push(AccountSettingsRoute());
},
),
if (kDebugMode) const Divider(height: 1).padding(vertical: 8), if (kDebugMode) const Divider(height: 1).padding(vertical: 8),
if (kDebugMode) if (kDebugMode)
ListTile( ListTile(
@ -244,8 +254,8 @@ class AccountScreen extends HookConsumerWidget {
contentPadding: EdgeInsets.symmetric(horizontal: 24), contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('Copy access token'), title: Text('Copy access token'),
onTap: () async { onTap: () async {
final tk = ref.watch(tokenPairProvider); final tk = ref.watch(tokenProvider);
Clipboard.setData(ClipboardData(text: tk!.accessToken)); Clipboard.setData(ClipboardData(text: tk!.token));
}, },
), ),
if (kDebugMode) if (kDebugMode)
@ -284,7 +294,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppScaffold( return AppScaffold(
appBar: AppBar(title: const Text('Account')), appBar: AppBar(title: const Text('account').tr()),
body: body:
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360), constraints: const BoxConstraints(maxWidth: 360),

View File

@ -1,8 +1,19 @@
import 'dart:io';
import 'package:auto_route/annotations.dart'; import 'package:auto_route/annotations.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/captcha.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@RoutePage() @RoutePage()
class AccountSettingsScreen extends HookConsumerWidget { class AccountSettingsScreen extends HookConsumerWidget {
@ -10,9 +21,274 @@ class AccountSettingsScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isDesktop =
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
final isWide = isWideScreen(context);
Future<void> requestAccountDeletion() async {
final confirm = await showConfirmAlert(
'accountDeletionHint'.tr(),
'accountDeletion'.tr(),
);
if (!confirm || !context.mounted) return;
try {
final client = ref.read(apiClientProvider);
await client.delete('/accounts/me');
if (context.mounted) {
showSnackBar(context, 'accountDeletionSent'.tr());
}
} catch (err) {
showErrorAlert(err);
}
}
Future<void> requestResetPassword() async {
final confirm = await showConfirmAlert(
'accountPasswordChangeDescription'.tr(),
'accountPassword'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
if (captchaTk == null) return;
try {
final userInfo = ref.read(userInfoProvider);
final client = ref.read(apiClientProvider);
await client.post(
'/accounts/recovery/password',
data: {'account': userInfo.value!.name, 'captcha_token': captchaTk},
);
if (context.mounted) {
showSnackBar(context, 'accountPasswordChangeSent'.tr());
}
} catch (err) {
showErrorAlert(err);
}
}
// Group settings into categories for better organization
final securitySettings = [
ListTile(
minLeadingWidth: 48,
title: Text('accountPassword').tr(),
subtitle: Text('accountPasswordDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.password),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
requestResetPassword();
},
),
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
},
child: Text('accountTwoFactorSetup').tr(),
),
],
),
);
},
),
];
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());
},
),
];
final dangerZoneSettings = [
ListTile(
minLeadingWidth: 48,
title: Text('accountDeletion').tr(),
subtitle: Text('accountDeletionDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.delete_forever, color: Colors.red),
trailing: const Icon(Symbols.chevron_right),
onTap: requestAccountDeletion,
),
];
// Create a responsive layout based on screen width
Widget buildSettingsList() {
if (isWide) {
// Two-column layout for wide screens
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'accountSecurityTitle',
children: securitySettings,
),
_SettingsSection(
title: 'accountPrivacyTitle',
children: privacySettings,
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'accountDangerZoneTitle',
children: dangerZoneSettings,
),
],
),
),
],
).padding(horizontal: 16);
} else {
// Single column layout for narrow screens
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'accountSecurityTitle',
children: securitySettings,
),
_SettingsSection(
title: 'accountPrivacyTitle',
children: privacySettings,
),
_SettingsSection(
title: 'accountDangerZoneTitle',
children: dangerZoneSettings,
),
],
);
}
}
return AppScaffold( return AppScaffold(
appBar: AppBar(title: Text('accountSettings').tr()), appBar: AppBar(
body: SingleChildScrollView(child: Column(children: [])), title: Text('accountSettings').tr(),
actions:
isDesktop
? [
IconButton(
icon: const Icon(Symbols.help_outline),
onPressed: () {
// Show help dialog
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text('accountSettingsHelp').tr(),
content: Text('accountSettingsHelpContent').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
),
],
),
);
},
),
]
: null,
),
body: Focus(
autofocus: true,
onKeyEvent: (node, event) {
// Add keyboard shortcuts for desktop
if (isDesktop &&
event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: buildSettingsList(),
),
),
);
}
}
// Helper widget for displaying settings sections with titles
class _SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _SettingsSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
title.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
const SizedBox(height: 16),
],
); );
} }
} }

View File

@ -59,19 +59,12 @@ class UpdateProfileScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: result, fileData: result,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: result.name, filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg', mimetype: result.mimeType ?? 'image/jpeg',

View File

@ -19,6 +19,8 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'captcha.dart';
final Map<int, (String, String, IconData)> kFactorTypes = { final Map<int, (String, String, IconData)> kFactorTypes = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password), 0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email), 1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
@ -140,10 +142,9 @@ class _LoginCheckScreen extends HookConsumerWidget {
'/auth/token', '/auth/token',
data: {'grant_type': 'authorization_code', 'code': result.id}, data: {'grant_type': 'authorization_code', 'code': result.id},
); );
final atk = tokenResp.data['access_token']; final token = tokenResp.data['token'];
final rtk = tokenResp.data['refresh_token']; setToken(ref.watch(sharedPreferencesProvider), token);
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk); ref.invalidate(tokenProvider);
ref.invalidate(tokenPairProvider);
if (!context.mounted) return; if (!context.mounted) return;
final userNotifier = ref.read(userInfoProvider.notifier); final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
@ -354,15 +355,18 @@ class _LoginLookupScreen extends HookConsumerWidget {
showErrorAlert('loginResetPasswordHint'.tr()); showErrorAlert('loginResetPasswordHint'.tr());
return; return;
} }
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
if (captchaTk == null) return;
isBusy.value = true; isBusy.value = true;
try { try {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
final lookupResp = await client.get('/users/lookup?probe=$uname');
await client.post( await client.post(
'/users/me/password-reset', '/accounts/recovery/password',
data: {'user_id': lookupResp.data['id']}, data: {'account': uname, 'captcha_token': captchaTk},
); );
showInfoAlert('done'.tr(), 'signinResetPasswordSent'.tr()); showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {

View File

@ -17,6 +17,7 @@ class TabNavigationObserver extends AutoRouterObserver {
@override @override
void didPush(Route route, Route? previousRoute) { void didPush(Route route, Route? previousRoute) {
if (route is DialogRoute) return;
Future(() { Future(() {
onChange(route.settings.name); onChange(route.settings.name);
}); });
@ -24,6 +25,7 @@ class TabNavigationObserver extends AutoRouterObserver {
@override @override
void didPop(Route route, Route? previousRoute) { void didPop(Route route, Route? previousRoute) {
if (route is DialogRoute) return;
Future(() { Future(() {
onChange(previousRoute?.settings.name); onChange(previousRoute?.settings.name);
}); });

View File

@ -5,11 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/chat/chat.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -22,8 +21,6 @@ class CallScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(roomId)); final ongoingCall = ref.watch(ongoingCallProvider(roomId));
final userInfo = ref.watch(userInfoProvider);
final chatRoom = ref.watch(chatroomProvider(roomId));
final callState = ref.watch(callNotifierProvider); final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
@ -32,10 +29,6 @@ class CallScreen extends HookConsumerWidget {
return null; return null;
}, []); }, []);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
final viewMode = useState<String>('grid'); final viewMode = useState<String>('grid');
return AppScaffold( return AppScaffold(
@ -74,20 +67,12 @@ class CallScreen extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
chatRoom.whenOrNull()?.name ?? 'loading'.tr(), ongoingCall.value?.room.name ?? 'call'.tr(),
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Text( Text(
callState.isConnected callState.isConnected
? Duration( ? formatDuration(callState.duration)
milliseconds:
(DateTime.now().millisecondsSinceEpoch -
(ongoingCall
.value
?.createdAt
.millisecondsSinceEpoch ??
0)),
).toString()
: 'Connecting', : 'Connecting',
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
@ -131,78 +116,6 @@ class CallScreen extends HookConsumerWidget {
) )
: Column( : Column(
children: [ children: [
Card(
margin: const EdgeInsets.only(left: 12, right: 12, top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking:
callNotifier
.localParticipant!
.isSpeaking,
audioLevel:
callNotifier
.localParticipant!
.audioLevel,
pictureId:
userInfo.value?.profile.pictureId,
size: 36,
).center(),
);
},
),
],
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled
? Icons.mic
: Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled
? Icons.videocam
: Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
),
],
).padding(all: 16),
),
Expanded( Expanded(
child: Builder( child: Builder(
builder: (context) { builder: (context) {
@ -374,6 +287,8 @@ class CallScreen extends HookConsumerWidget {
}, },
), ),
), ),
CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16),
], ],
), ),
); );

View File

@ -11,6 +11,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/call.dart';
import 'package:island/pods/chat_summary.dart'; import 'package:island/pods/chat_summary.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@ -21,6 +22,7 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.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/cloud_files.dart';
import 'package:island/widgets/realms/selection_dropdown.dart'; import 'package:island/widgets/realms/selection_dropdown.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
@ -147,12 +149,11 @@ class ChatRoomListTile extends HookConsumerWidget {
subtitle: buildSubtitle(), subtitle: buildSubtitle(),
onTap: () async { onTap: () async {
// Clear unread count if there are unread messages // Clear unread count if there are unread messages
final summary = await ref.read(chatSummaryProvider.future); ref.read(chatSummaryProvider.future).then((summary) {
if ((summary[room.id]?.unreadCount ?? 0) > 0) { if ((summary[room.id]?.unreadCount ?? 0) > 0) {
await ref ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
.read(chatSummaryProvider.notifier)
.clearUnreadCount(room.id);
} }
});
onTap?.call(); onTap?.call();
}, },
); );
@ -213,6 +214,8 @@ class ChatListScreen extends HookConsumerWidget {
0, 0,
); // 0 for All, 1 for Direct Messages, 2 for Group Chats ); // 0 for All, 1 for Direct Messages, 2 for Group Chats
final callState = ref.watch(callNotifierProvider);
useEffect(() { useEffect(() {
tabController.addListener(() { tabController.addListener(() {
selectedTab.value = tabController.index; selectedTab.value = tabController.index;
@ -334,7 +337,9 @@ class ChatListScreen extends HookConsumerWidget {
}, },
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
body: Column( body: Stack(
children: [
Column(
children: [ children: [
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
@ -354,7 +359,10 @@ class ChatListScreen extends HookConsumerWidget {
ref.invalidate(chatroomsJoinedProvider); ref.invalidate(chatroomsJoinedProvider);
}), }),
child: ListView.builder( child: ListView.builder(
padding: EdgeInsets.zero, padding:
callState.isConnected
? EdgeInsets.only(bottom: 96)
: EdgeInsets.zero,
itemCount: itemCount:
items items
.where( .where(
@ -362,7 +370,8 @@ class ChatListScreen extends HookConsumerWidget {
selectedTab.value == 0 || selectedTab.value == 0 ||
(selectedTab.value == 1 && (selectedTab.value == 1 &&
item.type == 1) || item.type == 1) ||
(selectedTab.value == 2 && item.type != 1), (selectedTab.value == 2 &&
item.type != 1),
) )
.length, .length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -388,14 +397,17 @@ class ChatListScreen extends HookConsumerWidget {
ChatRoomRoute(id: item.id), ChatRoomRoute(id: item.id),
); );
} else { } else {
context.router.push(ChatRoomRoute(id: item.id)); context.router.push(
ChatRoomRoute(id: item.id),
);
} }
}, },
); );
}, },
), ),
), ),
loading: () => const Center(child: CircularProgressIndicator()), loading:
() => const Center(child: CircularProgressIndicator()),
error: error:
(error, stack) => ResponseErrorWidget( (error, stack) => ResponseErrorWidget(
error: error, error: error,
@ -407,6 +419,14 @@ class ChatListScreen extends HookConsumerWidget {
), ),
], ],
), ),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: const CallOverlayBar().padding(horizontal: 16, vertical: 12),
),
],
),
); );
} }
} }
@ -502,19 +522,12 @@ class EditChatScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: result, fileData: result,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: result.name, filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg', mimetype: result.mimeType ?? 'image/jpeg',

View File

@ -22,6 +22,7 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/widgets/chat/message_item.dart'; import 'package:island/widgets/chat/message_item.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
@ -116,19 +117,12 @@ class MessagesNotifier extends _$MessagesNotifier {
messageRepositoryProvider(_roomId).future, messageRepositoryProvider(_roomId).future,
); );
final baseUrl = ref.read(serverUrlProvider); final baseUrl = ref.read(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Access token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
await repository.sendMessage( await repository.sendMessage(
atk, token,
baseUrl, baseUrl,
_roomId, _roomId,
content, content,
@ -352,6 +346,10 @@ class ChatRoomScreen extends HookConsumerWidget {
if (message.chatRoomId != chatRoom.value?.id) return; if (message.chatRoomId != chatRoom.value?.id) return;
switch (pkt.type) { switch (pkt.type) {
case 'messages.new': case 'messages.new':
if (message.type.startsWith('call')) {
// Handle the ongoing call.
ref.invalidate(ongoingCallProvider(message.chatRoomId));
}
messagesNotifier.receiveMessage(message); messagesNotifier.receiveMessage(message);
// Send read receipt for new message // Send read receipt for new message
sendReadReceipt(); sendReadReceipt();
@ -525,7 +523,9 @@ class ChatRoomScreen extends HookConsumerWidget {
const Gap(8), const Gap(8),
], ],
), ),
body: Column( body: Stack(
children: [
Column(
children: [ children: [
Expanded( Expanded(
child: messages.when( child: messages.when(
@ -536,7 +536,8 @@ class ChatRoomScreen extends HookConsumerWidget {
: SuperListView.builder( : SuperListView.builder(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
controller: scrollController, controller: scrollController,
reverse: true, // Show newest messages at the bottom reverse:
true, // Show newest messages at the bottom
itemCount: messageList.length, itemCount: messageList.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final message = messageList[index]; final message = messageList[index];
@ -546,7 +547,8 @@ class ChatRoomScreen extends HookConsumerWidget {
: null; : null;
final isLastInGroup = final isLastInGroup =
nextMessage == null || nextMessage == null ||
nextMessage.senderId != message.senderId || nextMessage.senderId !=
message.senderId ||
nextMessage.createdAt nextMessage.createdAt
.difference(message.createdAt) .difference(message.createdAt)
.inMinutes .inMinutes
@ -594,7 +596,8 @@ class ChatRoomScreen extends HookConsumerWidget {
} }
}, },
progress: progress:
attachmentProgress.value[message.id], attachmentProgress.value[message
.id],
showAvatar: isLastInGroup, showAvatar: isLastInGroup,
), ),
loading: loading:
@ -609,7 +612,8 @@ class ChatRoomScreen extends HookConsumerWidget {
); );
}, },
), ),
loading: () => const Center(child: CircularProgressIndicator()), loading:
() => const Center(child: CircularProgressIndicator()),
error: error:
(error, _) => ResponseErrorWidget( (error, _) => ResponseErrorWidget(
error: error, error: error,
@ -674,6 +678,14 @@ class ChatRoomScreen extends HookConsumerWidget {
), ),
], ],
), ),
Positioned(
left: 0,
right: 0,
top: 0,
child: CallOverlayBar().padding(horizontal: 8, top: 12),
),
],
),
); );
} }
} }
@ -870,6 +882,7 @@ class _ChatInput extends ConsumerWidget {
onKey: (event) => _handleKeyPress(context, ref, event), onKey: (event) => _handleKeyPress(context, ref, event),
child: TextField( child: TextField(
controller: messageController, controller: messageController,
onSubmitted: enterToSend ? (_) => onSend() : null,
inputFormatters: [ inputFormatters: [
if (enterToSend) if (enterToSend)
TextInputFormatter.withFunction((oldValue, newValue) { TextInputFormatter.withFunction((oldValue, newValue) {

View File

@ -6,7 +6,7 @@ part of 'room.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'71a9fc1c6d024f6203f06225384c19335b9b6f2c'; String _$messagesNotifierHash() => r'afc4d43f4948ec571118cef0321838a6cefc89c0';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@ -95,19 +95,12 @@ class EditPublisherScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: result, fileData: result,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: result.name, filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg', mimetype: result.mimeType ?? 'image/jpeg',

View File

@ -17,6 +17,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:styled_widget/styled_widget.dart';
part 'explore.g.dart'; part 'explore.g.dart';
@ -131,10 +132,16 @@ class _ActivityListView extends HookConsumerWidget {
switch (item.type) { switch (item.type) {
case 'posts.new': case 'posts.new':
case 'posts.new.replies':
final isReply = item.type == 'posts.new.replies';
itemWidget = PostItem( itemWidget = PostItem(
backgroundColor: backgroundColor:
isWideScreen(context) ? Colors.transparent : null, isWideScreen(context) ? Colors.transparent : null,
item: SnPost.fromJson(item.data), item: SnPost.fromJson(item.data),
padding:
isReply
? EdgeInsets.only(left: 16, right: 16, bottom: 16)
: null,
onRefresh: (_) { onRefresh: (_) {
activitiesNotifier.forceRefresh(); activitiesNotifier.forceRefresh();
}, },
@ -145,6 +152,21 @@ class _ActivityListView extends HookConsumerWidget {
); );
}, },
); );
if (isReply) {
itemWidget = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Symbols.reply),
const Gap(8),
Text('Replying your post'),
],
).padding(horizontal: 20, vertical: 8),
itemWidget,
],
);
}
break; break;
case 'accounts.check-in': case 'accounts.check-in':
itemWidget = CheckInActivityWidget(item: item); itemWidget = CheckInActivityWidget(item: item);

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
@ -7,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/user.dart'; import 'package:island/models/user.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
@ -21,8 +23,18 @@ part 'notification.g.dart';
@riverpod @riverpod
class NotificationUnreadCountNotifier class NotificationUnreadCountNotifier
extends _$NotificationUnreadCountNotifier { extends _$NotificationUnreadCountNotifier {
StreamSubscription<WebSocketPacket>? _subscription;
@override @override
Future<int> build() async { Future<int> build() async {
// Subscribe to websocket events when this provider is built
_subscribeToWebSocket();
// Dispose the subscription when this provider is disposed
ref.onDispose(() {
_subscription?.cancel();
});
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final response = await client.get('/notifications/count'); final response = await client.get('/notifications/count');
@ -32,9 +44,23 @@ class NotificationUnreadCountNotifier
} }
} }
void _subscribeToWebSocket() {
final webSocketService = ref.read(websocketProvider);
_subscription = webSocketService.dataStream.listen((packet) {
if (packet.type == 'notifications.new') {
_incrementCounter();
}
});
}
Future<void> _incrementCounter() async {
final current = await future;
state = AsyncData(current + 1);
}
Future<void> decrement(int count) async { Future<void> decrement(int count) async {
final current = await future; final current = await future;
state = AsyncData(math.min(current - count, 0)); state = AsyncData(math.max(current - count, 0));
} }
} }
@ -94,6 +120,7 @@ class NotificationScreen extends HookConsumerWidget {
notifierRefreshable: notificationListNotifierProvider.notifier, notifierRefreshable: notificationListNotifierProvider.notifier,
contentBuilder: contentBuilder:
(data, widgetCount, endItemView) => ListView.builder( (data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: widgetCount, itemCount: widgetCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == widgetCount - 1) { if (index == widgetCount - 1) {
@ -142,7 +169,7 @@ class NotificationScreen extends HookConsumerWidget {
], ],
), ),
trailing: trailing:
notification.viewedAt == null notification.viewedAt != null
? null ? null
: Container( : Container(
width: 12, width: 12,

View File

@ -7,7 +7,7 @@ part of 'notification.dart';
// ************************************************************************** // **************************************************************************
String _$notificationUnreadCountNotifierHash() => String _$notificationUnreadCountNotifierHash() =>
r'074143cf208a3afe1495be405198532a23ef77c8'; r'372a2cc259d7d838cd4f33a9129f7396ef31dbb9';
/// See also [NotificationUnreadCountNotifier]. /// See also [NotificationUnreadCountNotifier].
@ProviderFor(NotificationUnreadCountNotifier) @ProviderFor(NotificationUnreadCountNotifier)

View File

@ -1,5 +1,4 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -18,6 +17,7 @@ import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/creators/publishers.dart';
import 'package:island/screens/posts/detail.dart'; import 'package:island/screens/posts/detail.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@ -125,21 +125,14 @@ class PostComposeScreen extends HookConsumerWidget {
final attachment = attachments.value[index]; final attachment = attachments.value[index];
if (attachment is SnCloudFile) return; if (attachment is SnCloudFile) return;
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
try { try {
attachmentProgress.value = {...attachmentProgress.value, index: 0}; attachmentProgress.value = {...attachmentProgress.value, index: 0};
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: attachment.data, fileData: attachment.data,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: attachment.data.name ?? 'Post media', filename: attachment.data.name ?? 'Post media',
mimetype: mimetype:
@ -261,7 +254,43 @@ class PostComposeScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
title:
isWideScreen(context)
? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr())
: null,
actions: [ actions: [
if (isWideScreen(context))
Tooltip(
message: 'keyboard_shortcuts'.tr(),
child: IconButton(
icon: const Icon(Symbols.keyboard),
onPressed: () {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text('keyboard_shortcuts'.tr()),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'),
Text('Ctrl/Cmd + V: ${'paste'.tr()}'),
Text('Ctrl/Cmd + I: ${'add_image'.tr()}'),
Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('close'.tr()),
),
],
),
);
},
),
),
IconButton( IconButton(
onPressed: submitting.value ? null : performAction, onPressed: submitting.value ? null : performAction,
icon: icon:
@ -316,7 +345,7 @@ class PostComposeScreen extends HookConsumerWidget {
TextField( TextField(
controller: titleController, controller: titleController,
decoration: InputDecoration.collapsed( decoration: InputDecoration.collapsed(
hintText: 'Title', hintText: 'postTitle'.tr(),
), ),
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
onTapOutside: onTapOutside:
@ -326,7 +355,7 @@ class PostComposeScreen extends HookConsumerWidget {
TextField( TextField(
controller: descriptionController, controller: descriptionController,
decoration: InputDecoration.collapsed( decoration: InputDecoration.collapsed(
hintText: 'Description', hintText: 'postDescription'.tr(),
), ),
style: TextStyle(fontSize: 16), style: TextStyle(fontSize: 16),
onTapOutside: onTapOutside:
@ -345,6 +374,7 @@ class PostComposeScreen extends HookConsumerWidget {
hintText: 'postPlaceholder'.tr(), hintText: 'postPlaceholder'.tr(),
isDense: true, isDense: true,
), ),
maxLines: null,
onTapOutside: onTapOutside:
(_) => (_) =>
FocusManager.instance.primaryFocus FocusManager.instance.primaryFocus
@ -352,7 +382,48 @@ class PostComposeScreen extends HookConsumerWidget {
), ),
), ),
const Gap(8), const Gap(8),
Column( LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide
? Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (
var idx = 0;
idx < attachments.value.length;
idx++
)
SizedBox(
width: constraints.maxWidth / 2 - 4,
child: AttachmentPreview(
item: attachments.value[idx],
progress:
attachmentProgress.value[idx],
onRequestUpload:
() => uploadAttachment(idx),
onDelete: () => deleteAttachment(idx),
onMove: (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;
},
),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8, spacing: 8,
children: [ children: [
@ -364,14 +435,18 @@ class PostComposeScreen extends HookConsumerWidget {
AttachmentPreview( AttachmentPreview(
item: attachments.value[idx], item: attachments.value[idx],
progress: attachmentProgress.value[idx], progress: attachmentProgress.value[idx],
onRequestUpload: () => uploadAttachment(idx), onRequestUpload:
() => uploadAttachment(idx),
onDelete: () => deleteAttachment(idx), onDelete: () => deleteAttachment(idx),
onMove: (delta) { onMove: (delta) {
if (idx + delta < 0 || if (idx + delta < 0 ||
idx + delta >= attachments.value.length) { idx + delta >=
attachments.value.length) {
return; return;
} }
final clone = List.of(attachments.value); final clone = List.of(
attachments.value,
);
clone.insert( clone.insert(
idx + delta, idx + delta,
clone.removeAt(idx), clone.removeAt(idx),
@ -380,6 +455,8 @@ class PostComposeScreen extends HookConsumerWidget {
}, },
), ),
], ],
);
},
), ),
], ],
), ),

View File

@ -29,9 +29,9 @@ Future<SnPublisher> publisher(Ref ref, String uname) async {
@riverpod @riverpod
Future<List<SnAccountBadge>> publisherBadges(Ref ref, String pubName) async { Future<List<SnAccountBadge>> publisherBadges(Ref ref, String pubName) async {
final pub = await ref.watch(publisherProvider(pubName).future); final pub = await ref.watch(publisherProvider(pubName).future);
if (pub.type != 0) return []; if (pub.type != 0 || pub.account == null) return [];
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get("/accounts/${pub.name}/badges"); final resp = await apiClient.get("/accounts/${pub.account!.name}/badges");
return List<SnAccountBadge>.from( return List<SnAccountBadge>.from(
resp.data.map((x) => SnAccountBadge.fromJson(x)), resp.data.map((x) => SnAccountBadge.fromJson(x)),
); );
@ -177,9 +177,9 @@ class PublisherProfileScreen extends HookConsumerWidget {
).fontSize(14).opacity(0.85), ).fontSize(14).opacity(0.85),
], ],
), ),
if (data.type == 0) if (data.type == 0 && data.account != null)
AccountStatusWidget( AccountStatusWidget(
uname: name, uname: data.account!.name,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
], ],

View File

@ -145,7 +145,7 @@ class _PublisherProviderElement
String get uname => (origin as PublisherProvider).uname; String get uname => (origin as PublisherProvider).uname;
} }
String _$publisherBadgesHash() => r'b26d8804ddc9734c453bdf76af0a9336f166542c'; String _$publisherBadgesHash() => r'a5781deded7e682a781ccd7854418f050438e3f4';
/// See also [publisherBadges]. /// See also [publisherBadges].
@ProviderFor(publisherBadges) @ProviderFor(publisherBadges)

View File

@ -211,19 +211,12 @@ class EditRealmScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw ArgumentError('Access token is null');
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw ArgumentError('Access token is null');
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: result, fileData: result,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: result.name, filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg', mimetype: result.mimeType ?? 'image/jpeg',

View File

@ -6,10 +6,12 @@ import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@ -27,6 +29,9 @@ class SettingsScreen extends HookConsumerWidget {
final prefs = ref.watch(sharedPreferencesProvider); final prefs = ref.watch(sharedPreferencesProvider);
final controller = TextEditingController(text: serverUrl); final controller = TextEditingController(text: serverUrl);
final settings = ref.watch(appSettingsProvider); final settings = ref.watch(appSettingsProvider);
final isDesktop =
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
final isWide = isWideScreen(context);
final docBasepath = useState<String?>(null); final docBasepath = useState<String?>(null);
@ -37,13 +42,9 @@ class SettingsScreen extends HookConsumerWidget {
return null; return null;
}, []); }, []);
return AppScaffold( // Group settings into categories for better organization
noBackground: false, final appearanceSettings = [
appBar: AppBar(title: const Text('Settings')), // Language settings
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsDisplayLanguage').tr(), title: Text('settingsDisplayLanguage').tr(),
@ -53,9 +54,10 @@ class SettingsScreen extends HookConsumerWidget {
child: DropdownButton2<Locale?>( child: DropdownButton2<Locale?>(
isExpanded: true, isExpanded: true,
items: [ items: [
...EasyLocalization.of( ...EasyLocalization.of(context)!.supportedLocales.mapIndexed((
context, idx,
)!.supportedLocales.mapIndexed((idx, ele) { ele,
) {
return DropdownMenuItem<Locale?>( return DropdownMenuItem<Locale?>(
value: ele, value: ele,
child: Text( child: Text(
@ -85,6 +87,75 @@ class SettingsScreen extends HookConsumerWidget {
), ),
), ),
), ),
// Background image settings (only for non-web platforms)
if (!kIsWeb && docBasepath.value != null)
ListTile(
minLeadingWidth: 48,
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),
],
),
onTap: () async {
final imagePicker = ref.read(imagePickerProvider);
final image = await imagePicker.pickImage(
source: ImageSource.gallery,
);
if (image == null) return;
await File(
image.path,
).copy('${docBasepath.value}/$kAppBackgroundImagePath');
prefs.setBool(kAppBackgroundStoreKey, true);
ref.invalidate(backgroundImageFileProvider);
if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr());
}
},
),
// Clear background image option
if (!kIsWeb && docBasepath.value != null)
FutureBuilder<bool>(
future: File('${docBasepath.value}/app_background_image').exists(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink();
}
return ListTile(
title: Text('settingsBackgroundImageClear').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.texture),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
File(
'${docBasepath.value}/$kAppBackgroundImagePath',
).deleteSync();
prefs.remove(kAppBackgroundStoreKey);
ref.invalidate(backgroundImageFileProvider);
if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr());
}
},
);
},
),
];
final serverSettings = [
// Server URL settings
ListTile( ListTile(
isThreeLine: true, isThreeLine: true,
minLeadingWidth: 48, minLeadingWidth: 48,
@ -124,58 +195,10 @@ class SettingsScreen extends HookConsumerWidget {
), ),
), ),
), ),
if (!kIsWeb && docBasepath.value != null) ];
ListTile(
minLeadingWidth: 48,
title: Text('settingsBackgroundImage').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.image),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final imagePicker = ref.read(imagePickerProvider);
final image = await imagePicker.pickImage(
source: ImageSource.gallery,
);
if (image == null) return;
await File( final behaviorSettings = [
image.path, // Auto translate settings
).copy('${docBasepath.value}/$kAppBackgroundImagePath');
prefs.setBool(kAppBackgroundStoreKey, true);
ref.invalidate(backgroundImageFileProvider);
if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr());
}
},
),
if (!kIsWeb && docBasepath.value != null)
FutureBuilder<bool>(
future:
File('${docBasepath.value}/app_background_image').exists(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink();
}
return ListTile(
title: Text('settingsBackgroundImageClear').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.texture),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
File(
'${docBasepath.value}/$kAppBackgroundImagePath',
).deleteSync();
prefs.remove(kAppBackgroundStoreKey);
ref.invalidate(backgroundImageFileProvider);
if (context.mounted) {
showSnackBar(context, 'settingsApplied'.tr());
}
},
);
},
),
const Divider(),
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsAutoTranslate').tr(), title: Text('settingsAutoTranslate').tr(),
@ -184,12 +207,12 @@ class SettingsScreen extends HookConsumerWidget {
trailing: Switch( trailing: Switch(
value: settings.autoTranslate, value: settings.autoTranslate,
onChanged: (value) { onChanged: (value) {
ref ref.read(appSettingsProvider.notifier).setAutoTranslate(value);
.read(appSettingsProvider.notifier)
.setAutoTranslate(value);
}, },
), ),
), ),
// Sound effects settings
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsSoundEffects').tr(), title: Text('settingsSoundEffects').tr(),
@ -202,6 +225,8 @@ class SettingsScreen extends HookConsumerWidget {
}, },
), ),
), ),
// April Fool features settings
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsAprilFoolFeatures').tr(), title: Text('settingsAprilFoolFeatures').tr(),
@ -210,15 +235,19 @@ class SettingsScreen extends HookConsumerWidget {
trailing: Switch( trailing: Switch(
value: settings.aprilFoolFeatures, value: settings.aprilFoolFeatures,
onChanged: (value) { onChanged: (value) {
ref ref.read(appSettingsProvider.notifier).setAprilFoolFeatures(value);
.read(appSettingsProvider.notifier)
.setAprilFoolFeatures(value);
}, },
), ),
), ),
// Enter to send settings
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsEnterToSend').tr(), title: Text('settingsEnterToSend').tr(),
subtitle:
isDesktop
? Text('settingsEnterToSendDesktopHint').tr().fontSize(12)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 17), contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.send), leading: const Icon(Symbols.send),
trailing: Switch( trailing: Switch(
@ -228,9 +257,222 @@ class SettingsScreen extends HookConsumerWidget {
}, },
), ),
), ),
];
// Desktop-specific settings
final desktopSettings =
!isDesktop
? <Widget>[]
: <Widget>[
ListTile(
minLeadingWidth: 48,
title: Text('settingsKeyboardShortcuts').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.keyboard),
onTap: () {
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text('settingsKeyboardShortcuts').tr(),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShortcutRow(
shortcut: 'Ctrl+F',
description: 'Search',
),
_ShortcutRow(
shortcut: 'Ctrl+,',
description: 'Settings',
),
_ShortcutRow(
shortcut: 'Ctrl+N',
description: 'New Message',
),
_ShortcutRow(
shortcut: 'Esc',
description: 'Close Dialog',
),
// Add more shortcuts as needed
], ],
), ),
), ),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
),
],
),
);
},
trailing: const Icon(Symbols.chevron_right),
),
];
// Create a responsive layout based on screen width
Widget buildSettingsList() {
if (isWide) {
// Two-column layout for wide screens
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'Appearance',
children: appearanceSettings,
),
_SettingsSection(title: 'Server', children: serverSettings),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'Behavior',
children: behaviorSettings,
),
if (desktopSettings.isNotEmpty)
_SettingsSection(
title: 'Desktop',
children: desktopSettings,
),
],
),
),
],
).padding(horizontal: 16);
} else {
// Single column layout for narrow screens
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(title: 'Appearance', children: appearanceSettings),
_SettingsSection(title: 'Server', children: serverSettings),
_SettingsSection(title: 'Behavior', children: behaviorSettings),
if (desktopSettings.isNotEmpty)
_SettingsSection(title: 'Desktop', children: desktopSettings),
],
);
}
}
return AppScaffold(
noBackground: false,
appBar: AppBar(
title: Text('Settings').tr(),
actions:
isDesktop
? [
IconButton(
icon: const Icon(Symbols.help_outline),
onPressed: () {
// Show help dialog
showDialog(
context: context,
builder:
(context) => AlertDialog(
title: Text('settingsHelp').tr(),
content: Text('settingsHelpContent').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
),
],
),
);
},
),
]
: null,
),
body: Focus(
autofocus: true,
onKeyEvent: (node, event) {
// Add keyboard shortcuts for desktop
if (isDesktop &&
event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
context.router.pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(vertical: 16),
child: buildSettingsList(),
),
),
);
}
}
// Helper widget for displaying settings sections with titles
class _SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _SettingsSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
const SizedBox(height: 16),
],
);
}
}
// Helper widget for displaying keyboard shortcuts
class _ShortcutRow extends StatelessWidget {
final String shortcut;
final String description;
const _ShortcutRow({required this.shortcut, required this.description});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
),
),
child: Text(shortcut, style: TextStyle(fontFamily: 'monospace')),
),
SizedBox(width: 16),
Text(description),
],
),
); );
} }
} }

View File

@ -13,8 +13,6 @@ import 'package:island/services/responsive.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/pods/call.dart';
class WindowScaffold extends HookConsumerWidget { class WindowScaffold extends HookConsumerWidget {
final Widget child; final Widget child;
@ -152,22 +150,8 @@ class AppScaffold extends StatelessWidget {
noBackground noBackground
? Colors.transparent ? Colors.transparent
: Theme.of(context).scaffoldBackgroundColor, : Theme.of(context).scaffoldBackgroundColor,
body: Stack( body:
children: [ noBackground ? content : AppBackground(isRoot: true, child: content),
SizedBox.expand(
child:
noBackground
? content
: AppBackground(isRoot: true, child: content),
),
Positioned(
left: 16,
right: 16,
bottom: 8,
child: const _GlobalCallOverlay(),
),
],
),
appBar: appBar, appBar: appBar,
bottomNavigationBar: bottomNavigationBar, bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
@ -206,23 +190,6 @@ class PageBackButton extends StatelessWidget {
const kAppBackgroundImagePath = 'island_app_background'; const kAppBackgroundImagePath = 'island_app_background';
/// Global call overlay bar (appears when in a call but not on the call screen)
class _GlobalCallOverlay extends HookConsumerWidget {
const _GlobalCallOverlay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
// Find current route name
final modalRoute = ModalRoute.of(context);
final isOnCallScreen = modalRoute?.settings.name?.contains('call') ?? false;
// You may want to store roomId in callState for more robust navigation
if (callState.isConnected && !isOnCallScreen) {
return CallOverlayBar();
}
return const SizedBox.shrink();
}
}
final backgroundImageFileProvider = FutureProvider<File?>((ref) async { final backgroundImageFileProvider = FutureProvider<File?>((ref) async {
if (kIsWeb) return null; if (kIsWeb) return null;
final dir = await getApplicationSupportDirectory(); final dir = await getApplicationSupportDirectory();

View File

@ -1,10 +1,94 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart'; import 'package:island/route.gr.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:styled_widget/styled_widget.dart';
class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier);
final userInfo = ref.watch(userInfoProvider);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
return Card(
margin: const EdgeInsets.only(left: 12, right: 12, top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking:
callNotifier.localParticipant!.isSpeaking,
audioLevel:
callNotifier.localParticipant!.audioLevel,
pictureId: userInfo.value?.profile.pictureId,
size: 36,
).center(),
);
},
),
],
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
),
],
).padding(all: 16),
);
}
}
/// A floating bar that appears when user is in a call but not on the call screen.
class CallOverlayBar extends HookConsumerWidget { class CallOverlayBar extends HookConsumerWidget {
const CallOverlayBar({super.key}); const CallOverlayBar({super.key});
@ -15,48 +99,123 @@ class CallOverlayBar extends HookConsumerWidget {
// Only show if connected and not on the call screen // Only show if connected and not on the call screen
if (!callState.isConnected) return const SizedBox.shrink(); if (!callState.isConnected) return const SizedBox.shrink();
return Positioned( final lastSpeaker =
left: 16, callNotifier.participants
right: 16, .where(
bottom: 32, (element) => element.remoteParticipant.lastSpokeAt != null,
child: GestureDetector( )
.isEmpty
? callNotifier.participants.first
: callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.fold(
callNotifier.participants.first,
(value, element) =>
element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt!
.compareTo(
value
.remoteParticipant
.lastSpokeAt!,
) >
0)
? element
: value,
);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
return GestureDetector(
child: Card(
margin: EdgeInsets.zero,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking: lastSpeaker.isSpeaking,
audioLevel:
lastSpeaker.remoteParticipant.audioLevel,
pictureId:
lastSpeaker
.participant
.profile
?.account
.profile
.pictureId,
size: 36,
).center(),
);
},
),
const Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
lastSpeaker.participant.profile?.account.nick ??
'unknown'.tr(),
).bold(),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
],
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
),
],
).padding(all: 16),
),
onTap: () { onTap: () {
if (callNotifier.roomId == null) return;
context.router.push(CallRoute(roomId: callNotifier.roomId!)); context.router.push(CallRoute(roomId: callNotifier.roomId!));
}, },
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(24),
color: Theme.of(context).colorScheme.primary,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.call, color: Colors.white),
const SizedBox(width: 12),
const Text(
'In call',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 18,
),
],
),
),
),
),
); );
} }
} }

View File

@ -4,6 +4,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart'; import 'package:island/database/message.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/call.dart';
import 'package:island/screens/chat/room.dart'; import 'package:island/screens/chat/room.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_collection.dart'; import 'package:island/widgets/content/cloud_file_collection.dart';
@ -429,14 +430,6 @@ class _MessageContentCall extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${hours == 0 ? '' : '$hours hours '}'
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

View File

@ -43,15 +43,8 @@ class CloudFilePicker extends HookConsumerWidget {
if (files.value.isEmpty) return; if (files.value.isEmpty) return;
final baseUrl = ref.read(serverUrlProvider); final baseUrl = ref.read(serverUrlProvider);
final atk = await getFreshAtk( final token = await getToken(ref.watch(tokenProvider));
ref.watch(tokenPairProvider), if (token == null) throw Exception("Unauthorized");
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw Exception("Unauthorized");
List<SnCloudFile> result = List.empty(growable: true); List<SnCloudFile> result = List.empty(growable: true);
@ -64,7 +57,7 @@ class CloudFilePicker extends HookConsumerWidget {
final cloudFile = final cloudFile =
await putMediaToCloud( await putMediaToCloud(
fileData: file.data, fileData: file.data,
atk: atk, atk: token,
baseUrl: baseUrl, baseUrl: baseUrl,
filename: file.data.name ?? 'Post media', filename: file.data.name ?? 'Post media',
mimetype: mimetype:

View File

@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
@ -38,18 +37,10 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) { if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url'); log('[MediaPlayer] Miss cache: $url');
final baseUrl = ref.watch(serverUrlProvider); final token = await getToken(ref.watch(tokenProvider));
final atk = await getFreshAtk(
ref.watch(tokenPairProvider),
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
final fileStream = DefaultCacheManager().getFileStream( final fileStream = DefaultCacheManager().getFileStream(
url, url,
headers: {'Authorization': 'Bearer $atk'}, headers: {'Authorization': 'Bearer $token'},
withProgress: true, withProgress: true,
); );
await for (var fileInfo in fileStream) { await for (var fileInfo in fileStream) {

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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 3.0.0+94 version: 3.0.0+96
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2

View File

@ -1,4 +1,6 @@
<!DOCTYPE html><html><head> <!doctype html>
<html>
<head>
<!-- <!--
If you are serving your web app in a path other than the root, change the If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from. href value below to reflect the base path you are serving from.
@ -12,23 +14,26 @@
This is a placeholder for base href that will be replaced by the value of This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`. the `--base-href` argument provided to `flutter build`.
--> -->
<base href="$FLUTTER_BASE_HREF"> <base href="$FLUTTER_BASE_HREF" />
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="The Solar Network, an open-source social network."> <meta
name="description"
content="The Solar Network, an open-source social network."
/>
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="Solar Network"> <meta name="apple-mobile-web-app-title" content="Solar Network" />
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png" />
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"> <link rel="icon" type="image/png" href="favicon.png" />
<title>Solar Network</title> <title>Solar Network</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json" />
<style> <style>
@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap"); @import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap");
@ -77,7 +82,7 @@
} }
.swal-button--confirm { .swal-button--confirm {
background-color: #6750A4; background-color: #6750a4;
color: #ffffff; color: #ffffff;
} }
@ -87,7 +92,7 @@
.swal-button--cancel { .swal-button--cancel {
background-color: transparent; background-color: transparent;
color: #6750A4; color: #6750a4;
} }
.swal-button--cancel:hover { .swal-button--cancel:hover {
@ -95,33 +100,33 @@
} }
.swal-icon { .swal-icon {
border-color: #6750A4; border-color: #6750a4;
margin: 12px auto; margin: 12px auto;
} }
.swal-icon--success__line { .swal-icon--success__line {
background-color: #6750A4; background-color: #6750a4;
} }
.swal-icon--success__ring { .swal-icon--success__ring {
border-color: #6750A4; border-color: #6750a4;
} }
.swal-icon--warning { .swal-icon--warning {
border-color: #F2B824; border-color: #f2b824;
} }
.swal-icon--warning__body, .swal-icon--warning__body,
.swal-icon--warning__dot { .swal-icon--warning__dot {
background-color: #F2B824; background-color: #f2b824;
} }
.swal-icon--error { .swal-icon--error {
border-color: #DC362E; border-color: #dc362e;
} }
.swal-icon--error__line { .swal-icon--error__line {
background-color: #DC362E; background-color: #dc362e;
} }
.swal-footer { .swal-footer {
@ -130,11 +135,10 @@
border: none; border: none;
text-align: right; text-align: right;
} }
</style> </style>
<style id="splash-screen-style"> <style id="splash-screen-style">
html { html {
height: 100% height: 100%;
} }
body { body {
@ -154,19 +158,22 @@
} }
.contain { .contain {
display:block; display: block;
width:100%; height:100%; width: 100%;
height: 100%;
object-fit: contain; object-fit: contain;
} }
.stretch { .stretch {
display:block; display: block;
width:100%; height:100%; width: 100%;
height: 100%;
} }
.cover { .cover {
display:block; display: block;
width:100%; height:100%; width: 100%;
height: 100%;
object-fit: cover; object-fit: cover;
} }
@ -203,19 +210,69 @@
document.body.style.background = "transparent"; document.body.style.background = "transparent";
} }
</script> </script>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"> <meta
</head> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
name="viewport"
/>
</head>
<body> <body>
<picture id="splash"> <picture id="splash">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)"> <source
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)"> srcset="
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> splash/img/light-1x.png 1x,
splash/img/light-2x.png 2x,
splash/img/light-3x.png 3x,
splash/img/light-4x.png 4x
"
media="(prefers-color-scheme: light)"
/>
<source
srcset="
splash/img/dark-1x.png 1x,
splash/img/dark-2x.png 2x,
splash/img/dark-3x.png 3x,
splash/img/dark-4x.png 4x
"
media="(prefers-color-scheme: dark)"
/>
<img
class="center"
aria-hidden="true"
src="splash/img/light-1x.png"
alt=""
/>
</picture> </picture>
<script src="https://unpkg.com/sweetalert@2.1.2/dist/sweetalert.min.js" async=""></script> <script
<script src="flutter_bootstrap.js" async=""></script> src="https://unpkg.com/sweetalert@2.1.2/dist/sweetalert.min.js"
async=""
></script>
<script> <script>
document.oncontextmenu = (evt) => evt.preventDefault(); document.oncontextmenu = (evt) => evt.preventDefault();
</script> </script>
<script>
{{flutter_js}}
{{flutter_build_config}}
const searchParams = new URLSearchParams(window.location.search);
const renderer = searchParams.get("renderer");
let cdn = searchParams.get("cdn");
if (cdn) {
localStorage.setItem("sn-web-canvaskit-cdn", cdn);
} else {
const storagedCdn = localStorage.getItem("sn-web-canvaskit-cdn");
cdn = storagedCdn ?? "com";
}
_flutter.loader.load({
config: {
renderer: renderer ?? "canvaskit",
canvasKitVariant: "full",
canvasKitBaseUrl: `https://www.gstatic.${cdn}/flutter-canvaskit/f73bfc4522dd0bc87bbcdb4bb3088082755c5e87`,
},
});
</script>
</body>
</html>
</body></html>