feat: 实现密码重置功能与用户搜索API,优化注册登录流程

- 新增忘记密码页面与重置密码确认流程(前端+后端)
- 修复注册验证码页登录跳转路由
- 新增用户搜索API(按邮箱查询)
- 简化infra脚本,统一为app.sh
- 补充密码重置与用户API测试覆盖
- 更新runtime文档与AGENTS配置
This commit is contained in:
qzl
2026-02-27 15:22:42 +08:00
parent 0d4811fee5
commit e4e995854d
37 changed files with 2101 additions and 222 deletions
+5
View File
@@ -5,6 +5,7 @@ import 'go_router_refresh_stream.dart';
import '../../features/auth/ui/screens/login_screen.dart';
import '../../features/auth/ui/screens/register_screen.dart';
import '../../features/auth/ui/screens/register_verification_screen.dart';
import '../../features/auth/ui/screens/reset_password_screen.dart';
import '../../features/home/ui/screens/home_screen.dart';
import '../../features/messages/ui/screens/message_invite_list_screen.dart';
import '../../features/messages/ui/screens/message_invite_detail_screen.dart';
@@ -67,6 +68,10 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: '/register/verification',
builder: (context, state) => const RegisterVerificationScreen(),
),
GoRoute(
path: '/reset-password',
builder: (context, state) => const ResetPasswordScreen(),
),
GoRoute(path: '/home', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/messages/invites',
+15
View File
@@ -50,4 +50,19 @@ class AuthApi {
Future<void> deleteSession(LogoutRequest request) async {
await _client.delete('$_prefix/sessions', data: request.toJson());
}
Future<void> requestPasswordReset(String email) async {
await _client.post('$_prefix/password-reset', data: {'email': email});
}
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
}) async {
await _client.post(
'$_prefix/password-reset/confirm',
data: {'email': email, 'token': token, 'new_password': newPassword},
);
}
}
@@ -14,4 +14,10 @@ abstract class AuthRepository {
Future<String?> getAccessToken();
Future<String?> getRefreshToken();
Future<bool> isAuthenticated();
Future<void> requestPasswordReset(String email);
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
});
}
@@ -77,4 +77,22 @@ class AuthRepositoryImpl implements AuthRepository {
final token = await _tokenStorage.getAccessToken();
return token != null;
}
@override
Future<void> requestPasswordReset(String email) {
return _api.requestPasswordReset(email);
}
@override
Future<void> confirmPasswordReset({
required String email,
required String token,
required String newPassword,
}) {
return _api.confirmPasswordReset(
email: email,
token: token,
newPassword: newPassword,
);
}
}
@@ -2,17 +2,20 @@ class SignupStartRequest {
final String username;
final String email;
final String password;
final String? inviteCode;
const SignupStartRequest({
required this.username,
required this.email,
required this.password,
this.inviteCode,
});
Map<String, dynamic> toJson() => {
'username': username,
'email': email,
'password': password,
if (inviteCode != null) 'invite_code': inviteCode,
};
}
@@ -12,6 +12,7 @@ class RegisterState extends Equatable {
final Email email;
final Password password;
final VerificationCode verificationCode;
final String inviteCode;
final FormzSubmissionStatus status;
final String? errorMessage;
final String? pendingEmail;
@@ -23,6 +24,7 @@ class RegisterState extends Equatable {
this.email = const Email.pure(),
this.password = const Password.pure(),
this.verificationCode = const VerificationCode.pure(),
this.inviteCode = '',
this.status = FormzSubmissionStatus.initial,
this.errorMessage,
this.pendingEmail,
@@ -39,6 +41,7 @@ class RegisterState extends Equatable {
Email? email,
Password? password,
VerificationCode? verificationCode,
String? inviteCode,
FormzSubmissionStatus? status,
String? errorMessage,
String? pendingEmail,
@@ -50,6 +53,7 @@ class RegisterState extends Equatable {
email: email ?? this.email,
password: password ?? this.password,
verificationCode: verificationCode ?? this.verificationCode,
inviteCode: inviteCode ?? this.inviteCode,
status: status ?? this.status,
errorMessage: errorMessage,
pendingEmail: pendingEmail ?? this.pendingEmail,
@@ -64,6 +68,7 @@ class RegisterState extends Equatable {
email,
password,
verificationCode,
inviteCode,
status,
errorMessage,
pendingEmail,
@@ -93,6 +98,10 @@ class RegisterCubit extends Cubit<RegisterState> {
emit(state.copyWith(verificationCode: VerificationCode.dirty(value)));
}
void inviteCodeChanged(String value) {
emit(state.copyWith(inviteCode: value));
}
Future<bool> submitStep1() async {
if (!state.isStep1Valid) return false;
@@ -104,6 +113,7 @@ class RegisterCubit extends Cubit<RegisterState> {
username: state.username.value,
email: state.email.value,
password: state.password.value,
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
),
);
emit(
@@ -202,6 +212,7 @@ class RegisterCubit extends Cubit<RegisterState> {
username: state.username.value,
email: state.email.value,
password: state.password.value,
inviteCode: state.inviteCode.isNotEmpty ? state.inviteCode : null,
),
);
emit(
@@ -0,0 +1,314 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:formz/formz.dart';
import '../../../../core/form_inputs/form_inputs.dart';
import '../../data/auth_repository.dart';
class ResetPasswordState extends Equatable {
final Email email;
final VerificationCode code;
final Password newPassword;
final Password confirmPassword;
final FormzSubmissionStatus status;
final String? errorMessage;
final bool isSuccess;
final int resendCountdown;
final bool codeSent;
const ResetPasswordState({
this.email = const Email.pure(),
this.code = const VerificationCode.pure(),
this.newPassword = const Password.pure(),
this.confirmPassword = const Password.pure(),
this.status = FormzSubmissionStatus.initial,
this.errorMessage,
this.isSuccess = false,
this.resendCountdown = 0,
this.codeSent = false,
});
bool get canSubmit {
if (!codeSent) {
return email.isValid && status != FormzSubmissionStatus.inProgress;
}
return email.isValid &&
code.isValid &&
newPassword.isValid &&
confirmPassword.isValid &&
newPassword.value == confirmPassword.value &&
status != FormzSubmissionStatus.inProgress;
}
ResetPasswordState copyWith({
Email? email,
VerificationCode? code,
Password? newPassword,
Password? confirmPassword,
FormzSubmissionStatus? status,
String? errorMessage,
bool? isSuccess,
int? resendCountdown,
bool? codeSent,
}) {
return ResetPasswordState(
email: email ?? this.email,
code: code ?? this.code,
newPassword: newPassword ?? this.newPassword,
confirmPassword: confirmPassword ?? this.confirmPassword,
status: status ?? this.status,
errorMessage: errorMessage,
isSuccess: isSuccess ?? this.isSuccess,
resendCountdown: resendCountdown ?? this.resendCountdown,
codeSent: codeSent ?? this.codeSent,
);
}
@override
List<Object?> get props => [
email,
code,
newPassword,
confirmPassword,
status,
errorMessage,
isSuccess,
resendCountdown,
codeSent,
];
}
class ResetPasswordCubit extends Cubit<ResetPasswordState> {
final AuthRepository _repository;
Timer? _resendTimer;
ResetPasswordCubit(this._repository) : super(const ResetPasswordState());
@override
Future<void> close() {
_resendTimer?.cancel();
return super.close();
}
void emailChanged(String value) {
emit(state.copyWith(email: Email.dirty(value), errorMessage: null));
}
void codeChanged(String value) {
emit(
state.copyWith(code: VerificationCode.dirty(value), errorMessage: null),
);
}
void newPasswordChanged(String value) {
emit(
state.copyWith(newPassword: Password.dirty(value), errorMessage: null),
);
}
void confirmPasswordChanged(String value) {
emit(
state.copyWith(
confirmPassword: Password.dirty(value),
errorMessage: null,
),
);
}
Future<void> sendCode() async {
if (state.status == FormzSubmissionStatus.inProgress ||
state.resendCountdown > 0) {
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
codeSent: true,
resendCountdown: 60,
errorMessage: null,
),
);
_startResendCountdown();
try {
await _repository.requestPasswordReset(state.email.value);
emit(
state.copyWith(
status: FormzSubmissionStatus.success,
errorMessage: 'CODE_SENT_SUCCESS',
),
);
} catch (e) {
_cancelResendCountdown();
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
codeSent: false,
resendCountdown: 0,
errorMessage: '网络错误,请稍后重试',
),
);
}
}
void _cancelResendCountdown() {
_resendTimer?.cancel();
}
void _startResendCountdown() {
_cancelResendCountdown();
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
final newCountdown = state.resendCountdown - 1;
if (newCountdown <= 0) {
timer.cancel();
emit(state.copyWith(resendCountdown: 0));
} else {
emit(state.copyWith(resendCountdown: newCountdown));
}
});
}
Future<void> resendCode() async {
if (state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress) {
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: state.email.value.isEmpty ? '请输入邮箱' : '邮箱格式不正确',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
codeSent: true,
resendCountdown: 60,
errorMessage: null,
),
);
_startResendCountdown();
try {
await _repository.requestPasswordReset(state.email.value);
emit(
state.copyWith(
status: FormzSubmissionStatus.success,
errorMessage: 'CODE_SENT_SUCCESS',
),
);
} catch (e) {
_cancelResendCountdown();
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
resendCountdown: 0,
errorMessage: '网络错误,请稍后重试',
),
);
}
}
Future<void> submit() async {
if (!state.codeSent) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请先获取验证码',
),
);
return;
}
if (!state.email.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入有效的邮箱地址',
),
);
return;
}
if (!state.code.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入6位验证码',
),
);
return;
}
if (!state.newPassword.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '新密码至少6位',
),
);
return;
}
if (!state.confirmPassword.isValid) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '请输入确认密码',
),
);
return;
}
if (state.newPassword.value != state.confirmPassword.value) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '两次密码输入不一致',
),
);
return;
}
emit(
state.copyWith(
status: FormzSubmissionStatus.inProgress,
errorMessage: null,
),
);
try {
await _repository.confirmPasswordReset(
email: state.email.value,
token: state.code.value,
newPassword: state.newPassword.value,
);
emit(
state.copyWith(status: FormzSubmissionStatus.success, isSuccess: true),
);
} catch (e) {
emit(
state.copyWith(
status: FormzSubmissionStatus.failure,
errorMessage: '密码重置失败,请检查验证码',
),
);
}
}
}
@@ -162,6 +162,8 @@ class _LoginViewState extends State<LoginView> {
? null
: _handleLogin,
),
const SizedBox(height: 12),
_buildForgotPassword(),
],
),
);
@@ -236,6 +238,20 @@ class _LoginViewState extends State<LoginView> {
);
}
Widget _buildForgotPassword() {
return GestureDetector(
onTap: () => context.push('/reset-password'),
child: const Text(
'忘记密码?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
);
}
Widget _buildFooter() {
return GestureDetector(
onTap: () => context.push('/register'),
@@ -36,6 +36,7 @@ class _RegisterViewState extends State<RegisterView> {
final _nicknameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _inviteCodeController = TextEditingController();
bool _obscureText = true;
@override
@@ -43,6 +44,7 @@ class _RegisterViewState extends State<RegisterView> {
_nicknameController.dispose();
_emailController.dispose();
_passwordController.dispose();
_inviteCodeController.dispose();
super.dispose();
}
@@ -51,6 +53,7 @@ class _RegisterViewState extends State<RegisterView> {
cubit.usernameChanged(_nicknameController.text);
cubit.emailChanged(_emailController.text);
cubit.passwordChanged(_passwordController.text);
cubit.inviteCodeChanged(_inviteCodeController.text);
if (!cubit.state.isStep1Valid || cubit.state.isSending) {
String? errorMsg;
@@ -159,6 +162,8 @@ class _RegisterViewState extends State<RegisterView> {
const SizedBox(height: 12),
_buildPasswordInput(),
const SizedBox(height: 12),
_buildInput('邀请码(选填)', '请输入邀请码', _inviteCodeController),
const SizedBox(height: 12),
_buildStepIndicator(),
if (state.errorMessage != null)
Padding(
@@ -48,10 +48,22 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
Timer? _countdownTimer;
int _countdown = 0;
bool _firstSendCompleted = false;
bool _hintShown = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_hintShown) {
_hintShown = true;
Toast.show(
context,
'验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册',
type: ToastType.info,
duration: const Duration(seconds: 5),
);
}
});
}
@override
@@ -331,7 +343,7 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
Widget _buildFooter() {
return GestureDetector(
onTap: () => context.pop(),
onTap: () => context.go('/'),
child: const Text(
'已有账号?去登录',
style: TextStyle(
@@ -0,0 +1,355 @@
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/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../presentation/cubits/reset_password_cubit.dart';
import '../../data/auth_repository.dart';
class ResetPasswordScreen extends StatelessWidget {
const ResetPasswordScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ResetPasswordCubit(sl<AuthRepository>()),
child: const ResetPasswordView(),
);
}
}
class ResetPasswordView extends StatefulWidget {
const ResetPasswordView({super.key});
@override
State<ResetPasswordView> createState() => _ResetPasswordViewState();
}
class _ResetPasswordViewState extends State<ResetPasswordView> {
final _emailController = TextEditingController();
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_emailController.dispose();
_codeController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _handleSubmit() async {
final cubit = context.read<ResetPasswordCubit>();
cubit.emailChanged(_emailController.text);
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.go('/');
} 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: Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildTitle(),
const SizedBox(height: 32),
_buildFormContainer(),
],
),
),
),
],
),
),
),
),
);
}
Widget _buildTitle() {
return const Text(
'忘记密码',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
);
}
Widget _buildFormContainer() {
return BlocBuilder<ResetPasswordCubit, ResetPasswordState>(
builder: (context, state) {
return SizedBox(
width: 327,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildEmailInput(state.email.displayError != null),
const SizedBox(height: 12),
_buildCodeInput(state.code.displayError != null, state),
const SizedBox(height: 12),
_buildPasswordInput(state.newPassword.displayError != null),
const SizedBox(height: 12),
_buildConfirmPasswordInput(
state.confirmPassword.displayError != null,
),
const SizedBox(height: 24),
_buildSubmitButton(state),
const SizedBox(height: 16),
_buildBackToLogin(),
],
),
);
},
);
}
Widget _buildEmailInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'邮箱',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
onChanged: (value) {
context.read<ResetPasswordCubit>().emailChanged(value);
},
decoration: InputDecoration(
hintText: '请输入邮箱',
errorText: hasError ? ' ' : null,
),
),
],
);
}
Widget _buildCodeInput(bool hasError, ResetPasswordState state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'验证码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: TextField(
controller: _codeController,
keyboardType: TextInputType.number,
onChanged: (value) {
context.read<ResetPasswordCubit>().codeChanged(value);
},
decoration: InputDecoration(
hintText: '请输入 6 位验证码',
errorText: hasError ? ' ' : null,
),
),
),
const SizedBox(width: 12),
SizedBox(
height: 40,
child: TextButton(
onPressed:
state.resendCountdown > 0 ||
state.status == FormzSubmissionStatus.inProgress
? null
: () {
if (state.codeSent) {
context.read<ResetPasswordCubit>().resendCode();
} else {
context.read<ResetPasswordCubit>().sendCode();
}
},
style: TextButton.styleFrom(
backgroundColor: state.codeSent
? AppColors.background
: AppColors.primary,
foregroundColor: state.codeSent
? AppColors.primary
: AppColors.primaryForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
padding: const EdgeInsets.symmetric(horizontal: 14),
),
child: Text(
state.resendCountdown > 0
? '${state.resendCountdown}'
: (state.codeSent ? '重新发送' : '发送验证码'),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
);
}
Widget _buildPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'新密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().newPasswordChanged(value);
},
decoration: InputDecoration(
hintText: '请输入新密码(至少 6 位)',
errorText: hasError ? ' ' : null,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword ? Icons.visibility_off : Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
),
],
);
}
Widget _buildConfirmPasswordInput(bool hasError) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'确认密码',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate600,
),
),
const SizedBox(height: 6),
TextField(
controller: _confirmPasswordController,
obscureText: _obscureConfirmPassword,
onChanged: (value) {
context.read<ResetPasswordCubit>().confirmPasswordChanged(value);
},
decoration: InputDecoration(
hintText: '请再次输入新密码',
errorText: hasError ? ' ' : null,
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_off
: Icons.visibility,
size: 20,
color: AppColors.slate400,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
),
],
);
}
Widget _buildSubmitButton(ResetPasswordState state) {
final isLoading = state.status == FormzSubmissionStatus.inProgress;
final isDisabled = isLoading || !state.codeSent;
return AppButton(
text: '重置密码',
onPressed: isDisabled ? null : _handleSubmit,
);
}
Widget _buildBackToLogin() {
return GestureDetector(
onTap: () => context.go('/'),
child: const Text(
'返回登录',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
textAlign: TextAlign.center,
),
);
}
}
+7 -3
View File
@@ -17,8 +17,12 @@ class UsersApi {
return UserResponse.fromJson(response.data);
}
Future<UserResponse> getByUsername(String username) async {
final response = await _client.get('$_prefix/$username');
return UserResponse.fromJson(response.data);
Future<List<UserResponse>> searchUsers(String query) async {
final response = await _client.post(
'$_prefix/search',
data: {'query': query},
);
final List<dynamic> data = response.data;
return data.map((json) => UserResponse.fromJson(json)).toList();
}
}
@@ -3,5 +3,5 @@ import 'models/user_response.dart';
abstract class UsersRepository {
Future<UserResponse> getMe();
Future<UserResponse> updateMe(UserUpdateRequest request);
Future<UserResponse> getByUsername(String username);
Future<List<UserResponse>> searchUsers(String query);
}
@@ -18,7 +18,7 @@ class UsersRepositoryImpl implements UsersRepository {
}
@override
Future<UserResponse> getByUsername(String username) {
return _api.getByUsername(username);
Future<List<UserResponse>> searchUsers(String query) {
return _api.searchUsers(query);
}
}