Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
360572d7d1 | |||
9c1a983466 | |||
bfa97dcd11 | |||
aaa505e83e | |||
552bdfa58f | |||
ebde4eeed5 | |||
688f035f85 |
@ -278,5 +278,35 @@
|
||||
"settingsHideBottomNav": "Hide Bottom Navigation",
|
||||
"settingsSoundEffects": "Sound Effects",
|
||||
"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:island/models/file.dart';
|
||||
import 'package:island/models/user.dart';
|
||||
|
||||
part 'post.freezed.dart';
|
||||
part 'post.g.dart';
|
||||
@ -54,6 +55,7 @@ sealed class SnPublisher with _$SnPublisher {
|
||||
required SnCloudFile? picture,
|
||||
required String? backgroundId,
|
||||
required SnCloudFile? background,
|
||||
required SnAccount? account,
|
||||
required String? accountId,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
|
@ -370,7 +370,7 @@ $SnPublisherCopyWith<$Res> get publisher {
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -383,16 +383,16 @@ $SnPublisherCopyWith<SnPublisher> get copyWith => _$SnPublisherCopyWithImpl<SnPu
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@useResult
|
||||
$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
|
||||
@ -420,7 +420,7 @@ class _$SnPublisherCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnPublisher
|
||||
/// 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(
|
||||
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
|
||||
@ -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 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 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 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
|
||||
@ -463,6 +464,18 @@ $SnCloudFileCopyWith<$Res>? get background {
|
||||
return $SnCloudFileCopyWith<$Res>(_self.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()
|
||||
|
||||
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);
|
||||
|
||||
@override final String id;
|
||||
@ -483,6 +496,7 @@ class _SnPublisher implements SnPublisher {
|
||||
@override final SnCloudFile? picture;
|
||||
@override final String? backgroundId;
|
||||
@override final SnCloudFile? background;
|
||||
@override final SnAccount? account;
|
||||
@override final String? accountId;
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@ -502,16 +516,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@override @useResult
|
||||
$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
|
||||
@ -539,7 +553,7 @@ class __$SnPublisherCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnPublisher
|
||||
/// 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(
|
||||
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
|
||||
@ -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 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 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 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
|
||||
@ -583,6 +598,18 @@ $SnCloudFileCopyWith<$Res>? get background {
|
||||
return $SnCloudFileCopyWith<$Res>(_self.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
|
||||
? null
|
||||
: 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?,
|
||||
createdAt: DateTime.parse(json['created_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(),
|
||||
'background_id': instance.backgroundId,
|
||||
'background': instance.background?.toJson(),
|
||||
'account': instance.account?.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:island/pods/userinfo.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:freezed_annotation/freezed_annotation.dart';
|
||||
import 'dart:async';
|
||||
@ -11,6 +12,14 @@ import 'package:island/pods/websocket.dart';
|
||||
part 'call.g.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
|
||||
sealed class CallState with _$CallState {
|
||||
const factory CallState({
|
||||
@ -18,6 +27,7 @@ sealed class CallState with _$CallState {
|
||||
required bool isMicrophoneEnabled,
|
||||
required bool isCameraEnabled,
|
||||
required bool isScreenSharing,
|
||||
@Default(Duration(seconds: 0)) Duration duration,
|
||||
String? error,
|
||||
}) = _CallState;
|
||||
}
|
||||
@ -54,6 +64,8 @@ class CallNotifier extends _$CallNotifier {
|
||||
List.unmodifiable(_participants);
|
||||
LocalParticipant? get localParticipant => _localParticipant;
|
||||
|
||||
Timer? _durationTimer;
|
||||
|
||||
@override
|
||||
CallState build() {
|
||||
// Subscribe to websocket updates
|
||||
@ -219,8 +231,16 @@ class CallNotifier extends _$CallNotifier {
|
||||
|
||||
Future<void> joinRoom(String roomId) async {
|
||||
_roomId = roomId;
|
||||
if (_room != null) {
|
||||
await _room!.disconnect();
|
||||
await _room!.dispose();
|
||||
_room = null;
|
||||
_localParticipant = null;
|
||||
_participants = [];
|
||||
}
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final ongoingCall = await ref.read(ongoingCallProvider(roomId).future);
|
||||
final response = await apiClient.get('/chat/realtime/$roomId/join');
|
||||
if (response.statusCode == 200 && response.data != null) {
|
||||
final data = response.data;
|
||||
@ -229,6 +249,19 @@ class CallNotifier extends _$CallNotifier {
|
||||
final participants = joinResponse.participants;
|
||||
final String endpoint = joinResponse.endpoint;
|
||||
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
|
||||
_room = Room();
|
||||
|
||||
@ -314,5 +347,6 @@ class CallNotifier extends _$CallNotifier {
|
||||
_roomListener?.dispose();
|
||||
_room?.removeListener(_onRoomChange);
|
||||
_room?.dispose();
|
||||
_durationTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -26,16 +26,16 @@ $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>
|
||||
|
||||
@override
|
||||
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
|
||||
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
|
||||
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
|
||||
|
||||
@override
|
||||
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;
|
||||
@useResult
|
||||
$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
|
||||
/// 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(
|
||||
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,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,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?,
|
||||
));
|
||||
}
|
||||
@ -81,13 +82,14 @@ as String?,
|
||||
|
||||
|
||||
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 isMicrophoneEnabled;
|
||||
@override final bool isCameraEnabled;
|
||||
@override final bool isScreenSharing;
|
||||
@override@JsonKey() final Duration duration;
|
||||
@override final String? error;
|
||||
|
||||
/// Create a copy of CallState
|
||||
@ -100,16 +102,16 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt
|
||||
|
||||
@override
|
||||
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
|
||||
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
|
||||
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error);
|
||||
|
||||
@override
|
||||
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;
|
||||
@override @useResult
|
||||
$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
|
||||
/// 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(
|
||||
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,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,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?,
|
||||
));
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'5512070f943d98e999d97549c73e4d5f6e7b3ddd';
|
||||
String _$callNotifierHash() => r'2082a572b5cfb4bf929dc1ed492c52cd2735452e';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
@ -51,6 +51,7 @@ class WebSocketService {
|
||||
|
||||
Future<void> connect(Ref ref) async {
|
||||
_ref = ref;
|
||||
_statusStreamController.sink.add(WebSocketState.connecting());
|
||||
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final atk = await getFreshAtk(
|
||||
@ -140,23 +141,10 @@ class WebSocketStateNotifier extends StateNotifier<WebSocketState> {
|
||||
state = const WebSocketState.connecting();
|
||||
try {
|
||||
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);
|
||||
state = const WebSocketState.connected();
|
||||
service.statusStream.listen((event) {
|
||||
state = event;
|
||||
if (mounted) state = event;
|
||||
});
|
||||
} catch (err) {
|
||||
state = WebSocketState.error('Failed to connect: $err');
|
||||
|
@ -235,6 +235,16 @@ class AccountScreen extends HookConsumerWidget {
|
||||
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)
|
||||
ListTile(
|
||||
@ -284,7 +294,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: const Text('Account')),
|
||||
appBar: AppBar(title: const Text('account').tr()),
|
||||
body:
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
|
@ -1,8 +1,19 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/annotations.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package: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:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class AccountSettingsScreen extends HookConsumerWidget {
|
||||
@ -10,9 +21,274 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
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(
|
||||
appBar: AppBar(title: Text('accountSettings').tr()),
|
||||
body: SingleChildScrollView(child: Column(children: [])),
|
||||
appBar: AppBar(
|
||||
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:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'captcha.dart';
|
||||
|
||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||
@ -354,15 +356,18 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
||||
showErrorAlert('loginResetPasswordHint'.tr());
|
||||
return;
|
||||
}
|
||||
final captchaTk = await Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
||||
if (captchaTk == null) return;
|
||||
isBusy.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final lookupResp = await client.get('/users/lookup?probe=$uname');
|
||||
await client.post(
|
||||
'/users/me/password-reset',
|
||||
data: {'user_id': lookupResp.data['id']},
|
||||
'/accounts/recovery/password',
|
||||
data: {'account': uname, 'captcha_token': captchaTk},
|
||||
);
|
||||
showInfoAlert('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
|
@ -17,6 +17,7 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
if (route is DialogRoute) return;
|
||||
Future(() {
|
||||
onChange(route.settings.name);
|
||||
});
|
||||
@ -24,6 +25,7 @@ class TabNavigationObserver extends AutoRouterObserver {
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
if (route is DialogRoute) return;
|
||||
Future(() {
|
||||
onChange(previousRoute?.settings.name);
|
||||
});
|
||||
|
@ -5,11 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/widgets/app_scaffold.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:livekit_client/livekit_client.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@ -22,8 +21,6 @@ class CallScreen extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
final chatRoom = ref.watch(chatroomProvider(roomId));
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
final callNotifier = ref.read(callNotifierProvider.notifier);
|
||||
|
||||
@ -32,10 +29,6 @@ class CallScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final actionButtonStyle = ButtonStyle(
|
||||
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
|
||||
);
|
||||
|
||||
final viewMode = useState<String>('grid');
|
||||
|
||||
return AppScaffold(
|
||||
@ -74,20 +67,12 @@ class CallScreen extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
chatRoom.whenOrNull()?.name ?? 'loading'.tr(),
|
||||
ongoingCall.value?.room.name ?? 'call'.tr(),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
callState.isConnected
|
||||
? Duration(
|
||||
milliseconds:
|
||||
(DateTime.now().millisecondsSinceEpoch -
|
||||
(ongoingCall
|
||||
.value
|
||||
?.createdAt
|
||||
.millisecondsSinceEpoch ??
|
||||
0)),
|
||||
).toString()
|
||||
? formatDuration(callState.duration)
|
||||
: 'Connecting',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
@ -131,78 +116,6 @@ class CallScreen extends HookConsumerWidget {
|
||||
)
|
||||
: Column(
|
||||
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(
|
||||
child: Builder(
|
||||
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/file.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat_summary.dart';
|
||||
import 'package:island/pods/config.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/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/realms/selection_dropdown.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
@ -147,12 +149,11 @@ class ChatRoomListTile extends HookConsumerWidget {
|
||||
subtitle: buildSubtitle(),
|
||||
onTap: () async {
|
||||
// Clear unread count if there are unread messages
|
||||
final summary = await ref.read(chatSummaryProvider.future);
|
||||
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
|
||||
await ref
|
||||
.read(chatSummaryProvider.notifier)
|
||||
.clearUnreadCount(room.id);
|
||||
}
|
||||
ref.read(chatSummaryProvider.future).then((summary) {
|
||||
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
|
||||
ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
|
||||
}
|
||||
});
|
||||
onTap?.call();
|
||||
},
|
||||
);
|
||||
@ -213,6 +214,8 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
0,
|
||||
); // 0 for All, 1 for Direct Messages, 2 for Group Chats
|
||||
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
|
||||
useEffect(() {
|
||||
tabController.addListener(() {
|
||||
selectedTab.value = tabController.index;
|
||||
@ -334,76 +337,93 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
},
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaryState = ref.watch(chatSummaryProvider);
|
||||
return summaryState.maybeWhen(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: chats.when(
|
||||
data:
|
||||
(items) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filteredItems =
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.toList();
|
||||
final item = filteredItems[index];
|
||||
return ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
if (context.router.topRoute.name ==
|
||||
ChatRoomRoute.name) {
|
||||
context.router.replace(
|
||||
ChatRoomRoute(id: item.id),
|
||||
);
|
||||
} else {
|
||||
context.router.push(ChatRoomRoute(id: item.id));
|
||||
}
|
||||
Column(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaryState = ref.watch(chatSummaryProvider);
|
||||
return summaryState.maybeWhen(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: chats.when(
|
||||
data:
|
||||
(items) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding:
|
||||
callState.isConnected
|
||||
? EdgeInsets.only(bottom: 96)
|
||||
: EdgeInsets.zero,
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filteredItems =
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.toList();
|
||||
final item = filteredItems[index];
|
||||
return ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
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()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
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/widgets/alert.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/content/cloud_files.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
@ -352,6 +353,10 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
if (message.chatRoomId != chatRoom.value?.id) return;
|
||||
switch (pkt.type) {
|
||||
case 'messages.new':
|
||||
if (message.type.startsWith('call')) {
|
||||
// Handle the ongoing call.
|
||||
ref.invalidate(ongoingCallProvider(message.chatRoomId));
|
||||
}
|
||||
messagesNotifier.receiveMessage(message);
|
||||
// Send read receipt for new message
|
||||
sendReadReceipt();
|
||||
@ -525,152 +530,166 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: SuperListView.builder(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1
|
||||
? messageList[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data:
|
||||
(messageList) =>
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: SuperListView.builder(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse:
|
||||
true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
index < messageList.length - 1
|
||||
? messageList[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId !=
|
||||
message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser:
|
||||
identity?.id == message.senderId,
|
||||
onAction: (action) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(
|
||||
message.id,
|
||||
);
|
||||
case MessageItemAction.edit:
|
||||
messageEditingTo.value =
|
||||
message.toRemoteMessage();
|
||||
messageController.text =
|
||||
messageEditingTo
|
||||
.value
|
||||
?.content ??
|
||||
'';
|
||||
attachments.value =
|
||||
messageEditingTo
|
||||
.value!
|
||||
.attachments
|
||||
.map(
|
||||
(e) =>
|
||||
UniversalFile.fromAttachment(
|
||||
e,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
case MessageItemAction.forward:
|
||||
messageForwardingTo.value =
|
||||
message.toRemoteMessage();
|
||||
case MessageItemAction.reply:
|
||||
messageReplyingTo.value =
|
||||
message.toRemoteMessage();
|
||||
}
|
||||
},
|
||||
progress:
|
||||
attachmentProgress.value[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
),
|
||||
loading:
|
||||
() => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(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;
|
||||
},
|
||||
return chatIdentity.when(
|
||||
skipError: true,
|
||||
data:
|
||||
(identity) => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser:
|
||||
identity?.id == message.senderId,
|
||||
onAction: (action) {
|
||||
switch (action) {
|
||||
case MessageItemAction.delete:
|
||||
messagesNotifier.deleteMessage(
|
||||
message.id,
|
||||
);
|
||||
case MessageItemAction.edit:
|
||||
messageEditingTo.value =
|
||||
message.toRemoteMessage();
|
||||
messageController.text =
|
||||
messageEditingTo
|
||||
.value
|
||||
?.content ??
|
||||
'';
|
||||
attachments.value =
|
||||
messageEditingTo
|
||||
.value!
|
||||
.attachments
|
||||
.map(
|
||||
(e) =>
|
||||
UniversalFile.fromAttachment(
|
||||
e,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
case MessageItemAction.forward:
|
||||
messageForwardingTo.value =
|
||||
message.toRemoteMessage();
|
||||
case MessageItemAction.reply:
|
||||
messageReplyingTo.value =
|
||||
message.toRemoteMessage();
|
||||
}
|
||||
},
|
||||
progress:
|
||||
attachmentProgress.value[message
|
||||
.id],
|
||||
showAvatar: isLastInGroup,
|
||||
),
|
||||
loading:
|
||||
() => MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
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),
|
||||
child: TextField(
|
||||
controller: messageController,
|
||||
onSubmitted: enterToSend ? (_) => onSend() : null,
|
||||
inputFormatters: [
|
||||
if (enterToSend)
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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:island/models/user.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
@ -21,8 +23,18 @@ part 'notification.g.dart';
|
||||
@riverpod
|
||||
class NotificationUnreadCountNotifier
|
||||
extends _$NotificationUnreadCountNotifier {
|
||||
StreamSubscription<WebSocketPacket>? _subscription;
|
||||
|
||||
@override
|
||||
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 {
|
||||
final client = ref.read(apiClientProvider);
|
||||
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 {
|
||||
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,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
@ -142,7 +169,7 @@ class NotificationScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
trailing:
|
||||
notification.viewedAt == null
|
||||
notification.viewedAt != null
|
||||
? null
|
||||
: Container(
|
||||
width: 12,
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:auto_route/auto_route.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/posts/detail.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@ -261,7 +261,43 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title:
|
||||
isWideScreen(context)
|
||||
? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr())
|
||||
: null,
|
||||
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(
|
||||
onPressed: submitting.value ? null : performAction,
|
||||
icon:
|
||||
@ -316,7 +352,7 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
TextField(
|
||||
controller: titleController,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Title',
|
||||
hintText: 'postTitle'.tr(),
|
||||
),
|
||||
style: TextStyle(fontSize: 16),
|
||||
onTapOutside:
|
||||
@ -326,7 +362,7 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'Description',
|
||||
hintText: 'postDescription'.tr(),
|
||||
),
|
||||
style: TextStyle(fontSize: 16),
|
||||
onTapOutside:
|
||||
@ -345,6 +381,7 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
hintText: 'postPlaceholder'.tr(),
|
||||
isDense: true,
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
@ -352,34 +389,64 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
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),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide
|
||||
? Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var idx = 0; idx < attachments.value.length; idx++)
|
||||
SizedBox(
|
||||
width: constraints.maxWidth / 2 - 4,
|
||||
child: AttachmentPreview(
|
||||
item: attachments.value[idx],
|
||||
progress: attachmentProgress.value[idx],
|
||||
onRequestUpload: () => uploadAttachment(idx),
|
||||
onDelete: () => deleteAttachment(idx),
|
||||
onMove: (delta) {
|
||||
if (idx + delta < 0 ||
|
||||
idx + delta >= attachments.value.length) {
|
||||
return;
|
||||
}
|
||||
final clone = List.of(attachments.value);
|
||||
clone.insert(
|
||||
idx + delta,
|
||||
clone.removeAt(idx),
|
||||
);
|
||||
attachments.value = clone;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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
|
||||
Future<List<SnAccountBadge>> publisherBadges(Ref ref, String pubName) async {
|
||||
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 resp = await apiClient.get("/accounts/${pub.name}/badges");
|
||||
final resp = await apiClient.get("/accounts/${pub.account!.name}/badges");
|
||||
return List<SnAccountBadge>.from(
|
||||
resp.data.map((x) => SnAccountBadge.fromJson(x)),
|
||||
);
|
||||
@ -177,9 +177,9 @@ class PublisherProfileScreen extends HookConsumerWidget {
|
||||
).fontSize(14).opacity(0.85),
|
||||
],
|
||||
),
|
||||
if (data.type == 0)
|
||||
if (data.type == 0 && data.account != null)
|
||||
AccountStatusWidget(
|
||||
uname: name,
|
||||
uname: data.account!.name,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
|
@ -6,10 +6,12 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@ -27,6 +29,9 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
final controller = TextEditingController(text: serverUrl);
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
final isDesktop =
|
||||
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
final docBasepath = useState<String?>(null);
|
||||
|
||||
@ -37,200 +42,437 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(title: const Text('Settings')),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsDisplayLanguage').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.translate),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<Locale?>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...EasyLocalization.of(
|
||||
context,
|
||||
)!.supportedLocales.mapIndexed((idx, ele) {
|
||||
return DropdownMenuItem<Locale?>(
|
||||
value: ele,
|
||||
child: Text(
|
||||
'${ele.languageCode}-${ele.countryCode}',
|
||||
).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),
|
||||
),
|
||||
// Group settings into categories for better organization
|
||||
final appearanceSettings = [
|
||||
// Language settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsDisplayLanguage').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.translate),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<Locale?>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((
|
||||
idx,
|
||||
ele,
|
||||
) {
|
||||
return DropdownMenuItem<Locale?>(
|
||||
value: ele,
|
||||
child: Text(
|
||||
'${ele.languageCode}-${ele.countryCode}',
|
||||
).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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
prefs.setString(kNetworkServerStoreKey, value);
|
||||
ref.invalidate(serverUrlProvider);
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
menuItemStyleData: const MenuItemStyleData(height: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Background image settings (only for non-web platforms)
|
||||
if (!kIsWeb && docBasepath.value != null)
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsBackgroundImage').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.image),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isDesktop)
|
||||
Tooltip(
|
||||
message: 'settingsBackgroundImageTooltip'.tr(),
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: const Icon(Symbols.info, size: 18),
|
||||
),
|
||||
const Icon(Symbols.chevron_right),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
final imagePicker = ref.read(imagePickerProvider);
|
||||
final image = await imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
if (image == null) return;
|
||||
|
||||
await File(
|
||||
image.path,
|
||||
).copy('${docBasepath.value}/$kAppBackgroundImagePath');
|
||||
prefs.setBool(kAppBackgroundStoreKey, true);
|
||||
ref.invalidate(backgroundImageFileProvider);
|
||||
if (context.mounted) {
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Clear background image option
|
||||
if (!kIsWeb && docBasepath.value != null)
|
||||
FutureBuilder<bool>(
|
||||
future: File('${docBasepath.value}/app_background_image').exists(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || !snapshot.data!) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text('settingsBackgroundImageClear').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.texture),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
File(
|
||||
'${docBasepath.value}/$kAppBackgroundImagePath',
|
||||
).deleteSync();
|
||||
prefs.remove(kAppBackgroundStoreKey);
|
||||
ref.invalidate(backgroundImageFileProvider);
|
||||
if (context.mounted) {
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
final serverSettings = [
|
||||
// Server URL settings
|
||||
ListTile(
|
||||
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(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsBackgroundImage').tr(),
|
||||
title: Text('settingsKeyboardShortcuts').tr(),
|
||||
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),
|
||||
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());
|
||||
}
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && docBasepath.value != null)
|
||||
FutureBuilder<bool>(
|
||||
future:
|
||||
File('${docBasepath.value}/app_background_image').exists(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || !snapshot.data!) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text('settingsBackgroundImageClear').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.texture),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
File(
|
||||
'${docBasepath.value}/$kAppBackgroundImagePath',
|
||||
).deleteSync();
|
||||
prefs.remove(kAppBackgroundStoreKey);
|
||||
ref.invalidate(backgroundImageFileProvider);
|
||||
if (context.mounted) {
|
||||
showSnackBar(context, 'settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
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);
|
||||
},
|
||||
// Create a responsive layout based on screen width
|
||||
Widget buildSettingsList() {
|
||||
if (isWide) {
|
||||
// Two-column layout for wide screens
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: 'Appearance',
|
||||
children: appearanceSettings,
|
||||
),
|
||||
_SettingsSection(title: 'Server', children: serverSettings),
|
||||
],
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
),
|
||||
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);
|
||||
},
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingsSection(
|
||||
title: 'Behavior',
|
||||
children: behaviorSettings,
|
||||
),
|
||||
if (desktopSettings.isNotEmpty)
|
||||
_SettingsSection(
|
||||
title: 'Desktop',
|
||||
children: desktopSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16);
|
||||
} else {
|
||||
// Single column layout for narrow screens
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SettingsSection(title: 'Appearance', children: appearanceSettings),
|
||||
_SettingsSection(title: 'Server', children: serverSettings),
|
||||
_SettingsSection(title: 'Behavior', children: behaviorSettings),
|
||||
if (desktopSettings.isNotEmpty)
|
||||
_SettingsSection(title: 'Desktop', children: desktopSettings),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
noBackground: false,
|
||||
appBar: AppBar(
|
||||
title: Text('Settings').tr(),
|
||||
actions:
|
||||
isDesktop
|
||||
? [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.help_outline),
|
||||
onPressed: () {
|
||||
// Show help dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('settingsHelp').tr(),
|
||||
content: Text('settingsHelpContent').tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Close').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
body: Focus(
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
// Add keyboard shortcuts for desktop
|
||||
if (isDesktop &&
|
||||
event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
context.router.pop();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: buildSettingsList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper widget for displaying settings sections with titles
|
||||
class _SettingsSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
...children,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper widget for displaying keyboard shortcuts
|
||||
class _ShortcutRow extends StatelessWidget {
|
||||
final String shortcut;
|
||||
final String description;
|
||||
|
||||
const _ShortcutRow({required this.shortcut, required this.description});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
child: Text(shortcut, style: TextStyle(fontFamily: 'monospace')),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Text(description),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -13,8 +13,6 @@ import 'package:island/services/responsive.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.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 {
|
||||
final Widget child;
|
||||
@ -152,22 +150,8 @@ class AppScaffold extends StatelessWidget {
|
||||
noBackground
|
||||
? Colors.transparent
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
SizedBox.expand(
|
||||
child:
|
||||
noBackground
|
||||
? content
|
||||
: AppBackground(isRoot: true, child: content),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 8,
|
||||
child: const _GlobalCallOverlay(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body:
|
||||
noBackground ? content : AppBackground(isRoot: true, child: content),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
bottomSheet: bottomSheet,
|
||||
@ -206,23 +190,6 @@ class PageBackButton extends StatelessWidget {
|
||||
|
||||
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 {
|
||||
if (kIsWeb) return null;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
|
@ -1,10 +1,94 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/userinfo.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 {
|
||||
const CallOverlayBar({super.key});
|
||||
|
||||
@ -15,48 +99,123 @@ class CallOverlayBar extends HookConsumerWidget {
|
||||
// Only show if connected and not on the call screen
|
||||
if (!callState.isConnected) return const SizedBox.shrink();
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 32,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (callNotifier.roomId == null) return;
|
||||
context.router.push(CallRoute(roomId: callNotifier.roomId!));
|
||||
},
|
||||
child: Material(
|
||||
elevation: 8,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.call, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'In call',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
final lastSpeaker =
|
||||
callNotifier.participants
|
||||
.where(
|
||||
(element) => element.remoteParticipant.lastSpokeAt != null,
|
||||
)
|
||||
.isEmpty
|
||||
? callNotifier.participants.first
|
||||
: callNotifier.participants
|
||||
.where(
|
||||
(element) => element.remoteParticipant.lastSpokeAt != null,
|
||||
)
|
||||
.fold(
|
||||
callNotifier.participants.first,
|
||||
(value, element) =>
|
||||
element.remoteParticipant.lastSpokeAt != null &&
|
||||
(value.remoteParticipant.lastSpokeAt == null ||
|
||||
element.remoteParticipant.lastSpokeAt!
|
||||
.compareTo(
|
||||
value
|
||||
.remoteParticipant
|
||||
.lastSpokeAt!,
|
||||
) >
|
||||
0)
|
||||
? element
|
||||
: value,
|
||||
);
|
||||
|
||||
final actionButtonStyle = ButtonStyle(
|
||||
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (callNotifier.localParticipant == null) {
|
||||
return CircularProgressIndicator().center();
|
||||
}
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child:
|
||||
SpeakingRippleAvatar(
|
||||
isSpeaking: lastSpeaker.isSpeaking,
|
||||
audioLevel:
|
||||
lastSpeaker.remoteParticipant.audioLevel,
|
||||
pictureId:
|
||||
lastSpeaker
|
||||
.participant
|
||||
.profile
|
||||
?.account
|
||||
.profile
|
||||
.pictureId,
|
||||
size: 36,
|
||||
).center(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
lastSpeaker.participant.profile?.account.nick ??
|
||||
'unknown'.tr(),
|
||||
).bold(),
|
||||
Text(
|
||||
formatDuration(callState.duration),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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:island/database/message.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/screens/chat/room.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_file_collection.dart';
|
||||
@ -429,14 +430,6 @@ class _MessageContentCall extends StatelessWidget {
|
||||
|
||||
@override
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 3.0.0+94
|
||||
version: 3.0.0+95
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
|
Reference in New Issue
Block a user