✨ Auth factor settings
This commit is contained in:
parent
accb99bb24
commit
bf77bfce64
@ -165,7 +165,7 @@
|
||||
"status": "Status",
|
||||
"statusActivityTitle": "{} is {} {}",
|
||||
"statusActivityEndedTitle": "{} is {} {} until {}",
|
||||
"appSettings": "App settings",
|
||||
"appSettings": "App Settings",
|
||||
"accountSettings": "Account Settings",
|
||||
"settings": "Settings",
|
||||
"language": "Language",
|
||||
@ -302,24 +302,14 @@
|
||||
"accountDeletionHint": "Are you sure to delete your account? If you confirmed, we will send an confirmation email to your primary email address, you can continue the deletion process by follow the insturctions in the email.",
|
||||
"accountDeletionSent": "Account deletion confirmation email sent, please check your email inbox.",
|
||||
"accountSecurityTitle": "Security",
|
||||
"accountPrivacyTitle": "Privacy",
|
||||
"accountDangerZoneTitle": "Danger Zone",
|
||||
"accountPassword": "Password",
|
||||
"accountPasswordDescription": "Change your account password",
|
||||
"accountPasswordChange": "Change Password",
|
||||
"accountPasswordChangeSent": "Password reset link sent, please check your email inbox.",
|
||||
"accountPasswordChangeDescription": "We will send an email to your primary email address to reset your password.",
|
||||
"accountTwoFactor": "Two-Factor Authentication",
|
||||
"accountTwoFactorDescription": "Add an extra layer of security to your account",
|
||||
"accountTwoFactorSetup": "Set Up 2FA",
|
||||
"accountTwoFactorSetupDescription": "Two-factor authentication adds an additional layer of security to your account by requiring more than just a password to sign in.",
|
||||
"accountPrivacy": "Privacy Settings",
|
||||
"accountPrivacyDescription": "Control who can see your profile and content",
|
||||
"accountDataExport": "Export Your Data",
|
||||
"accountDataExportDescription": "Download a copy of your data",
|
||||
"accountDataExportConfirmation": "We'll prepare an export of your data which may take some time. You'll receive an email when it's ready to download.",
|
||||
"accountDataExportConfirm": "Request Export",
|
||||
"accountDataExportRequested": "Data export requested. You'll receive an email when it's ready.",
|
||||
"accountAuthFactor": "Auth factors",
|
||||
"accountAuthFactorDescription": "Multi-factor authentication to ensure safety and convience",
|
||||
"accountDeletionDescription": "Permanently delete your account and all your data",
|
||||
"accountSettingsHelp": "Account Settings Help",
|
||||
"accountSettingsHelpContent": "This page allows you to manage your account security, privacy, and other settings. If you need assistance, please contact support.",
|
||||
@ -330,5 +320,21 @@
|
||||
"postVisibilityPublic": "Public",
|
||||
"postVisibilityFriends": "Friends Only",
|
||||
"postVisibilityUnlisted": "Unlisted",
|
||||
"postVisibilityPrivate": "Private"
|
||||
"postVisibilityPrivate": "Private",
|
||||
"copyMessage": "Copy Message",
|
||||
"authFactor": "Authentication Factor",
|
||||
"authFactorDelete": "Delete the Factor",
|
||||
"authFactorDeleteHint": "Are you sure you want to delete this authentication factor? This action cannot be undone.",
|
||||
"authFactorDisable": "Disable the Factor",
|
||||
"authFactorDisableHint": "Are you sure you want to disable this authentication factor? You can enable it again later.",
|
||||
"authFactorEnable": "Enable the Factor",
|
||||
"authFactorEnableHint": "Please enter the code that generated by the factor to enable it.",
|
||||
"authFactorNew": "Create Auth Factor",
|
||||
"authFactorSecret": "Secret",
|
||||
"authFactorSecretHint": "Create an secret for this factor.",
|
||||
"authFactorQrCodeScan": "Scan this QR code with your authenticator app to set up TOTP authentication",
|
||||
"authFactorNoQrCode": "No QR code available for this authentication factor",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"authFactorAdditional": "One more step"
|
||||
}
|
||||
|
@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:island/database/drift_db.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
@ -45,6 +48,13 @@ class MessageRepository {
|
||||
},
|
||||
);
|
||||
|
||||
for (final item in resp.data['changes']) {
|
||||
if (item['message']['sender']['account'] == null)
|
||||
log(jsonEncode(item['message']['sender']['account']));
|
||||
// if (item['message'] != null &&
|
||||
// item['message']['sender']['account'] == null) {
|
||||
// }
|
||||
}
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
for (final change in response.changes) {
|
||||
switch (change.action) {
|
||||
|
@ -42,6 +42,10 @@ sealed class SnAuthFactor with _$SnAuthFactor {
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required DateTime? expiredAt,
|
||||
required DateTime? enabledAt,
|
||||
required int trustworthy,
|
||||
required Map<String, dynamic>? createdResponse,
|
||||
}) = _SnAuthFactor;
|
||||
|
||||
factory SnAuthFactor.fromJson(Map<String, dynamic> json) =>
|
||||
|
@ -339,7 +339,7 @@ as DateTime?,
|
||||
/// @nodoc
|
||||
mixin _$SnAuthFactor {
|
||||
|
||||
String get id; int get type; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||
String get id; int get type; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; DateTime? get expiredAt; DateTime? get enabledAt; int get trustworthy; Map<String, dynamic>? get createdResponse;
|
||||
/// Create a copy of SnAuthFactor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@ -352,16 +352,16 @@ $SnAuthFactorCopyWith<SnAuthFactor> get copyWith => _$SnAuthFactorCopyWithImpl<S
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.enabledAt, enabledAt) || other.enabledAt == enabledAt)&&(identical(other.trustworthy, trustworthy) || other.trustworthy == trustworthy)&&const DeepCollectionEquality().equals(other.createdResponse, createdResponse));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt);
|
||||
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt,expiredAt,enabledAt,trustworthy,const DeepCollectionEquality().hash(createdResponse));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, enabledAt: $enabledAt, trustworthy: $trustworthy, createdResponse: $createdResponse)';
|
||||
}
|
||||
|
||||
|
||||
@ -372,7 +372,7 @@ abstract mixin class $SnAuthFactorCopyWith<$Res> {
|
||||
factory $SnAuthFactorCopyWith(SnAuthFactor value, $Res Function(SnAuthFactor) _then) = _$SnAuthFactorCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, DateTime? expiredAt, DateTime? enabledAt, int trustworthy, Map<String, dynamic>? createdResponse
|
||||
});
|
||||
|
||||
|
||||
@ -389,14 +389,18 @@ class _$SnAuthFactorCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnAuthFactor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? expiredAt = freezed,Object? enabledAt = freezed,Object? trustworthy = null,Object? createdResponse = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
as DateTime?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,enabledAt: freezed == enabledAt ? _self.enabledAt : enabledAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,trustworthy: null == trustworthy ? _self.trustworthy : trustworthy // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdResponse: freezed == createdResponse ? _self.createdResponse : createdResponse // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,
|
||||
));
|
||||
}
|
||||
|
||||
@ -407,7 +411,7 @@ as DateTime?,
|
||||
@JsonSerializable()
|
||||
|
||||
class _SnAuthFactor implements SnAuthFactor {
|
||||
const _SnAuthFactor({required this.id, required this.type, required this.createdAt, required this.updatedAt, required this.deletedAt});
|
||||
const _SnAuthFactor({required this.id, required this.type, required this.createdAt, required this.updatedAt, required this.deletedAt, required this.expiredAt, required this.enabledAt, required this.trustworthy, required final Map<String, dynamic>? createdResponse}): _createdResponse = createdResponse;
|
||||
factory _SnAuthFactor.fromJson(Map<String, dynamic> json) => _$SnAuthFactorFromJson(json);
|
||||
|
||||
@override final String id;
|
||||
@ -415,6 +419,18 @@ class _SnAuthFactor implements SnAuthFactor {
|
||||
@override final DateTime createdAt;
|
||||
@override final DateTime updatedAt;
|
||||
@override final DateTime? deletedAt;
|
||||
@override final DateTime? expiredAt;
|
||||
@override final DateTime? enabledAt;
|
||||
@override final int trustworthy;
|
||||
final Map<String, dynamic>? _createdResponse;
|
||||
@override Map<String, dynamic>? get createdResponse {
|
||||
final value = _createdResponse;
|
||||
if (value == null) return null;
|
||||
if (_createdResponse is EqualUnmodifiableMapView) return _createdResponse;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableMapView(value);
|
||||
}
|
||||
|
||||
|
||||
/// Create a copy of SnAuthFactor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@ -429,16 +445,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAuthFactor&&(identical(other.id, id) || other.id == id)&&(identical(other.type, type) || other.type == type)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.enabledAt, enabledAt) || other.enabledAt == enabledAt)&&(identical(other.trustworthy, trustworthy) || other.trustworthy == trustworthy)&&const DeepCollectionEquality().equals(other._createdResponse, _createdResponse));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt);
|
||||
int get hashCode => Object.hash(runtimeType,id,type,createdAt,updatedAt,deletedAt,expiredAt,enabledAt,trustworthy,const DeepCollectionEquality().hash(_createdResponse));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||
return 'SnAuthFactor(id: $id, type: $type, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, expiredAt: $expiredAt, enabledAt: $enabledAt, trustworthy: $trustworthy, createdResponse: $createdResponse)';
|
||||
}
|
||||
|
||||
|
||||
@ -449,7 +465,7 @@ abstract mixin class _$SnAuthFactorCopyWith<$Res> implements $SnAuthFactorCopyWi
|
||||
factory _$SnAuthFactorCopyWith(_SnAuthFactor value, $Res Function(_SnAuthFactor) _then) = __$SnAuthFactorCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||
String id, int type, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, DateTime? expiredAt, DateTime? enabledAt, int trustworthy, Map<String, dynamic>? createdResponse
|
||||
});
|
||||
|
||||
|
||||
@ -466,14 +482,18 @@ class __$SnAuthFactorCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of SnAuthFactor
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? type = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? expiredAt = freezed,Object? enabledAt = freezed,Object? trustworthy = null,Object? createdResponse = freezed,}) {
|
||||
return _then(_SnAuthFactor(
|
||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
as DateTime?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,enabledAt: freezed == enabledAt ? _self.enabledAt : enabledAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,trustworthy: null == trustworthy ? _self.trustworthy : trustworthy // ignore: cast_nullable_to_non_nullable
|
||||
as int,createdResponse: freezed == createdResponse ? _self._createdResponse : createdResponse // ignore: cast_nullable_to_non_nullable
|
||||
as Map<String, dynamic>?,
|
||||
));
|
||||
}
|
||||
|
||||
|
@ -67,6 +67,16 @@ _SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
|
||||
json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
expiredAt:
|
||||
json['expired_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['expired_at'] as String),
|
||||
enabledAt:
|
||||
json['enabled_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['enabled_at'] as String),
|
||||
trustworthy: (json['trustworthy'] as num).toInt(),
|
||||
createdResponse: json['created_response'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
|
||||
@ -76,4 +86,8 @@ Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'expired_at': instance.expiredAt?.toIso8601String(),
|
||||
'enabled_at': instance.enabledAt?.toIso8601String(),
|
||||
'trustworthy': instance.trustworthy,
|
||||
'created_response': instance.createdResponse,
|
||||
};
|
||||
|
@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/annotations.dart';
|
||||
@ -5,16 +6,33 @@ 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:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/auth/captcha.dart';
|
||||
import 'package:island/screens/auth/login.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'settings.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAuthFactor>> authFactors(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final res = await client.get('/accounts/me/factors');
|
||||
return res.data.map<SnAuthFactor>((e) => SnAuthFactor.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class AccountSettingsScreen extends HookConsumerWidget {
|
||||
const AccountSettingsScreen({super.key});
|
||||
@ -45,7 +63,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
Future<void> requestResetPassword() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'accountPasswordChangeDescription'.tr(),
|
||||
'accountPassword'.tr(),
|
||||
'accountPasswordChange'.tr(),
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
final captchaTk = await Navigator.of(
|
||||
@ -67,81 +85,80 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final authFactors = ref.watch(authFactorsProvider);
|
||||
|
||||
// Group settings into categories for better organization
|
||||
final securitySettings = [
|
||||
ExpansionTile(
|
||||
leading: const Icon(
|
||||
Symbols.security,
|
||||
).alignment(Alignment.centerLeft).width(48),
|
||||
title: Text('accountAuthFactor').tr(),
|
||||
subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
|
||||
tilePadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
children: [
|
||||
authFactors.when(
|
||||
data:
|
||||
(factors) => Column(
|
||||
children: [
|
||||
for (final factor in factors)
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
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();
|
||||
},
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 17,
|
||||
),
|
||||
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),
|
||||
title: Text(kFactorTypes[factor.type]!.$1).tr(),
|
||||
subtitle: Text(kFactorTypes[factor.type]!.$2).tr(),
|
||||
leading: Icon(kFactorTypes[factor.type]!.$3),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
// Navigate to two-factor authentication settings
|
||||
showDialog(
|
||||
if (factor.type == 0) {
|
||||
requestResetPassword();
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet(
|
||||
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
|
||||
(context) => _AuthFactorSheet(factor: factor),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(authFactorsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (factors.isNotEmpty) Divider(height: 1),
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 17,
|
||||
),
|
||||
title: Text('authFactorNew').tr(),
|
||||
leading: const Icon(Symbols.add),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => const _AuthFactorNewSheet(),
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
ref.invalidate(authFactorsProvider);
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Text('accountTwoFactorSetup').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
error:
|
||||
(err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(authFactorsProvider),
|
||||
),
|
||||
];
|
||||
|
||||
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());
|
||||
},
|
||||
loading: () => ResponseLoadingWidget(),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
@ -172,10 +189,6 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
title: 'accountSecurityTitle',
|
||||
children: securitySettings,
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'accountPrivacyTitle',
|
||||
children: privacySettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -201,10 +214,6 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
||||
title: 'accountSecurityTitle',
|
||||
children: securitySettings,
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'accountPrivacyTitle',
|
||||
children: privacySettings,
|
||||
),
|
||||
_SettingsSection(
|
||||
title: 'accountDangerZoneTitle',
|
||||
children: dangerZoneSettings,
|
||||
@ -292,3 +301,288 @@ class _SettingsSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthFactorSheet extends HookConsumerWidget {
|
||||
final SnAuthFactor factor;
|
||||
const _AuthFactorSheet({required this.factor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Future<void> deleteFactor() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authFactorDeleteHint'.tr(),
|
||||
'authFactorDelete'.tr(),
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/accounts/me/factors/${factor.id}');
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableFactor() async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authFactorDisableHint'.tr(),
|
||||
'authFactorDisable'.tr(),
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post('/accounts/me/factors/${factor.id}/disable');
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enableFactor() async {
|
||||
final passwordController = TextEditingController();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('authFactorEnable').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('authFactorEnableHint').tr(),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('cancel').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('confirm').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final password = passwordController.text;
|
||||
if (confirmed == false || password.isEmpty || !context.mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
passwordController.dispose();
|
||||
});
|
||||
}
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/accounts/me/factors/${factor.id}/enable',
|
||||
data: jsonEncode(password),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
passwordController.dispose();
|
||||
});
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'authFactor'.tr(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(kFactorTypes[factor.type]!.$1).tr(),
|
||||
subtitle: Text(kFactorTypes[factor.type]!.$2).tr(),
|
||||
leading: Icon(kFactorTypes[factor.type]!.$3),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (factor.enabledAt != null)
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.disabled_by_default),
|
||||
title: Text('authFactorDisable').tr(),
|
||||
onTap: disableFactor,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.check_circle),
|
||||
title: Text('authFactorEnable').tr(),
|
||||
onTap: enableFactor,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.delete),
|
||||
title: Text('authFactorDelete').tr(),
|
||||
onTap: deleteFactor,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthFactorNewSheet extends HookConsumerWidget {
|
||||
const _AuthFactorNewSheet();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final factorType = useState<int>(0);
|
||||
final secretController = useTextEditingController();
|
||||
|
||||
Future<void> addFactor() async {
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final resp = await apiClient.post(
|
||||
'/accounts/me/factors',
|
||||
data: {'type': factorType.value, 'secret': secretController.text},
|
||||
);
|
||||
final factor = SnAuthFactor.fromJson(resp.data);
|
||||
if (!context.mounted) return;
|
||||
if (factor.type == 3) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _AuthFactorNewAdditonalSheet(factor: factor),
|
||||
).then((_) {
|
||||
if (context.mounted) Navigator.pop(context, true);
|
||||
});
|
||||
} else {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'authFactorNew'.tr(),
|
||||
child: Column(
|
||||
spacing: 16,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<int>(
|
||||
value: factorType.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'authFactor'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items:
|
||||
kFactorTypes.entries.map((entry) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: entry.key,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(entry.value.$3),
|
||||
const Gap(8),
|
||||
Text(entry.value.$1).tr(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
factorType.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
if (factorType.value == 0)
|
||||
TextField(
|
||||
controller: secretController,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Symbols.password_2),
|
||||
labelText: 'authFactorSecret'.tr(),
|
||||
hintText: 'authFactorSecretHint'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onTapOutside:
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(kFactorTypes[factorType.value]!.$2).tr(),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: addFactor,
|
||||
icon: Icon(Symbols.add),
|
||||
label: Text('create').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 24),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AuthFactorNewAdditonalSheet extends StatelessWidget {
|
||||
final SnAuthFactor factor;
|
||||
const _AuthFactorNewAdditonalSheet({required this.factor});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uri = factor.createdResponse?['uri'];
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'authFactorAdditional'.tr(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (uri != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: QrImageView(
|
||||
data: uri,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'authFactorQrCodeScan'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'authFactorNoQrCode'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
const Gap(16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Symbols.check),
|
||||
label: Text('next'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
29
lib/screens/account/me/settings.g.dart
Normal file
29
lib/screens/account/me/settings.g.dart
Normal file
@ -0,0 +1,29 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$authFactorsHash() => r'4bb65bc0c065c4091c209ee81e57ddef41051ae2';
|
||||
|
||||
/// See also [authFactors].
|
||||
@ProviderFor(authFactors)
|
||||
final authFactorsProvider =
|
||||
AutoDisposeFutureProvider<List<SnAuthFactor>>.internal(
|
||||
authFactors,
|
||||
name: r'authFactorsProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$authFactorsHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef AuthFactorsRef = AutoDisposeFutureProviderRef<List<SnAuthFactor>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
@ -24,12 +24,12 @@ import 'captcha.dart';
|
||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||
2: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||
3: (
|
||||
2: (
|
||||
'authFactorInAppNotify',
|
||||
'authFactorInAppNotifyDescription',
|
||||
Symbols.notifications_active,
|
||||
),
|
||||
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||
};
|
||||
|
||||
@RoutePage()
|
||||
|
@ -24,6 +24,7 @@ import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/realms/selection_dropdown.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@ -714,42 +715,17 @@ class _ChatInvitesSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'invites'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
return SheetScaffold(
|
||||
titleText: 'invites'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.refresh),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
onPressed: () {
|
||||
ref.invalidate(chatroomInvitesProvider);
|
||||
ref.invalidate(realmInvitesProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: invites.when(
|
||||
data:
|
||||
(items) =>
|
||||
@ -786,13 +762,9 @@ class _ChatInvitesSheet extends HookConsumerWidget {
|
||||
Badge(
|
||||
label: Text('directMessage').tr(),
|
||||
backgroundColor:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
textColor:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -815,9 +787,6 @@ class _ChatInvitesSheet extends HookConsumerWidget {
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -416,6 +416,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
final compactHeader = isWideScreen(context);
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||
@ -541,11 +543,19 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse:
|
||||
true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final valueKey = key as ValueKey;
|
||||
final messageId = valueKey.value as String;
|
||||
return messageList.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage =
|
||||
@ -602,6 +612,18 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
message.toRemoteMessage();
|
||||
}
|
||||
},
|
||||
onJump: (messageId) {
|
||||
final messageIndex = messageList
|
||||
.indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
listController.jumpToItem(
|
||||
index: messageIndex,
|
||||
scrollController:
|
||||
scrollController,
|
||||
alignment: 0.5,
|
||||
);
|
||||
},
|
||||
progress:
|
||||
attachmentProgress.value[message
|
||||
.id],
|
||||
@ -614,6 +636,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
onJump: (_) {},
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
@ -148,7 +148,7 @@ class _StickerPackProviderElement
|
||||
}
|
||||
|
||||
String _$stickerPacksNotifierHash() =>
|
||||
r'2feff50a7896eb8759fe91e9626b0409354d9fee';
|
||||
r'dc0cc4ec27fdd6d5da28f982ff10c852f8107a18';
|
||||
|
||||
abstract class _$StickerPacksNotifier
|
||||
extends BuildlessAutoDisposeAsyncNotifier<CursorPagingData<SnStickerPack>> {
|
||||
|
@ -16,6 +16,7 @@ import 'package:island/services/file.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@ -397,25 +398,9 @@ class _RealmInviteSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'invites'.tr(),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
return SheetScaffold(
|
||||
titleText: 'invites'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.refresh),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
@ -423,16 +408,7 @@ class _RealmInviteSheet extends HookConsumerWidget {
|
||||
ref.invalidate(realmInvitesProvider);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: invites.when(
|
||||
data:
|
||||
(items) =>
|
||||
@ -486,9 +462,6 @@ class _RealmInviteSheet extends HookConsumerWidget {
|
||||
onRetry: () => ref.invalidate(realmInvitesProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
@ -27,6 +31,7 @@ class MessageItem extends HookConsumerWidget {
|
||||
final Function(String action)? onAction;
|
||||
final Map<int, double>? progress;
|
||||
final bool showAvatar;
|
||||
final Function(String messageId) onJump;
|
||||
|
||||
const MessageItem({
|
||||
super.key,
|
||||
@ -35,6 +40,7 @@ class MessageItem extends HookConsumerWidget {
|
||||
required this.onAction,
|
||||
required this.progress,
|
||||
required this.showAvatar,
|
||||
required this.onJump,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -54,6 +60,8 @@ class MessageItem extends HookConsumerWidget {
|
||||
final remoteMessage = message.toRemoteMessage();
|
||||
final sender = remoteMessage.sender;
|
||||
|
||||
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
return ContextMenuWidget(
|
||||
menuProvider: (_) {
|
||||
if (onAction == null) return Menu(children: []);
|
||||
@ -90,6 +98,17 @@ class MessageItem extends HookConsumerWidget {
|
||||
onAction!.call(MessageItemAction.forward);
|
||||
},
|
||||
),
|
||||
if (isMobile) MenuSeparator(),
|
||||
if (isMobile)
|
||||
MenuAction(
|
||||
title: 'copyMessage'.tr(),
|
||||
image: MenuImage.icon(Symbols.copy_all),
|
||||
callback: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: remoteMessage.content ?? ''),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -355,6 +374,19 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
||||
if (remoteMessage != null) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final messageId =
|
||||
isReply
|
||||
? message.toRemoteMessage().repliedMessageId!
|
||||
: message.toRemoteMessage().forwardedMessageId!;
|
||||
// Find the nearest MessageItem ancestor and call its onJump method
|
||||
final MessageItem? ancestor =
|
||||
context.findAncestorWidgetOfExactType<MessageItem>();
|
||||
if (ancestor != null) {
|
||||
ancestor.onJump(messageId);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6),
|
||||
color: Theme.of(
|
||||
@ -370,7 +402,7 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(Symbols.reply, size: 16, color: textColor),
|
||||
Text(
|
||||
'Replying to ${remoteMessage.sender.account.nick}',
|
||||
'${'repliedTo'.tr()} ${remoteMessage.sender.account.nick}',
|
||||
).textColor(textColor).bold(),
|
||||
],
|
||||
).padding(right: 8)
|
||||
@ -381,13 +413,28 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(Symbols.forward, size: 16, color: textColor),
|
||||
Text(
|
||||
'Forwarded from ${remoteMessage.sender.account.nick}',
|
||||
'${'forwarded'.tr()} ${remoteMessage.sender.account.nick}',
|
||||
).textColor(textColor).bold(),
|
||||
],
|
||||
).padding(right: 8),
|
||||
if (_MessageItemContent.hasContent(remoteMessage))
|
||||
_MessageItemContent(item: remoteMessage),
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.attach_file, size: 12, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'hasAttachments'.plural(
|
||||
remoteMessage.attachments.length,
|
||||
),
|
||||
style: TextStyle(color: textColor, fontSize: 12),
|
||||
),
|
||||
],
|
||||
).padding(vertical: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
).padding(bottom: 4);
|
||||
|
@ -20,6 +20,9 @@ String _parseRemoteError(DioException err) {
|
||||
}
|
||||
|
||||
void showErrorAlert(dynamic err) async {
|
||||
if (err is Error) {
|
||||
log('${err.stackTrace}');
|
||||
}
|
||||
final text = switch (err) {
|
||||
String _ => err,
|
||||
DioException _ => _parseRemoteError(err),
|
||||
|
60
lib/widgets/content/sheet.dart
Normal file
60
lib/widgets/content/sheet.dart
Normal file
@ -0,0 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class SheetScaffold extends StatelessWidget {
|
||||
final Widget? title;
|
||||
final String? titleText;
|
||||
final List<Widget> actions;
|
||||
final Widget child;
|
||||
final double heightFactor;
|
||||
const SheetScaffold({
|
||||
super.key,
|
||||
this.title,
|
||||
this.titleText,
|
||||
required this.child,
|
||||
this.actions = const [],
|
||||
this.heightFactor = 0.8,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(title != null || titleText != null);
|
||||
|
||||
var titleWidget =
|
||||
title ??
|
||||
Text(
|
||||
titleText!,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * heightFactor,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
titleWidget,
|
||||
const Spacer(),
|
||||
...actions,
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class TechicalReviewIntroWidget extends StatelessWidget {
|
||||
@ -8,34 +8,8 @@ class TechicalReviewIntroWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'技术性预览',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
return SheetScaffold(
|
||||
titleText: '技术性预览',
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@ -55,9 +29,6 @@ class TechicalReviewIntroWidget extends StatelessWidget {
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Solian</string>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
16
pubspec.lock
16
pubspec.lock
@ -1550,6 +1550,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: qr
|
||||
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
qr_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: qr_flutter
|
||||
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -106,6 +106,7 @@ dependencies:
|
||||
pasteboard: ^0.4.0
|
||||
flutter_colorpicker: ^1.1.0
|
||||
record: ^6.0.0
|
||||
qr_flutter: ^4.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
Loading…
x
Reference in New Issue
Block a user