Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
360572d7d1 | |||
9c1a983466 | |||
bfa97dcd11 | |||
aaa505e83e | |||
552bdfa58f | |||
ebde4eeed5 | |||
688f035f85 |
@ -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."
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(),
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -51,6 +51,7 @@ 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 atk = await getFreshAtk(
|
||||||
@ -140,23 +141,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');
|
||||||
|
@ -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(
|
||||||
@ -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),
|
||||||
|
@ -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),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
@ -354,15 +356,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 {
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -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,76 +337,93 @@ class ChatListScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
child: const Icon(Symbols.add),
|
child: const Icon(Symbols.add),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Consumer(
|
Column(
|
||||||
builder: (context, ref, _) {
|
children: [
|
||||||
final summaryState = ref.watch(chatSummaryProvider);
|
Consumer(
|
||||||
return summaryState.maybeWhen(
|
builder: (context, ref, _) {
|
||||||
loading: () => const LinearProgressIndicator(),
|
final summaryState = ref.watch(chatSummaryProvider);
|
||||||
orElse: () => const SizedBox.shrink(),
|
return summaryState.maybeWhen(
|
||||||
);
|
loading: () => const LinearProgressIndicator(),
|
||||||
},
|
orElse: () => const SizedBox.shrink(),
|
||||||
),
|
);
|
||||||
Expanded(
|
},
|
||||||
child: chats.when(
|
),
|
||||||
data:
|
Expanded(
|
||||||
(items) => RefreshIndicator(
|
child: chats.when(
|
||||||
onRefresh:
|
data:
|
||||||
() => Future.sync(() {
|
(items) => RefreshIndicator(
|
||||||
ref.invalidate(chatroomsJoinedProvider);
|
onRefresh:
|
||||||
}),
|
() => Future.sync(() {
|
||||||
child: ListView.builder(
|
ref.invalidate(chatroomsJoinedProvider);
|
||||||
padding: EdgeInsets.zero,
|
}),
|
||||||
itemCount:
|
child: ListView.builder(
|
||||||
items
|
padding:
|
||||||
.where(
|
callState.isConnected
|
||||||
(item) =>
|
? EdgeInsets.only(bottom: 96)
|
||||||
selectedTab.value == 0 ||
|
: EdgeInsets.zero,
|
||||||
(selectedTab.value == 1 &&
|
itemCount:
|
||||||
item.type == 1) ||
|
items
|
||||||
(selectedTab.value == 2 && item.type != 1),
|
.where(
|
||||||
)
|
(item) =>
|
||||||
.length,
|
selectedTab.value == 0 ||
|
||||||
itemBuilder: (context, index) {
|
(selectedTab.value == 1 &&
|
||||||
final filteredItems =
|
item.type == 1) ||
|
||||||
items
|
(selectedTab.value == 2 &&
|
||||||
.where(
|
item.type != 1),
|
||||||
(item) =>
|
)
|
||||||
selectedTab.value == 0 ||
|
.length,
|
||||||
(selectedTab.value == 1 &&
|
itemBuilder: (context, index) {
|
||||||
item.type == 1) ||
|
final filteredItems =
|
||||||
(selectedTab.value == 2 &&
|
items
|
||||||
item.type != 1),
|
.where(
|
||||||
)
|
(item) =>
|
||||||
.toList();
|
selectedTab.value == 0 ||
|
||||||
final item = filteredItems[index];
|
(selectedTab.value == 1 &&
|
||||||
return ChatRoomListTile(
|
item.type == 1) ||
|
||||||
room: item,
|
(selectedTab.value == 2 &&
|
||||||
isDirect: item.type == 1,
|
item.type != 1),
|
||||||
onTap: () {
|
)
|
||||||
if (context.router.topRoute.name ==
|
.toList();
|
||||||
ChatRoomRoute.name) {
|
final item = filteredItems[index];
|
||||||
context.router.replace(
|
return ChatRoomListTile(
|
||||||
ChatRoomRoute(id: item.id),
|
room: item,
|
||||||
);
|
isDirect: item.type == 1,
|
||||||
} else {
|
onTap: () {
|
||||||
context.router.push(ChatRoomRoute(id: item.id));
|
if (context.router.topRoute.name ==
|
||||||
}
|
ChatRoomRoute.name) {
|
||||||
|
context.router.replace(
|
||||||
|
ChatRoomRoute(id: item.id),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
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,
|
onRetry: () {
|
||||||
onRetry: () {
|
ref.invalidate(chatroomsJoinedProvider);
|
||||||
ref.invalidate(chatroomsJoinedProvider);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: const CallOverlayBar().padding(horizontal: 16, vertical: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -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';
|
||||||
@ -352,6 +353,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,152 +530,166 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Column(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Column(
|
||||||
child: messages.when(
|
children: [
|
||||||
data:
|
Expanded(
|
||||||
(messageList) =>
|
child: messages.when(
|
||||||
messageList.isEmpty
|
data:
|
||||||
? Center(child: Text('No messages yet'.tr()))
|
(messageList) =>
|
||||||
: SuperListView.builder(
|
messageList.isEmpty
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
? Center(child: Text('No messages yet'.tr()))
|
||||||
controller: scrollController,
|
: SuperListView.builder(
|
||||||
reverse: true, // Show newest messages at the bottom
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
itemCount: messageList.length,
|
controller: scrollController,
|
||||||
itemBuilder: (context, index) {
|
reverse:
|
||||||
final message = messageList[index];
|
true, // Show newest messages at the bottom
|
||||||
final nextMessage =
|
itemCount: messageList.length,
|
||||||
index < messageList.length - 1
|
itemBuilder: (context, index) {
|
||||||
? messageList[index + 1]
|
final message = messageList[index];
|
||||||
: null;
|
final nextMessage =
|
||||||
final isLastInGroup =
|
index < messageList.length - 1
|
||||||
nextMessage == null ||
|
? messageList[index + 1]
|
||||||
nextMessage.senderId != message.senderId ||
|
: null;
|
||||||
nextMessage.createdAt
|
final isLastInGroup =
|
||||||
.difference(message.createdAt)
|
nextMessage == null ||
|
||||||
.inMinutes
|
nextMessage.senderId !=
|
||||||
.abs() >
|
message.senderId ||
|
||||||
3;
|
nextMessage.createdAt
|
||||||
|
.difference(message.createdAt)
|
||||||
|
.inMinutes
|
||||||
|
.abs() >
|
||||||
|
3;
|
||||||
|
|
||||||
return chatIdentity.when(
|
return chatIdentity.when(
|
||||||
skipError: true,
|
skipError: true,
|
||||||
data:
|
data:
|
||||||
(identity) => MessageItem(
|
(identity) => MessageItem(
|
||||||
message: message,
|
message: message,
|
||||||
isCurrentUser:
|
isCurrentUser:
|
||||||
identity?.id == message.senderId,
|
identity?.id == message.senderId,
|
||||||
onAction: (action) {
|
onAction: (action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case MessageItemAction.delete:
|
case MessageItemAction.delete:
|
||||||
messagesNotifier.deleteMessage(
|
messagesNotifier.deleteMessage(
|
||||||
message.id,
|
message.id,
|
||||||
);
|
);
|
||||||
case MessageItemAction.edit:
|
case MessageItemAction.edit:
|
||||||
messageEditingTo.value =
|
messageEditingTo.value =
|
||||||
message.toRemoteMessage();
|
message.toRemoteMessage();
|
||||||
messageController.text =
|
messageController.text =
|
||||||
messageEditingTo
|
messageEditingTo
|
||||||
.value
|
.value
|
||||||
?.content ??
|
?.content ??
|
||||||
'';
|
'';
|
||||||
attachments.value =
|
attachments.value =
|
||||||
messageEditingTo
|
messageEditingTo
|
||||||
.value!
|
.value!
|
||||||
.attachments
|
.attachments
|
||||||
.map(
|
.map(
|
||||||
(e) =>
|
(e) =>
|
||||||
UniversalFile.fromAttachment(
|
UniversalFile.fromAttachment(
|
||||||
e,
|
e,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
case MessageItemAction.forward:
|
case MessageItemAction.forward:
|
||||||
messageForwardingTo.value =
|
messageForwardingTo.value =
|
||||||
message.toRemoteMessage();
|
message.toRemoteMessage();
|
||||||
case MessageItemAction.reply:
|
case MessageItemAction.reply:
|
||||||
messageReplyingTo.value =
|
messageReplyingTo.value =
|
||||||
message.toRemoteMessage();
|
message.toRemoteMessage();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
progress:
|
progress:
|
||||||
attachmentProgress.value[message.id],
|
attachmentProgress.value[message
|
||||||
showAvatar: isLastInGroup,
|
.id],
|
||||||
),
|
showAvatar: isLastInGroup,
|
||||||
loading:
|
),
|
||||||
() => MessageItem(
|
loading:
|
||||||
message: message,
|
() => MessageItem(
|
||||||
isCurrentUser: false,
|
message: message,
|
||||||
onAction: null,
|
isCurrentUser: false,
|
||||||
progress: null,
|
onAction: null,
|
||||||
showAvatar: false,
|
progress: null,
|
||||||
),
|
showAvatar: false,
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
),
|
||||||
);
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
),
|
||||||
error:
|
loading:
|
||||||
(error, _) => ResponseErrorWidget(
|
() => const Center(child: CircularProgressIndicator()),
|
||||||
error: error,
|
error:
|
||||||
onRetry: () => messagesNotifier.loadInitial(),
|
(error, _) => ResponseErrorWidget(
|
||||||
),
|
error: error,
|
||||||
),
|
onRetry: () => messagesNotifier.loadInitial(),
|
||||||
),
|
),
|
||||||
chatRoom.when(
|
|
||||||
data:
|
|
||||||
(room) => _ChatInput(
|
|
||||||
messageController: messageController,
|
|
||||||
chatRoom: room!,
|
|
||||||
onSend: sendMessage,
|
|
||||||
onClear: () {
|
|
||||||
if (messageEditingTo.value != null) {
|
|
||||||
attachments.value.clear();
|
|
||||||
messageController.clear();
|
|
||||||
}
|
|
||||||
messageEditingTo.value = null;
|
|
||||||
messageReplyingTo.value = null;
|
|
||||||
messageForwardingTo.value = null;
|
|
||||||
},
|
|
||||||
messageEditingTo: messageEditingTo.value,
|
|
||||||
messageReplyingTo: messageReplyingTo.value,
|
|
||||||
messageForwardingTo: messageForwardingTo.value,
|
|
||||||
onPickFile: (bool isPhoto) {
|
|
||||||
if (isPhoto) {
|
|
||||||
pickPhotoMedia();
|
|
||||||
} else {
|
|
||||||
pickVideoMedia();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
attachments: attachments.value,
|
|
||||||
onUploadAttachment: (_) {
|
|
||||||
// not going to do anything, only upload when send the message
|
|
||||||
},
|
|
||||||
onDeleteAttachment: (index) async {
|
|
||||||
final attachment = attachments.value[index];
|
|
||||||
if (attachment.isOnCloud) {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.delete('/files/${attachment.data.id}');
|
|
||||||
}
|
|
||||||
final clone = List.of(attachments.value);
|
|
||||||
clone.removeAt(index);
|
|
||||||
attachments.value = clone;
|
|
||||||
},
|
|
||||||
onMoveAttachment: (idx, delta) {
|
|
||||||
if (idx + delta < 0 ||
|
|
||||||
idx + delta >= attachments.value.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final clone = List.of(attachments.value);
|
|
||||||
clone.insert(idx + delta, clone.removeAt(idx));
|
|
||||||
attachments.value = clone;
|
|
||||||
},
|
|
||||||
onAttachmentsChanged: (newAttachments) {
|
|
||||||
attachments.value = newAttachments;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
error: (_, __) => const SizedBox.shrink(),
|
),
|
||||||
loading: () => const SizedBox.shrink(),
|
chatRoom.when(
|
||||||
|
data:
|
||||||
|
(room) => _ChatInput(
|
||||||
|
messageController: messageController,
|
||||||
|
chatRoom: room!,
|
||||||
|
onSend: sendMessage,
|
||||||
|
onClear: () {
|
||||||
|
if (messageEditingTo.value != null) {
|
||||||
|
attachments.value.clear();
|
||||||
|
messageController.clear();
|
||||||
|
}
|
||||||
|
messageEditingTo.value = null;
|
||||||
|
messageReplyingTo.value = null;
|
||||||
|
messageForwardingTo.value = null;
|
||||||
|
},
|
||||||
|
messageEditingTo: messageEditingTo.value,
|
||||||
|
messageReplyingTo: messageReplyingTo.value,
|
||||||
|
messageForwardingTo: messageForwardingTo.value,
|
||||||
|
onPickFile: (bool isPhoto) {
|
||||||
|
if (isPhoto) {
|
||||||
|
pickPhotoMedia();
|
||||||
|
} else {
|
||||||
|
pickVideoMedia();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachments: attachments.value,
|
||||||
|
onUploadAttachment: (_) {
|
||||||
|
// not going to do anything, only upload when send the message
|
||||||
|
},
|
||||||
|
onDeleteAttachment: (index) async {
|
||||||
|
final attachment = attachments.value[index];
|
||||||
|
if (attachment.isOnCloud) {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
await client.delete('/files/${attachment.data.id}');
|
||||||
|
}
|
||||||
|
final clone = List.of(attachments.value);
|
||||||
|
clone.removeAt(index);
|
||||||
|
attachments.value = clone;
|
||||||
|
},
|
||||||
|
onMoveAttachment: (idx, delta) {
|
||||||
|
if (idx + delta < 0 ||
|
||||||
|
idx + delta >= attachments.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final clone = List.of(attachments.value);
|
||||||
|
clone.insert(idx + delta, clone.removeAt(idx));
|
||||||
|
attachments.value = clone;
|
||||||
|
},
|
||||||
|
onAttachmentsChanged: (newAttachments) {
|
||||||
|
attachments.value = newAttachments;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
error: (_, __) => const SizedBox.shrink(),
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: CallOverlayBar().padding(horizontal: 8, top: 12),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -870,6 +889,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) {
|
||||||
|
@ -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,
|
||||||
|
@ -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';
|
||||||
@ -261,7 +261,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 +352,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 +362,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 +381,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,34 +389,64 @@ class PostComposeScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Column(
|
LayoutBuilder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
builder: (context, constraints) {
|
||||||
spacing: 8,
|
final isWide = isWideScreen(context);
|
||||||
children: [
|
return isWide
|
||||||
for (
|
? Wrap(
|
||||||
var idx = 0;
|
spacing: 8,
|
||||||
idx < attachments.value.length;
|
runSpacing: 8,
|
||||||
idx++
|
children: [
|
||||||
)
|
for (var idx = 0; idx < attachments.value.length; idx++)
|
||||||
AttachmentPreview(
|
SizedBox(
|
||||||
item: attachments.value[idx],
|
width: constraints.maxWidth / 2 - 4,
|
||||||
progress: attachmentProgress.value[idx],
|
child: AttachmentPreview(
|
||||||
onRequestUpload: () => uploadAttachment(idx),
|
item: attachments.value[idx],
|
||||||
onDelete: () => deleteAttachment(idx),
|
progress: attachmentProgress.value[idx],
|
||||||
onMove: (delta) {
|
onRequestUpload: () => uploadAttachment(idx),
|
||||||
if (idx + delta < 0 ||
|
onDelete: () => deleteAttachment(idx),
|
||||||
idx + delta >= attachments.value.length) {
|
onMove: (delta) {
|
||||||
return;
|
if (idx + delta < 0 ||
|
||||||
}
|
idx + delta >= attachments.value.length) {
|
||||||
final clone = List.of(attachments.value);
|
return;
|
||||||
clone.insert(
|
}
|
||||||
idx + delta,
|
final clone = List.of(attachments.value);
|
||||||
clone.removeAt(idx),
|
clone.insert(
|
||||||
|
idx + delta,
|
||||||
|
clone.removeAt(idx),
|
||||||
|
);
|
||||||
|
attachments.value = clone;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
for (var idx = 0; idx < attachments.value.length; idx++)
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
attachments.value = clone;
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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,200 +42,437 @@ 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(
|
ListTile(
|
||||||
child: Column(
|
minLeadingWidth: 48,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
title: Text('settingsDisplayLanguage').tr(),
|
||||||
children: [
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
ListTile(
|
leading: const Icon(Symbols.translate),
|
||||||
minLeadingWidth: 48,
|
trailing: DropdownButtonHideUnderline(
|
||||||
title: Text('settingsDisplayLanguage').tr(),
|
child: DropdownButton2<Locale?>(
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
isExpanded: true,
|
||||||
leading: const Icon(Symbols.translate),
|
items: [
|
||||||
trailing: DropdownButtonHideUnderline(
|
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((
|
||||||
child: DropdownButton2<Locale?>(
|
idx,
|
||||||
isExpanded: true,
|
ele,
|
||||||
items: [
|
) {
|
||||||
...EasyLocalization.of(
|
return DropdownMenuItem<Locale?>(
|
||||||
context,
|
value: ele,
|
||||||
)!.supportedLocales.mapIndexed((idx, ele) {
|
child: Text(
|
||||||
return DropdownMenuItem<Locale?>(
|
'${ele.languageCode}-${ele.countryCode}',
|
||||||
value: ele,
|
).fontSize(14),
|
||||||
child: Text(
|
);
|
||||||
'${ele.languageCode}-${ele.countryCode}',
|
}),
|
||||||
).fontSize(14),
|
DropdownMenuItem<Locale?>(
|
||||||
);
|
value: null,
|
||||||
}),
|
child: Text('languageFollowSystem').tr().fontSize(14),
|
||||||
DropdownMenuItem<Locale?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('languageFollowSystem').tr().fontSize(14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
value: EasyLocalization.of(context)!.currentLocale,
|
|
||||||
onChanged: (Locale? value) {
|
|
||||||
if (value != null) {
|
|
||||||
EasyLocalization.of(context)!.setLocale(value);
|
|
||||||
} else {
|
|
||||||
EasyLocalization.of(context)!.resetLocale();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buttonStyleData: const ButtonStyleData(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
|
||||||
height: 40,
|
|
||||||
width: 160,
|
|
||||||
),
|
|
||||||
menuItemStyleData: const MenuItemStyleData(height: 40),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
value: EasyLocalization.of(context)!.currentLocale,
|
||||||
|
onChanged: (Locale? value) {
|
||||||
|
if (value != null) {
|
||||||
|
EasyLocalization.of(context)!.setLocale(value);
|
||||||
|
} else {
|
||||||
|
EasyLocalization.of(context)!.resetLocale();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonStyleData: const ButtonStyleData(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||||
|
height: 40,
|
||||||
|
width: 160,
|
||||||
),
|
),
|
||||||
ListTile(
|
menuItemStyleData: const MenuItemStyleData(height: 40),
|
||||||
isThreeLine: true,
|
),
|
||||||
minLeadingWidth: 48,
|
),
|
||||||
title: Text('settingsServerUrl').tr(),
|
),
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
|
||||||
leading: const Icon(Symbols.link),
|
// Background image settings (only for non-web platforms)
|
||||||
subtitle: Padding(
|
if (!kIsWeb && docBasepath.value != null)
|
||||||
padding: const EdgeInsets.only(top: 6),
|
ListTile(
|
||||||
child: TextField(
|
minLeadingWidth: 48,
|
||||||
controller: controller,
|
title: Text('settingsBackgroundImage').tr(),
|
||||||
decoration: InputDecoration(
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
hintText: kNetworkServerDefault,
|
leading: const Icon(Symbols.image),
|
||||||
suffixIcon: IconButton(
|
trailing: Row(
|
||||||
icon: const Icon(Symbols.restart_alt),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onPressed: () {
|
children: [
|
||||||
controller.text = kNetworkServerDefault;
|
if (isDesktop)
|
||||||
prefs.setString(
|
Tooltip(
|
||||||
kNetworkServerStoreKey,
|
message: 'settingsBackgroundImageTooltip'.tr(),
|
||||||
kNetworkServerDefault,
|
padding: EdgeInsets.only(left: 8),
|
||||||
);
|
child: const Icon(Symbols.info, size: 18),
|
||||||
ref.invalidate(serverUrlProvider);
|
|
||||||
showSnackBar(context, 'settingsApplied'.tr());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onSubmitted: (value) {
|
|
||||||
if (value.isNotEmpty) {
|
|
||||||
prefs.setString(kNetworkServerStoreKey, value);
|
|
||||||
ref.invalidate(serverUrlProvider);
|
|
||||||
showSnackBar(context, 'settingsApplied'.tr());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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(
|
||||||
|
isThreeLine: true,
|
||||||
|
minLeadingWidth: 48,
|
||||||
|
title: Text('settingsServerUrl').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
leading: const Icon(Symbols.link),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: kNetworkServerDefault,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Symbols.restart_alt),
|
||||||
|
onPressed: () {
|
||||||
|
controller.text = kNetworkServerDefault;
|
||||||
|
prefs.setString(
|
||||||
|
kNetworkServerStoreKey,
|
||||||
|
kNetworkServerDefault,
|
||||||
|
);
|
||||||
|
ref.invalidate(serverUrlProvider);
|
||||||
|
showSnackBar(context, 'settingsApplied'.tr());
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
isDense: true,
|
||||||
),
|
),
|
||||||
if (!kIsWeb && docBasepath.value != null)
|
onSubmitted: (value) {
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
prefs.setString(kNetworkServerStoreKey, value);
|
||||||
|
ref.invalidate(serverUrlProvider);
|
||||||
|
showSnackBar(context, 'settingsApplied'.tr());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final behaviorSettings = [
|
||||||
|
// Auto translate settings
|
||||||
|
ListTile(
|
||||||
|
minLeadingWidth: 48,
|
||||||
|
title: Text('settingsAutoTranslate').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
leading: const Icon(Symbols.translate),
|
||||||
|
trailing: Switch(
|
||||||
|
value: settings.autoTranslate,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(appSettingsProvider.notifier).setAutoTranslate(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Sound effects settings
|
||||||
|
ListTile(
|
||||||
|
minLeadingWidth: 48,
|
||||||
|
title: Text('settingsSoundEffects').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
leading: const Icon(Symbols.volume_up),
|
||||||
|
trailing: Switch(
|
||||||
|
value: settings.soundEffects,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(appSettingsProvider.notifier).setSoundEffects(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// April Fool features settings
|
||||||
|
ListTile(
|
||||||
|
minLeadingWidth: 48,
|
||||||
|
title: Text('settingsAprilFoolFeatures').tr(),
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
leading: const Icon(Symbols.celebration),
|
||||||
|
trailing: Switch(
|
||||||
|
value: settings.aprilFoolFeatures,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(appSettingsProvider.notifier).setAprilFoolFeatures(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Enter to send settings
|
||||||
|
ListTile(
|
||||||
|
minLeadingWidth: 48,
|
||||||
|
title: Text('settingsEnterToSend').tr(),
|
||||||
|
subtitle:
|
||||||
|
isDesktop
|
||||||
|
? Text('settingsEnterToSendDesktopHint').tr().fontSize(12)
|
||||||
|
: null,
|
||||||
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
|
leading: const Icon(Symbols.send),
|
||||||
|
trailing: Switch(
|
||||||
|
value: settings.enterToSend,
|
||||||
|
onChanged: (value) {
|
||||||
|
ref.read(appSettingsProvider.notifier).setEnterToSend(value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Desktop-specific settings
|
||||||
|
final desktopSettings =
|
||||||
|
!isDesktop
|
||||||
|
? <Widget>[]
|
||||||
|
: <Widget>[
|
||||||
ListTile(
|
ListTile(
|
||||||
minLeadingWidth: 48,
|
minLeadingWidth: 48,
|
||||||
title: Text('settingsBackgroundImage').tr(),
|
title: Text('settingsKeyboardShortcuts').tr(),
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||||
leading: const Icon(Symbols.image),
|
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),
|
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(
|
// Create a responsive layout based on screen width
|
||||||
image.path,
|
Widget buildSettingsList() {
|
||||||
).copy('${docBasepath.value}/$kAppBackgroundImagePath');
|
if (isWide) {
|
||||||
prefs.setBool(kAppBackgroundStoreKey, true);
|
// Two-column layout for wide screens
|
||||||
ref.invalidate(backgroundImageFileProvider);
|
return Row(
|
||||||
if (context.mounted) {
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
showSnackBar(context, 'settingsApplied'.tr());
|
children: [
|
||||||
}
|
Expanded(
|
||||||
},
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (!kIsWeb && docBasepath.value != null)
|
children: [
|
||||||
FutureBuilder<bool>(
|
_SettingsSection(
|
||||||
future:
|
title: 'Appearance',
|
||||||
File('${docBasepath.value}/app_background_image').exists(),
|
children: appearanceSettings,
|
||||||
builder: (context, snapshot) {
|
),
|
||||||
if (!snapshot.hasData || !snapshot.data!) {
|
_SettingsSection(title: 'Server', children: serverSettings),
|
||||||
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(
|
|
||||||
minLeadingWidth: 48,
|
|
||||||
title: Text('settingsAutoTranslate').tr(),
|
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
|
||||||
leading: const Icon(Symbols.translate),
|
|
||||||
trailing: Switch(
|
|
||||||
value: settings.autoTranslate,
|
|
||||||
onChanged: (value) {
|
|
||||||
ref
|
|
||||||
.read(appSettingsProvider.notifier)
|
|
||||||
.setAutoTranslate(value);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
Expanded(
|
||||||
minLeadingWidth: 48,
|
child: Column(
|
||||||
title: Text('settingsSoundEffects').tr(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
children: [
|
||||||
leading: const Icon(Symbols.volume_up),
|
_SettingsSection(
|
||||||
trailing: Switch(
|
title: 'Behavior',
|
||||||
value: settings.soundEffects,
|
children: behaviorSettings,
|
||||||
onChanged: (value) {
|
),
|
||||||
ref.read(appSettingsProvider.notifier).setSoundEffects(value);
|
if (desktopSettings.isNotEmpty)
|
||||||
},
|
_SettingsSection(
|
||||||
),
|
title: 'Desktop',
|
||||||
),
|
children: desktopSettings,
|
||||||
ListTile(
|
),
|
||||||
minLeadingWidth: 48,
|
],
|
||||||
title: Text('settingsAprilFoolFeatures').tr(),
|
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
|
||||||
leading: const Icon(Symbols.celebration),
|
|
||||||
trailing: Switch(
|
|
||||||
value: settings.aprilFoolFeatures,
|
|
||||||
onChanged: (value) {
|
|
||||||
ref
|
|
||||||
.read(appSettingsProvider.notifier)
|
|
||||||
.setAprilFoolFeatures(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
minLeadingWidth: 48,
|
|
||||||
title: Text('settingsEnterToSend').tr(),
|
|
||||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
|
||||||
leading: const Icon(Symbols.send),
|
|
||||||
trailing: Switch(
|
|
||||||
value: settings.enterToSend,
|
|
||||||
onChanged: (value) {
|
|
||||||
ref.read(appSettingsProvider.notifier).setEnterToSend(value);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
).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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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(
|
)
|
||||||
onTap: () {
|
.isEmpty
|
||||||
if (callNotifier.roomId == null) return;
|
? callNotifier.participants.first
|
||||||
context.router.push(CallRoute(roomId: callNotifier.roomId!));
|
: callNotifier.participants
|
||||||
},
|
.where(
|
||||||
child: Material(
|
(element) => element.remoteParticipant.lastSpokeAt != null,
|
||||||
elevation: 8,
|
)
|
||||||
borderRadius: BorderRadius.circular(24),
|
.fold(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
callNotifier.participants.first,
|
||||||
child: Container(
|
(value, element) =>
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
element.remoteParticipant.lastSpokeAt != null &&
|
||||||
child: Row(
|
(value.remoteParticipant.lastSpokeAt == null ||
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
element.remoteParticipant.lastSpokeAt!
|
||||||
children: [
|
.compareTo(
|
||||||
Row(
|
value
|
||||||
children: [
|
.remoteParticipant
|
||||||
Icon(Icons.call, color: Colors.white),
|
.lastSpokeAt!,
|
||||||
const SizedBox(width: 12),
|
) >
|
||||||
const Text(
|
0)
|
||||||
'In call',
|
? element
|
||||||
style: TextStyle(
|
: value,
|
||||||
color: Colors.white,
|
);
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
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,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
const Icon(
|
),
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
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: () {
|
||||||
|
context.router.push(CallRoute(roomId: callNotifier.roomId!));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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: [
|
||||||
|
@ -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+95
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.7.2
|
sdk: ^3.7.2
|
||||||
|
Reference in New Issue
Block a user