feat(apps): update UI screens and shared components

- Update home screen with new composer and interactions
- Update settings screens with new profile flow
- Update calendar share dialog
- Update contacts screen
- Add new shared widgets: confirm_sheet, phone_prefix_selector
- Add new formatters: phone_display_formatter
- Update tests for modified components
This commit is contained in:
qzl
2026-03-19 18:43:08 +08:00
parent f0af44d840
commit 8d4a14150b
24 changed files with 868 additions and 989 deletions
@@ -49,12 +49,6 @@ class AccountScreen extends StatelessWidget {
onTap: () => context.push('/edit-profile'),
),
_buildDivider(),
_buildMenuItem(
icon: Icons.lock,
title: '修改密码',
onTap: () => context.push('/change-password'),
),
_buildDivider(),
_buildMenuItem(
icon: Icons.logout,
title: '退出登录',
@@ -1,368 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../core/di/injection.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/fixed_length_code_input.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../auth/presentation/bloc/auth_bloc.dart';
import '../../../auth/presentation/bloc/auth_state.dart';
import '../../../../features/auth/presentation/cubits/reset_password_cubit.dart';
import '../../../../features/auth/data/auth_repository.dart';
import '../widgets/account_section_card.dart';
import '../widgets/settings_page_scaffold.dart';
class ChangePasswordScreen extends StatelessWidget {
const ChangePasswordScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
child: const _ChangePasswordView(),
);
}
}
class _ChangePasswordView extends StatefulWidget {
const _ChangePasswordView();
@override
State<_ChangePasswordView> createState() => __ChangePasswordViewState();
}
class __ChangePasswordViewState extends State<_ChangePasswordView> {
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
String _resolveUserEmail() {
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
return authState.user.email;
}
return '';
}
@override
void dispose() {
_codeController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
final email = _resolveUserEmail();
if (email.isEmpty) {
Toast.show(context, '未读取到登录邮箱,请重新登录后重试', type: ToastType.warning);
return;
}
final cubit = context.read<ResetPasswordCubit>();
cubit.emailChanged(email);
cubit.codeChanged(_codeController.text);
cubit.newPasswordChanged(_passwordController.text);
cubit.confirmPasswordChanged(_confirmPasswordController.text);
await cubit.submit();
}
@override
Widget build(BuildContext context) {
return BlocListener<ResetPasswordCubit, ResetPasswordState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.errorMessage != current.errorMessage ||
previous.codeSent != current.codeSent,
listener: (context, state) {
if (state.status == FormzSubmissionStatus.success && state.isSuccess) {
Toast.show(context, '密码修改成功', type: ToastType.success);
context.pop();
} else if (state.status == FormzSubmissionStatus.success &&
state.codeSent &&
state.errorMessage == 'CODE_SENT_SUCCESS') {
Toast.show(context, '验证码已发送到您的邮箱', type: ToastType.success);
} else if (state.status == FormzSubmissionStatus.failure &&
state.errorMessage != null &&
state.errorMessage != '' &&
state.errorMessage != 'CODE_SENT_SUCCESS') {
Toast.show(context, state.errorMessage!, type: ToastType.error);
}
},
child: SettingsPageScaffold(
title: '修改密码',
onBack: () => context.pop(),
body: _buildForm(),
footer: BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return _buildSubmitButton(state);
},
),
),
);
}
Widget _buildForm() {
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildEmailSection(state, _resolveUserEmail()),
const SizedBox(height: AppSpacing.lg),
_buildPasswordSection(
state,
state.newPassword.displayError != null,
state.confirmPassword.displayError != null,
),
],
);
},
);
}
Widget _buildEmailSection(ResetPasswordState state, String userEmail) {
return AccountSectionCard(
title: '发送验证码',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderTertiary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.email_outlined,
size: 20,
color: AppColors.blue600,
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Text(
userEmail.isEmpty ? '未读取到登录邮箱' : userEmail,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
AppButton(
text: state.resendCountdown > 0
? '${state.resendCountdown} 秒后可重发'
: (state.codeSent ? '重新发送验证码' : '发送验证码'),
onPressed:
state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress
? null
: () {
if (userEmail.isEmpty) {
Toast.show(
context,
'未读取到登录邮箱,请重新登录后重试',
type: ToastType.warning,
);
return;
}
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().emailChanged(
userEmail,
);
context.read<ResetPasswordCubit>().sendCode();
}
},
isOutlined: state.codeSent,
),
],
),
);
}
Widget _buildPasswordSection(
ResetPasswordState state,
bool passwordHasError,
bool confirmHasError,
) {
if (!state.codeSent) {
return const SizedBox.shrink();
}
return AccountSectionCard(
title: '设置新密码',
backgroundColor: AppColors.white,
borderColor: AppColors.borderSecondary,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
FixedLengthCodeInput(
controller: _codeController,
length: 6,
semanticLabel: '修改密码验证码输入框',
keyboardType: TextInputType.number,
allowedCharacters: const {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
},
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
),
const SizedBox(height: AppSpacing.lg),
_buildPasswordInput(passwordHasError),
const SizedBox(height: AppSpacing.lg),
_buildConfirmPasswordInput(confirmHasError),
],
),
);
}
Widget _buildPasswordInput(bool hasError) {
return _buildPasswordField(
label: '新密码',
controller: _passwordController,
hintText: '请输入新密码(至少 6 位)',
hasError: hasError,
isObscured: _obscurePassword,
onToggleVisibility: () =>
setState(() => _obscurePassword = !_obscurePassword),
onChanged: (value) =>
context.read<ResetPasswordCubit>().newPasswordChanged(value),
);
}
Widget _buildConfirmPasswordInput(bool hasError) {
return _buildPasswordField(
label: '确认密码',
controller: _confirmPasswordController,
hintText: '请再次输入新密码',
hasError: hasError,
isObscured: _obscureConfirmPassword,
onToggleVisibility: () =>
setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
onChanged: (value) =>
context.read<ResetPasswordCubit>().confirmPasswordChanged(value),
);
}
Widget _buildPasswordField({
required String label,
required TextEditingController controller,
required String hintText,
required bool hasError,
required bool isObscured,
required VoidCallback onToggleVisibility,
required ValueChanged<String> onChanged,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: controller,
obscureText: isObscured,
onChanged: onChanged,
decoration: InputDecoration(
hintText: hintText,
errorText: hasError ? ' ' : null,
filled: true,
fillColor: AppColors.surfaceSecondary,
hintStyle: const TextStyle(color: AppColors.slate400),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.lg,
),
suffixIcon: IconButton(
icon: Icon(
isObscured ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: onToggleVisibility,
),
border: _inputBorder,
enabledBorder: _enabledBorder,
focusedBorder: _focusedBorder,
),
),
],
);
}
static final _inputBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: BorderSide.none,
);
static final _enabledBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.borderTertiary),
);
static final _focusedBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
borderSide: const BorderSide(color: AppColors.blue500),
);
Widget _buildSubmitButton(ResetPasswordState state) {
final isLoading = state.status == FormzSubmissionStatus.inProgress;
final isDisabled = isLoading || !state.codeSent || !state.canSubmit;
return SizedBox(
width: double.infinity,
height: 52,
child: AppButton(
text: '确认修改',
onPressed: isDisabled ? null : _handleSubmit,
isLoading: isLoading,
),
);
}
}
@@ -122,6 +122,8 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
return SettingsPageScaffold(
title: '编辑资料',
onBack: () => context.pop(),
resizeOnKeyboard: false,
maintainBottomViewPadding: true,
body: _isLoading
? const Center(
child: AppLoadingIndicator(variant: AppLoadingVariant.surface),
@@ -6,6 +6,7 @@ import 'package:social_app/core/theme/design_tokens.dart';
import 'package:social_app/shared/widgets/app_loading_indicator.dart';
import 'package:social_app/shared/widgets/toast/toast.dart';
import 'package:social_app/shared/widgets/toast/toast_type.dart';
import 'package:social_app/shared/utils/phone_display_formatter.dart';
import 'package:social_app/features/friends/data/friends_api.dart';
import 'package:social_app/features/settings/data/settings_api.dart';
import 'package:social_app/features/users/data/models/user_response.dart';
@@ -98,7 +99,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
final username = _user?.username ?? '未设置';
final email = _user?.email ?? '未设置';
final phone = _user?.phone == null
? '未设置'
: formatPhoneForDisplay(_user?.phone);
return Container(
width: double.infinity,
@@ -195,7 +198,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
const SizedBox(height: 6),
Text(
email,
phone,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
@@ -10,18 +10,24 @@ class SettingsPageScaffold extends StatelessWidget {
required this.body,
this.footer,
this.onBack,
this.resizeOnKeyboard = true,
this.maintainBottomViewPadding = false,
});
final String title;
final Widget body;
final Widget? footer;
final VoidCallback? onBack;
final bool resizeOnKeyboard;
final bool maintainBottomViewPadding;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.surfaceSecondary,
resizeToAvoidBottomInset: resizeOnKeyboard,
body: SafeArea(
maintainBottomViewPadding: maintainBottomViewPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [