Auth factor settings

This commit is contained in:
2025-06-06 01:21:22 +08:00
parent accb99bb24
commit bf77bfce64
20 changed files with 826 additions and 383 deletions

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:island/database/drift_db.dart';
import 'package:island/database/message.dart';
@ -45,6 +48,13 @@ class MessageRepository {
},
);
for (final item in resp.data['changes']) {
if (item['message']['sender']['account'] == null)
log(jsonEncode(item['message']['sender']['account']));
// if (item['message'] != null &&
// item['message']['sender']['account'] == null) {
// }
}
final response = MessageSyncResponse.fromJson(resp.data);
for (final change in response.changes) {
switch (change.action) {

View File

@ -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) =>

View File

@ -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>?,
));
}

View File

@ -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,
};

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/file.dart';
import 'package:island/models/realm.dart';

View File

@ -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 = [
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
ExpansionTile(
leading: const Icon(
Symbols.security,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountAuthFactor').tr(),
subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
authFactors.when(
data:
(factors) => Column(
children: [
for (final factor in factors)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
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: () {
if (factor.type == 0) {
requestResetPassword();
return;
}
showModalBottomSheet(
context: context,
builder:
(context) => _AuthFactorSheet(factor: factor),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
),
if (factors.isNotEmpty) Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
title: Text('authFactorNew').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => const _AuthFactorNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
child: Text('accountTwoFactorSetup').tr(),
),
],
),
);
},
),
];
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());
},
error:
(err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(authFactorsProvider),
),
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()),
),
),
],
),
);
}
}

View 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

View File

@ -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()

View File

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

View File

@ -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(),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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