diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index aba0727..5b8d0e5 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -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" } diff --git a/lib/database/message_repository.dart b/lib/database/message_repository.dart index 43f8426..c75e640 100644 --- a/lib/database/message_repository.dart +++ b/lib/database/message_repository.dart @@ -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) { diff --git a/lib/models/auth.dart b/lib/models/auth.dart index b2078d4..f5ae09d 100644 --- a/lib/models/auth.dart +++ b/lib/models/auth.dart @@ -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? createdResponse, }) = _SnAuthFactor; factory SnAuthFactor.fromJson(Map json) => diff --git a/lib/models/auth.freezed.dart b/lib/models/auth.freezed.dart index e44a1ba..c4bab8d 100644 --- a/lib/models/auth.freezed.dart +++ b/lib/models/auth.freezed.dart @@ -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? 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 get copyWith => _$SnAuthFactorCopyWithImpl 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? 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?, )); } @@ -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? createdResponse}): _createdResponse = createdResponse; factory _SnAuthFactor.fromJson(Map 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? _createdResponse; +@override Map? 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 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? 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?, )); } diff --git a/lib/models/auth.g.dart b/lib/models/auth.g.dart index e407493..41423e0 100644 --- a/lib/models/auth.g.dart +++ b/lib/models/auth.g.dart @@ -67,6 +67,16 @@ _SnAuthFactor _$SnAuthFactorFromJson(Map 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?, ); Map _$SnAuthFactorToJson(_SnAuthFactor instance) => @@ -76,4 +86,8 @@ Map _$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, }; diff --git a/lib/models/chat.dart b/lib/models/chat.dart index 026d7a7..b339e92 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -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'; diff --git a/lib/screens/account/me/settings.dart b/lib/screens/account/me/settings.dart index 0948edb..9e1e870 100644 --- a/lib/screens/account/me/settings.dart +++ b/lib/screens/account/me/settings.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> authFactors(Ref ref) async { + final client = ref.read(apiClientProvider); + final res = await client.get('/accounts/me/factors'); + return res.data.map((e) => SnAuthFactor.fromJson(e)).toList(); +} + @RoutePage() class AccountSettingsScreen extends HookConsumerWidget { const AccountSettingsScreen({super.key}); @@ -45,7 +63,7 @@ class AccountSettingsScreen extends HookConsumerWidget { Future 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 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 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 enableFactor() async { + final passwordController = TextEditingController(); + final confirmed = await showDialog( + 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(0); + final secretController = useTextEditingController(); + + Future 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( + value: factorType.value, + decoration: InputDecoration( + labelText: 'authFactor'.tr(), + border: const OutlineInputBorder(), + ), + items: + kFactorTypes.entries.map((entry) { + return DropdownMenuItem( + 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()), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/account/me/settings.g.dart b/lib/screens/account/me/settings.g.dart new file mode 100644 index 0000000..912f6be --- /dev/null +++ b/lib/screens/account/me/settings.g.dart @@ -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>.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>; +// 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 diff --git a/lib/screens/auth/login.dart b/lib/screens/auth/login.dart index 868ad6f..b73f08b 100644 --- a/lib/screens/auth/login.dart +++ b/lib/screens/auth/login.dart @@ -24,12 +24,12 @@ import 'captcha.dart'; final Map 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() diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 0002927..cc33f54 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -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')), ), ); } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index aa157fa..599e361 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -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(), ); diff --git a/lib/screens/creators/stickers/stickers.g.dart b/lib/screens/creators/stickers/stickers.g.dart index 583b25e..69bbf9b 100644 --- a/lib/screens/creators/stickers/stickers.g.dart +++ b/lib/screens/creators/stickers/stickers.g.dart @@ -148,7 +148,7 @@ class _StickerPackProviderElement } String _$stickerPacksNotifierHash() => - r'2feff50a7896eb8759fe91e9626b0409354d9fee'; + r'dc0cc4ec27fdd6d5da28f982ff10c852f8107a18'; abstract class _$StickerPacksNotifier extends BuildlessAutoDisposeAsyncNotifier> { diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart index 0fd069b..fc55a6c 100644 --- a/lib/screens/realm/realms.dart +++ b/lib/screens/realm/realms.dart @@ -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), ), - ), - ], ), ); } diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 384f28d..f500fe0 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -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? 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(); + 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); diff --git a/lib/widgets/content/alert.native.dart b/lib/widgets/content/alert.native.dart index 8b29b56..8bf0008 100644 --- a/lib/widgets/content/alert.native.dart +++ b/lib/widgets/content/alert.native.dart @@ -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), diff --git a/lib/widgets/content/sheet.dart b/lib/widgets/content/sheet.dart new file mode 100644 index 0000000..2902239 --- /dev/null +++ b/lib/widgets/content/sheet.dart @@ -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 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), + ], + ), + ); + } +} diff --git a/lib/widgets/tour/techincal_review_intro.dart b/lib/widgets/tour/techincal_review_intro.dart index 22bc67f..b665ae7 100644 --- a/lib/widgets/tour/techincal_review_intro.dart +++ b/lib/widgets/tour/techincal_review_intro.dart @@ -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), ), ); } diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 30949e5..ea79168 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable - Solian + $(EXECUTABLE_NAME) CFBundleIconFile CFBundleIdentifier diff --git a/pubspec.lock b/pubspec.lock index 90bf095..8054416 100644 --- a/pubspec.lock +++ b/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: diff --git a/pubspec.yaml b/pubspec.yaml index 5b33dba..042f37d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: