table alignment
This commit is contained in:
parent
f871af752b
commit
c462374125
@ -6,9 +6,12 @@ class ApiEndpoints {
|
|||||||
static const String logout = '/auth/logout';
|
static const String logout = '/auth/logout';
|
||||||
static const String refreshToken = '/auth/refresh';
|
static const String refreshToken = '/auth/refresh';
|
||||||
static const String forgotPassword = '/auth/forgot-password';
|
static const String forgotPassword = '/auth/forgot-password';
|
||||||
|
static const String resetPassword = '/auth/reset-password';
|
||||||
static const String changePassword = '/auth/change-password';
|
static const String changePassword = '/auth/change-password';
|
||||||
static const String verifyOtp = '/auth/verify-otp';
|
static const String verifyOtp = '/auth/verify-otp';
|
||||||
static const String me = '/auth/me';
|
static const String me = '/auth/me';
|
||||||
|
static const String updateProfile = '/auth/profile';
|
||||||
|
static const String profileAvatar = '/auth/profile/avatar';
|
||||||
|
|
||||||
// Companies
|
// Companies
|
||||||
static const String companies = '/companies';
|
static const String companies = '/companies';
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class AuthInterceptor extends Interceptor {
|
|||||||
ApiEndpoints.refreshToken,
|
ApiEndpoints.refreshToken,
|
||||||
ApiEndpoints.logout,
|
ApiEndpoints.logout,
|
||||||
ApiEndpoints.forgotPassword,
|
ApiEndpoints.forgotPassword,
|
||||||
|
ApiEndpoints.resetPassword,
|
||||||
ApiEndpoints.verifyOtp,
|
ApiEndpoints.verifyOtp,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import '../../../../core/constants/api_endpoints.dart';
|
|||||||
import '../../../../core/network/api_envelope.dart';
|
import '../../../../core/network/api_envelope.dart';
|
||||||
import '../../../../core/services/permission_matrix_api_parser.dart';
|
import '../../../../core/services/permission_matrix_api_parser.dart';
|
||||||
import '../../../../core/utils/jwt_utils.dart';
|
import '../../../../core/utils/jwt_utils.dart';
|
||||||
|
import '../../../../core/utils/media_url.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/user_model.dart';
|
import '../../../../shared/models/user_model.dart';
|
||||||
|
|
||||||
@ -80,6 +81,51 @@ class AuthRemoteDataSource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> resetPassword(ResetPasswordRequest request) async {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
ApiEndpoints.resetPassword,
|
||||||
|
data: request.toJson(),
|
||||||
|
);
|
||||||
|
ApiEnvelope.ensureSuccess(
|
||||||
|
response.data ?? const {},
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<UserModel> updateProfile(UpdateProfileRequest request) async {
|
||||||
|
final payload = request.toJson()..removeWhere((_, v) => v == null);
|
||||||
|
final response = await dio.put<Map<String, dynamic>>(
|
||||||
|
ApiEndpoints.updateProfile,
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
final data = ApiEnvelope.data(response);
|
||||||
|
return UserModel.fromLoginJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> uploadProfileAvatar({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'avatar': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
|
});
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
ApiEndpoints.profileAvatar,
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
final data = ApiEnvelope.data(response);
|
||||||
|
final url = resolveMediaUrl(
|
||||||
|
data['avatar_url'] as String? ??
|
||||||
|
data['avatarUrl'] as String? ??
|
||||||
|
data['url'] as String? ??
|
||||||
|
data['avatar_path'] as String?,
|
||||||
|
);
|
||||||
|
if (url == null || url.isEmpty) {
|
||||||
|
throw const FormatException('Avatar upload response missing avatar_url');
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
Future<LoginResponse> verifyOtp(OtpVerifyRequest request) async {
|
Future<LoginResponse> verifyOtp(OtpVerifyRequest request) async {
|
||||||
final response = await dio.post<Map<String, dynamic>>(
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
ApiEndpoints.verifyOtp,
|
ApiEndpoints.verifyOtp,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../core/network/token_storage.dart';
|
import '../../../../core/network/token_storage.dart';
|
||||||
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/user_model.dart';
|
import '../../../../shared/models/user_model.dart';
|
||||||
import '../../domain/repositories/auth_repository.dart';
|
import '../../domain/repositories/auth_repository.dart';
|
||||||
import '../datasources/auth_remote_data_source.dart';
|
import '../datasources/auth_remote_data_source.dart';
|
||||||
@ -79,6 +80,34 @@ class AuthRepositoryImpl implements AuthRepository {
|
|||||||
return safeApiCall(() => remote.forgotPassword(request));
|
return safeApiCall(() => remote.forgotPassword(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void>> resetPassword(ResetPasswordRequest request) async {
|
||||||
|
return safeApiCall(() => remote.resetPassword(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<UserModel>> updateProfile(UpdateProfileRequest request) async {
|
||||||
|
return safeApiCall(() async {
|
||||||
|
final updated = await remote.updateProfile(request);
|
||||||
|
// Prefer fresh /auth/me so avatar + role/department stay in sync.
|
||||||
|
try {
|
||||||
|
return await remote.getCurrentUser();
|
||||||
|
} catch (_) {
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<String>> uploadProfileAvatar({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) async {
|
||||||
|
return safeApiCall(
|
||||||
|
() => remote.uploadProfileAvatar(bytes: bytes, filename: filename),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Result<LoginResponse>> verifyOtp(OtpVerifyRequest request) async {
|
Future<Result<LoginResponse>> verifyOtp(OtpVerifyRequest request) async {
|
||||||
return safeApiCall(() async {
|
return safeApiCall(() async {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/user_model.dart';
|
import '../../../../shared/models/user_model.dart';
|
||||||
|
|
||||||
abstract class AuthRepository {
|
abstract class AuthRepository {
|
||||||
@ -7,6 +8,12 @@ abstract class AuthRepository {
|
|||||||
Future<Result<UserModel>> getCurrentUser();
|
Future<Result<UserModel>> getCurrentUser();
|
||||||
Future<Result<AuthTokens>> refreshSession();
|
Future<Result<AuthTokens>> refreshSession();
|
||||||
Future<Result<void>> forgotPassword(ForgotPasswordRequest request);
|
Future<Result<void>> forgotPassword(ForgotPasswordRequest request);
|
||||||
|
Future<Result<void>> resetPassword(ResetPasswordRequest request);
|
||||||
|
Future<Result<UserModel>> updateProfile(UpdateProfileRequest request);
|
||||||
|
Future<Result<String>> uploadProfileAvatar({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
});
|
||||||
Future<Result<LoginResponse>> verifyOtp(OtpVerifyRequest request);
|
Future<Result<LoginResponse>> verifyOtp(OtpVerifyRequest request);
|
||||||
Future<Result<void>> changePassword(ChangePasswordRequest request);
|
Future<Result<void>> changePassword(ChangePasswordRequest request);
|
||||||
Future<bool> isAuthenticated();
|
Future<bool> isAuthenticated();
|
||||||
|
|||||||
@ -1,23 +1,39 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import '../../../../core/constants/route_constants.dart';
|
||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
|
import '../../../../shared/models/user_model.dart';
|
||||||
|
import '../../../../shared/providers/auth_provider.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
|
import '../../data/repositories/auth_repository_impl.dart';
|
||||||
|
import '../widgets/login_colors.dart';
|
||||||
|
|
||||||
class ChangePasswordScreen extends StatefulWidget {
|
/// Change password for the signed-in user (`POST /auth/change-password`).
|
||||||
|
/// Styled to match the login / auth card design language.
|
||||||
|
class ChangePasswordScreen extends ConsumerStatefulWidget {
|
||||||
const ChangePasswordScreen({super.key});
|
const ChangePasswordScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChangePasswordScreen> createState() => _ChangePasswordScreenState();
|
ConsumerState<ChangePasswordScreen> createState() =>
|
||||||
|
_ChangePasswordScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _currentController = TextEditingController();
|
final _currentController = TextEditingController();
|
||||||
final _newController = TextEditingController();
|
final _newController = TextEditingController();
|
||||||
final _confirmController = TextEditingController();
|
final _confirmController = TextEditingController();
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _obscureCurrent = true;
|
||||||
|
bool _obscureNew = true;
|
||||||
|
bool _obscureConfirm = true;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_currentController.dispose();
|
_currentController.dispose();
|
||||||
@ -26,58 +42,476 @@ class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clearForm() {
|
||||||
|
_currentController.clear();
|
||||||
|
_newController.clear();
|
||||||
|
_confirmController.clear();
|
||||||
|
_formKey.currentState?.reset();
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = null;
|
||||||
|
_obscureCurrent = true;
|
||||||
|
_obscureNew = true;
|
||||||
|
_obscureConfirm = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final repository = ref.read(authRepositoryProvider);
|
||||||
|
final result = await repository.changePassword(
|
||||||
|
ChangePasswordRequest(
|
||||||
|
currentPassword: _currentController.text,
|
||||||
|
newPassword: _newController.text,
|
||||||
|
confirmPassword: _confirmController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
|
||||||
|
if (result.failure != null) {
|
||||||
|
setState(
|
||||||
|
() => _errorMessage =
|
||||||
|
result.failure?.message ?? result.failure.toString(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearForm();
|
||||||
|
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Password updated. Please sign in again.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// API revokes all refresh tokens — end session and return to login.
|
||||||
|
await ref.read(authStateProvider.notifier).logout();
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(RouteConstants.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _fieldDecoration({
|
||||||
|
required LoginColors colors,
|
||||||
|
required String label,
|
||||||
|
required IconData icon,
|
||||||
|
Widget? suffix,
|
||||||
|
}) {
|
||||||
|
final radius = BorderRadius.circular(12);
|
||||||
|
return InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||||
|
labelStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
floatingLabelStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.primary,
|
||||||
|
),
|
||||||
|
prefixIcon: Icon(icon, color: colors.iconMuted, size: 18),
|
||||||
|
suffixIcon: suffix,
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.fieldFillColor,
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(12, 18, 12, 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: radius,
|
||||||
|
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: radius,
|
||||||
|
borderSide: BorderSide(color: colors.outlineSoft, width: 1.5),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: radius,
|
||||||
|
borderSide: BorderSide(color: colors.primary, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: radius,
|
||||||
|
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: radius,
|
||||||
|
borderSide: BorderSide(color: colors.colorScheme.error, width: 1.5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _visibilityToggle({
|
||||||
|
required LoginColors colors,
|
||||||
|
required bool obscure,
|
||||||
|
required VoidCallback onPressed,
|
||||||
|
}) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: obscure ? 'Show password' : 'Hide password',
|
||||||
|
icon: Icon(
|
||||||
|
obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
||||||
|
color: colors.iconMuted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colors = LoginColors.of(context);
|
||||||
|
final canPop = context.canPop();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Change Password')),
|
backgroundColor: colors.bg,
|
||||||
body: Center(
|
body: Stack(
|
||||||
child: SingleChildScrollView(
|
children: [
|
||||||
padding: const EdgeInsets.all(24),
|
Positioned.fill(
|
||||||
child: ConstrainedBox(
|
child: DecoratedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
decoration: BoxDecoration(
|
||||||
child: Form(
|
gradient: LinearGradient(
|
||||||
key: _formKey,
|
begin: Alignment.topCenter,
|
||||||
child: Column(
|
end: Alignment.bottomCenter,
|
||||||
children: [
|
colors: [colors.bgGradA, colors.bg],
|
||||||
AppTextField(
|
),
|
||||||
controller: _currentController,
|
|
||||||
label: 'Current Password',
|
|
||||||
obscureText: true,
|
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Current Password'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _newController,
|
|
||||||
label: 'New Password',
|
|
||||||
obscureText: true,
|
|
||||||
validator: Validators.password,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _confirmController,
|
|
||||||
label: 'Confirm Password',
|
|
||||||
obscureText: true,
|
|
||||||
validator: (v) {
|
|
||||||
if (v != _newController.text) return 'Passwords do not match';
|
|
||||||
return Validators.password(v);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
AppButton(
|
|
||||||
label: 'Update Password',
|
|
||||||
onPressed: () {
|
|
||||||
if (_formKey.currentState!.validate()) {
|
|
||||||
showAppToastFromSnackBar(context,
|
|
||||||
const SnackBar(content: Text('Password updated (API pending)')),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -120,
|
||||||
|
right: -100,
|
||||||
|
child: _AmbientBlob(color: colors.primaryContainer, size: 420),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -140,
|
||||||
|
left: -120,
|
||||||
|
child: _AmbientBlob(color: colors.tertiaryContainer, size: 360),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (canPop)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () => context.pop(),
|
||||||
|
icon: Icon(
|
||||||
|
Icons.arrow_back_rounded,
|
||||||
|
color: colors.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 48),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Change Password',
|
||||||
|
style: GoogleFonts.manrope(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
vertical: 24,
|
||||||
|
),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(color: colors.outlineSoft),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.cardShadowColor,
|
||||||
|
blurRadius: 32,
|
||||||
|
offset: const Offset(0, 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(36, 40, 36, 32),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
colors.primary,
|
||||||
|
colors.primaryDim,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: colors.primary
|
||||||
|
.withValues(alpha: 0.28),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.lock_reset_rounded,
|
||||||
|
color: colors.onPrimary,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Update your password',
|
||||||
|
style: GoogleFonts.manrope(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
'Enter your current password and choose a new one. You’ll need to sign in again afterward.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13.5,
|
||||||
|
height: 1.45,
|
||||||
|
color: colors.subtitleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
TextFormField(
|
||||||
|
controller: _currentController,
|
||||||
|
obscureText: _obscureCurrent,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.password
|
||||||
|
],
|
||||||
|
validator: (v) => Validators.required(
|
||||||
|
v,
|
||||||
|
fieldName: 'Current password',
|
||||||
|
),
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
decoration: _fieldDecoration(
|
||||||
|
colors: colors,
|
||||||
|
label: 'Current password',
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
suffix: _visibilityToggle(
|
||||||
|
colors: colors,
|
||||||
|
obscure: _obscureCurrent,
|
||||||
|
onPressed: () => setState(
|
||||||
|
() =>
|
||||||
|
_obscureCurrent = !_obscureCurrent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
TextFormField(
|
||||||
|
controller: _newController,
|
||||||
|
obscureText: _obscureNew,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.newPassword
|
||||||
|
],
|
||||||
|
validator: Validators.password,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
decoration: _fieldDecoration(
|
||||||
|
colors: colors,
|
||||||
|
label: 'New password',
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
suffix: _visibilityToggle(
|
||||||
|
colors: colors,
|
||||||
|
obscure: _obscureNew,
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _obscureNew = !_obscureNew,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
TextFormField(
|
||||||
|
controller: _confirmController,
|
||||||
|
obscureText: _obscureConfirm,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.newPassword
|
||||||
|
],
|
||||||
|
validator: (v) {
|
||||||
|
if (v != _newController.text) {
|
||||||
|
return 'Passwords do not match';
|
||||||
|
}
|
||||||
|
return Validators.password(v);
|
||||||
|
},
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
decoration: _fieldDecoration(
|
||||||
|
colors: colors,
|
||||||
|
label: 'Confirm password',
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
suffix: _visibilityToggle(
|
||||||
|
colors: colors,
|
||||||
|
obscure: _obscureConfirm,
|
||||||
|
onPressed: () => setState(
|
||||||
|
() =>
|
||||||
|
_obscureConfirm = !_obscureConfirm,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
elevatedButtonTheme:
|
||||||
|
ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
foregroundColor: colors.onPrimary,
|
||||||
|
disabledBackgroundColor: colors.primary
|
||||||
|
.withValues(alpha: 0.75),
|
||||||
|
minimumSize:
|
||||||
|
const Size(double.infinity, 52),
|
||||||
|
elevation: 0,
|
||||||
|
shadowColor: colors.primary
|
||||||
|
.withValues(alpha: 0.35),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
textStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: AppButton(
|
||||||
|
label: _isLoading
|
||||||
|
? 'Updating…'
|
||||||
|
: 'Update Password',
|
||||||
|
isLoading: _isLoading,
|
||||||
|
onPressed: _submit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Divider(
|
||||||
|
color: colors.outlineSoft,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'SECURE ACCESS',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Divider(
|
||||||
|
color: colors.outlineSoft,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.verified_user_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: colors.success,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Your data is protected and encrypted',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AmbientBlob extends StatelessWidget {
|
||||||
|
const _AmbientBlob({required this.color, required this.size});
|
||||||
|
|
||||||
|
final Color color;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: color.withValues(alpha: 0.35),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,33 +24,47 @@ import '../widgets/login_colors.dart';
|
|||||||
import '../widgets/login_hero_panel.dart';
|
import '../widgets/login_hero_panel.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key, this.resetToken});
|
||||||
|
|
||||||
|
/// Token from email deep link (`/reset-password?token=` or `/login?token=`).
|
||||||
|
final String? resetToken;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _AuthBackFace { forgot, reset }
|
||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final _loginFormKey = GlobalKey<FormState>();
|
final _loginFormKey = GlobalKey<FormState>();
|
||||||
final _forgotFormKey = GlobalKey<FormState>();
|
final _forgotFormKey = GlobalKey<FormState>();
|
||||||
|
final _resetFormKey = GlobalKey<FormState>();
|
||||||
final _cardMeasureKey = GlobalKey();
|
final _cardMeasureKey = GlobalKey();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
final _forgotEmailController = TextEditingController();
|
final _forgotEmailController = TextEditingController();
|
||||||
|
final _newPasswordController = TextEditingController();
|
||||||
|
final _confirmPasswordController = TextEditingController();
|
||||||
|
|
||||||
late final AnimationController _flipController;
|
late final AnimationController _flipController;
|
||||||
late final Animation<double> _flipAnimation;
|
late final Animation<double> _flipAnimation;
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isForgotLoading = false;
|
bool _isForgotLoading = false;
|
||||||
|
bool _isResetLoading = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureNewPassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
bool _rememberMe = false;
|
bool _rememberMe = false;
|
||||||
String? _forgotSuccessMessage;
|
String? _forgotSuccessMessage;
|
||||||
String? _forgotErrorMessage;
|
String? _forgotErrorMessage;
|
||||||
|
String? _resetErrorMessage;
|
||||||
double? _cardHeight;
|
double? _cardHeight;
|
||||||
|
_AuthBackFace _backFace = _AuthBackFace.forgot;
|
||||||
|
String? _resetToken;
|
||||||
|
|
||||||
bool get _showingForgot => _flipAnimation.value > 0.5;
|
bool get _showingBack => _flipAnimation.value > 0.5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -64,6 +78,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
curve: Curves.easeInOutCubic,
|
curve: Curves.easeInOutCubic,
|
||||||
reverseCurve: Curves.easeInOutCubic,
|
reverseCurve: Curves.easeInOutCubic,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final token = widget.resetToken?.trim();
|
||||||
|
if (token != null && token.isNotEmpty) {
|
||||||
|
_resetToken = token;
|
||||||
|
_backFace = _AuthBackFace.reset;
|
||||||
|
_flipController.value = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadRememberedEmail());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadRememberedEmail());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,6 +122,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_forgotEmailController.dispose();
|
_forgotEmailController.dispose();
|
||||||
|
_newPasswordController.dispose();
|
||||||
|
_confirmPasswordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,34 +182,108 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _submitResetPassword() async {
|
||||||
|
if (!_resetFormKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final token = _resetToken?.trim() ?? '';
|
||||||
|
if (token.isEmpty) {
|
||||||
|
setState(
|
||||||
|
() => _resetErrorMessage =
|
||||||
|
'This reset link is invalid or has expired. Request a new one.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isResetLoading = true;
|
||||||
|
_resetErrorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final repository = ref.read(authRepositoryProvider);
|
||||||
|
final result = await repository.resetPassword(
|
||||||
|
ResetPasswordRequest(
|
||||||
|
token: token,
|
||||||
|
newPassword: _newPasswordController.text,
|
||||||
|
confirmPassword: _confirmPasswordController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isResetLoading = false);
|
||||||
|
|
||||||
|
if (result.failure != null) {
|
||||||
|
setState(
|
||||||
|
() => _resetErrorMessage =
|
||||||
|
result.failure?.message ?? result.failure.toString(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear reset form + token, then leave the deep-link route for a clean login.
|
||||||
|
_newPasswordController.clear();
|
||||||
|
_confirmPasswordController.clear();
|
||||||
|
_resetToken = null;
|
||||||
|
_resetFormKey.currentState?.reset();
|
||||||
|
setState(() {
|
||||||
|
_resetErrorMessage = null;
|
||||||
|
_backFace = _AuthBackFace.forgot;
|
||||||
|
_obscureNewPassword = true;
|
||||||
|
_obscureConfirmPassword = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Password reset successfully. Please sign in.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go(RouteConstants.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ensureCardHeightLocked() async {
|
||||||
|
if (_cardHeight != null) return;
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
if (!mounted) return;
|
||||||
|
final box =
|
||||||
|
_cardMeasureKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (box != null && box.hasSize) {
|
||||||
|
setState(() => _cardHeight = box.size.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _flipToForgot() async {
|
Future<void> _flipToForgot() async {
|
||||||
if (_flipController.isAnimating || _isLoading || _showingForgot) return;
|
if (_flipController.isAnimating || _isLoading || _showingBack) return;
|
||||||
if (_forgotEmailController.text.isEmpty &&
|
if (_forgotEmailController.text.isEmpty &&
|
||||||
_emailController.text.trim().isNotEmpty) {
|
_emailController.text.trim().isNotEmpty) {
|
||||||
_forgotEmailController.text = _emailController.text.trim();
|
_forgotEmailController.text = _emailController.text.trim();
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
|
_backFace = _AuthBackFace.forgot;
|
||||||
_forgotSuccessMessage = null;
|
_forgotSuccessMessage = null;
|
||||||
_forgotErrorMessage = null;
|
_forgotErrorMessage = null;
|
||||||
});
|
});
|
||||||
// Lock card height from the sign-in face before flipping.
|
await _ensureCardHeightLocked();
|
||||||
if (_cardHeight == null) {
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
if (!mounted) return;
|
|
||||||
final box =
|
|
||||||
_cardMeasureKey.currentContext?.findRenderObject() as RenderBox?;
|
|
||||||
if (box != null && box.hasSize) {
|
|
||||||
setState(() => _cardHeight = box.size.height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _flipController.forward();
|
await _flipController.forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _flipToSignIn() {
|
Future<void> _flipToSignIn() async {
|
||||||
if (_flipController.isAnimating || _isForgotLoading || !_showingForgot) {
|
if (_flipController.isAnimating ||
|
||||||
|
_isForgotLoading ||
|
||||||
|
_isResetLoading ||
|
||||||
|
!_showingBack) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_flipController.reverse();
|
await _flipController.reverse();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_backFace = _AuthBackFace.forgot;
|
||||||
|
_resetErrorMessage = null;
|
||||||
|
});
|
||||||
|
if (widget.resetToken != null) {
|
||||||
|
context.go(RouteConstants.login);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
InputDecoration _fieldDecoration({
|
InputDecoration _fieldDecoration({
|
||||||
@ -269,6 +367,81 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _secureAccessFooter(LoginColors colors) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Text(
|
||||||
|
'SECURE ACCESS',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11.5,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.verified_user_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: colors.success,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Your data is protected and encrypted',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _versionLabel(LoginColors colors) {
|
||||||
|
return Text(
|
||||||
|
'${AppConstants.appName} · v${AppConstants.appVersion}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _backToSignInButton(LoginColors colors) {
|
||||||
|
return Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _flipToSignIn,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: colors.linkColor,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Back to Sign In',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _primaryButtonTheme({
|
Widget _primaryButtonTheme({
|
||||||
required LoginColors colors,
|
required LoginColors colors,
|
||||||
required Widget child,
|
required Widget child,
|
||||||
@ -415,62 +588,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_primaryButtonTheme(
|
_primaryButtonTheme(
|
||||||
colors: colors,
|
colors: colors,
|
||||||
child: AppButton(
|
child: AppButton(
|
||||||
label: _isLoading ? 'Signing in…' : 'Sign in',
|
label: _isLoading ? 'Signing in…' : 'Sign in',
|
||||||
isLoading: _isLoading,
|
isLoading: _isLoading,
|
||||||
onPressed: _login,
|
onPressed: _login,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 26),
|
||||||
const SizedBox(height: 26),
|
_secureAccessFooter(colors),
|
||||||
Row(
|
const SizedBox(height: 22),
|
||||||
children: [
|
_versionLabel(colors),
|
||||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
if (DevConfig.screenPreviewEnabled) ...[
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
child: Text(
|
|
||||||
'SECURE ACCESS',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 11.5,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.verified_user_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: colors.success,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Your data is protected and encrypted',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
Text(
|
|
||||||
'${AppConstants.appName} · v${AppConstants.appVersion}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 11,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (DevConfig.screenPreviewEnabled) ...[
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Divider(color: colors.outlineSoft),
|
Divider(color: colors.outlineSoft),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@ -591,69 +721,151 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const Spacer()
|
const Spacer()
|
||||||
else
|
else
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
Center(
|
_backToSignInButton(colors),
|
||||||
child: TextButton(
|
const SizedBox(height: 18),
|
||||||
onPressed: _flipToSignIn,
|
_secureAccessFooter(colors),
|
||||||
style: TextButton.styleFrom(
|
const SizedBox(height: 22),
|
||||||
foregroundColor: colors.linkColor,
|
_versionLabel(colors),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
],
|
||||||
),
|
),
|
||||||
child: Text(
|
);
|
||||||
'Back to Sign In',
|
}
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 13.5,
|
Widget _buildResetFace({
|
||||||
fontWeight: FontWeight.w600,
|
required LoginColors colors,
|
||||||
|
required String logoUrl,
|
||||||
|
}) {
|
||||||
|
final fillHeight = _cardHeight != null;
|
||||||
|
return Form(
|
||||||
|
key: _resetFormKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildLogo(logoUrl),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Reset Password',
|
||||||
|
style: GoogleFonts.manrope(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
'Choose a new password for your account.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13.5,
|
||||||
|
height: 1.45,
|
||||||
|
color: colors.subtitleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 26),
|
||||||
|
TextFormField(
|
||||||
|
controller: _newPasswordController,
|
||||||
|
obscureText: _obscureNewPassword,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
|
validator: Validators.password,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14.5,
|
||||||
|
color: colors.headingColor,
|
||||||
|
),
|
||||||
|
decoration: _fieldDecoration(
|
||||||
|
colors: colors,
|
||||||
|
label: 'New password',
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
suffix: IconButton(
|
||||||
|
tooltip:
|
||||||
|
_obscureNewPassword ? 'Show password' : 'Hide password',
|
||||||
|
icon: Icon(
|
||||||
|
_obscureNewPassword
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: colors.iconMuted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _obscureNewPassword = !_obscureNewPassword,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Row(
|
TextFormField(
|
||||||
children: [
|
controller: _confirmPasswordController,
|
||||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
obscureText: _obscureConfirmPassword,
|
||||||
Padding(
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
validator: (v) {
|
||||||
child: Text(
|
if (v != _newPasswordController.text) {
|
||||||
'SECURE ACCESS',
|
return 'Passwords do not match';
|
||||||
style: GoogleFonts.inter(
|
}
|
||||||
fontSize: 11.5,
|
return Validators.required(v, fieldName: 'Confirm password');
|
||||||
letterSpacing: 0.6,
|
},
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: Divider(color: colors.outlineSoft, height: 1)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.verified_user_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: colors.success,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
'Your data is protected and encrypted',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 22),
|
|
||||||
Text(
|
|
||||||
'${AppConstants.appName} · v${AppConstants.appVersion}',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 11,
|
fontSize: 14.5,
|
||||||
letterSpacing: 0.3,
|
color: colors.headingColor,
|
||||||
color: colors.onSurfaceVariant,
|
),
|
||||||
|
decoration: _fieldDecoration(
|
||||||
|
colors: colors,
|
||||||
|
label: 'Confirm password',
|
||||||
|
icon: Icons.lock_outline_rounded,
|
||||||
|
suffix: IconButton(
|
||||||
|
tooltip: _obscureConfirmPassword
|
||||||
|
? 'Show password'
|
||||||
|
: 'Hide password',
|
||||||
|
icon: Icon(
|
||||||
|
_obscureConfirmPassword
|
||||||
|
? Icons.visibility_outlined
|
||||||
|
: Icons.visibility_off_outlined,
|
||||||
|
color: colors.iconMuted,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _obscureConfirmPassword = !_obscureConfirmPassword,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_resetErrorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_resetErrorMessage!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if ((_resetToken ?? '').isEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'This reset link is invalid or has expired. Request a new one from Forgot Password.',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_primaryButtonTheme(
|
||||||
|
colors: colors,
|
||||||
|
child: AppButton(
|
||||||
|
label: _isResetLoading ? 'Updating…' : 'Reset Password',
|
||||||
|
isLoading: _isResetLoading,
|
||||||
|
onPressed: _submitResetPassword,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (fillHeight)
|
||||||
|
const Spacer()
|
||||||
|
else
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_backToSignInButton(colors),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_secureAccessFooter(colors),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_versionLabel(colors),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -702,10 +914,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: _buildSignInFace(colors: colors, logoUrl: logoUrl),
|
child: _buildSignInFace(colors: colors, logoUrl: logoUrl),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final backChild = _buildForgotFace(
|
final backChild = _backFace == _AuthBackFace.reset
|
||||||
colors: colors,
|
? _buildResetFace(colors: colors, logoUrl: logoUrl)
|
||||||
logoUrl: logoUrl,
|
: _buildForgotFace(colors: colors, logoUrl: logoUrl);
|
||||||
);
|
|
||||||
final back = _cardShell(
|
final back = _cardShell(
|
||||||
colors: colors,
|
colors: colors,
|
||||||
child: _cardHeight != null
|
child: _cardHeight != null
|
||||||
|
|||||||
@ -352,8 +352,13 @@ class _PoDataTable extends StatelessWidget {
|
|||||||
label: 'Total',
|
label: 'Total',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
searchText: (order) => CurrencyFormatter.format(order.totalAmount),
|
searchText: (order) => CurrencyFormatter.format(order.totalAmount),
|
||||||
cellBuilder: (_, order) =>
|
cellBuilder: (_, order) => SizedBox(
|
||||||
Text(CurrencyFormatter.format(order.totalAmount)),
|
width: double.infinity,
|
||||||
|
child: AppTableCell.text(
|
||||||
|
CurrencyFormatter.format(order.totalAmount),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
|
|||||||
@ -668,7 +668,10 @@ class _ReportTable extends StatelessWidget {
|
|||||||
label: 'Method',
|
label: 'Method',
|
||||||
flex: 2,
|
flex: 2,
|
||||||
searchText: (row) => row.depreciationMethod ?? '',
|
searchText: (row) => row.depreciationMethod ?? '',
|
||||||
cellBuilder: (_, row) => AppTableCell.text(row.depreciationMethod),
|
cellBuilder: (_, row) => AppTableCell.text(
|
||||||
|
row.depreciationMethod,
|
||||||
|
placeholder: '-',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
label: 'Rate %',
|
label: 'Rate %',
|
||||||
@ -678,6 +681,7 @@ class _ReportTable extends StatelessWidget {
|
|||||||
cellBuilder: (_, row) => AppTableCell.text(
|
cellBuilder: (_, row) => AppTableCell.text(
|
||||||
row.depreciationRate?.toStringAsFixed(2),
|
row.depreciationRate?.toStringAsFixed(2),
|
||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right,
|
||||||
|
placeholder: '-',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AppDataColumn(
|
AppDataColumn(
|
||||||
|
|||||||
@ -143,10 +143,12 @@ class UserRemoteDataSource {
|
|||||||
|
|
||||||
Future<ManagedUserModel> updateProfile(UpdateProfileRequest request) async {
|
Future<ManagedUserModel> updateProfile(UpdateProfileRequest request) async {
|
||||||
final response = await dio.put(
|
final response = await dio.put(
|
||||||
ApiEndpoints.me,
|
ApiEndpoints.updateProfile,
|
||||||
data: request.toJson()..removeWhere((_, v) => v == null),
|
data: request.toJson()..removeWhere((_, v) => v == null),
|
||||||
);
|
);
|
||||||
return ManagedUserModel.fromJson(response.data['data'] as Map<String, dynamic>);
|
return ManagedUserModel.fromJson(
|
||||||
|
response.data['data'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _exportQueryToMap(UserListQuery query) {
|
Map<String, dynamic> _exportQueryToMap(UserListQuery query) {
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/utils/media_url.dart';
|
||||||
import '../../../../core/utils/validators.dart';
|
import '../../../../core/utils/validators.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/models/user_model.dart';
|
|
||||||
import '../../../../shared/providers/auth_provider.dart';
|
import '../../../../shared/providers/auth_provider.dart';
|
||||||
import '../../../../shared/widgets/app_button.dart';
|
import '../../../../shared/widgets/app_button.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
import '../../../auth/data/repositories/auth_repository_impl.dart';
|
|
||||||
import '../providers/users_provider.dart';
|
|
||||||
import '../../../../shared/widgets/app_toast.dart';
|
import '../../../../shared/widgets/app_toast.dart';
|
||||||
|
import '../../../../shared/widgets/user_avatar.dart';
|
||||||
|
import '../../../auth/data/repositories/auth_repository_impl.dart';
|
||||||
|
|
||||||
class UserProfileScreen extends ConsumerStatefulWidget {
|
class UserProfileScreen extends ConsumerStatefulWidget {
|
||||||
const UserProfileScreen({super.key});
|
const UserProfileScreen({super.key});
|
||||||
@ -23,14 +21,12 @@ class UserProfileScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _fullNameController = TextEditingController();
|
||||||
final _nameController = TextEditingController();
|
final _nameController = TextEditingController();
|
||||||
|
final _emailController = TextEditingController();
|
||||||
final _mobileController = TextEditingController();
|
final _mobileController = TextEditingController();
|
||||||
final _currentPasswordController = TextEditingController();
|
|
||||||
final _newPasswordController = TextEditingController();
|
|
||||||
final _confirmPasswordController = TextEditingController();
|
|
||||||
String? _avatarUrl;
|
|
||||||
bool _isUpdatingProfile = false;
|
bool _isUpdatingProfile = false;
|
||||||
bool _isChangingPassword = false;
|
bool _isUploadingAvatar = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -41,30 +37,76 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
void _loadUser() {
|
void _loadUser() {
|
||||||
final user = ref.read(authStateProvider).user;
|
final user = ref.read(authStateProvider).user;
|
||||||
if (user == null) return;
|
if (user == null) return;
|
||||||
|
_fullNameController.text = user.name;
|
||||||
_nameController.text = user.name;
|
_nameController.text = user.name;
|
||||||
|
_emailController.text = user.email;
|
||||||
_mobileController.text = user.mobile;
|
_mobileController.text = user.mobile;
|
||||||
_avatarUrl = user.avatarUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_fullNameController.dispose();
|
||||||
_nameController.dispose();
|
_nameController.dispose();
|
||||||
|
_emailController.dispose();
|
||||||
_mobileController.dispose();
|
_mobileController.dispose();
|
||||||
_currentPasswordController.dispose();
|
|
||||||
_newPasswordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
Future<void> _pickAndUploadAvatar() async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
type: FileType.image,
|
type: FileType.custom,
|
||||||
withData: false,
|
allowedExtensions: const ['jpg', 'jpeg', 'png', 'webp'],
|
||||||
|
withData: true,
|
||||||
);
|
);
|
||||||
if (result == null || result.files.isEmpty) return;
|
if (result == null || result.files.isEmpty) return;
|
||||||
setState(() => _avatarUrl = result.files.first.path);
|
|
||||||
showAppToastFromSnackBar(context,
|
final file = result.files.first;
|
||||||
const SnackBar(content: Text('Image selected. Upload will use avatar_url on save.')),
|
final bytes = file.bytes;
|
||||||
|
if (bytes == null || bytes.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
|
const SnackBar(content: Text('Could not read the selected image.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isUploadingAvatar = true);
|
||||||
|
final repository = ref.read(authRepositoryProvider);
|
||||||
|
final uploadResult = await repository.uploadProfileAvatar(
|
||||||
|
bytes: bytes,
|
||||||
|
filename: file.name.isNotEmpty ? file.name : 'avatar.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (uploadResult.failure != null) {
|
||||||
|
setState(() => _isUploadingAvatar = false);
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
uploadResult.failure?.message ?? uploadResult.failure.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final current = ref.read(authStateProvider).user;
|
||||||
|
if (current != null) {
|
||||||
|
ref.read(authStateProvider.notifier).setUser(
|
||||||
|
current.copyWith(avatarUrl: uploadResult.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sync role/department/avatar from /auth/me when available.
|
||||||
|
await ref.read(authStateProvider.notifier).refreshCurrentUser();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isUploadingAvatar = false);
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
|
const SnackBar(content: Text('Profile picture updated')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,12 +114,13 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
setState(() => _isUpdatingProfile = true);
|
setState(() => _isUpdatingProfile = true);
|
||||||
|
|
||||||
final useCase = ref.read(updateProfileUseCaseProvider);
|
final repository = ref.read(authRepositoryProvider);
|
||||||
final result = await useCase(
|
final result = await repository.updateProfile(
|
||||||
UpdateProfileRequest(
|
UpdateProfileRequest(
|
||||||
fullName: _nameController.text.trim(),
|
fullName: _fullNameController.text.trim(),
|
||||||
|
name: _nameController.text.trim(),
|
||||||
|
email: _emailController.text.trim(),
|
||||||
mobile: _mobileController.text.trim(),
|
mobile: _mobileController.text.trim(),
|
||||||
avatarUrl: _avatarUrl,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -85,68 +128,40 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
setState(() => _isUpdatingProfile = false);
|
setState(() => _isUpdatingProfile = false);
|
||||||
|
|
||||||
if (result.failure != null) {
|
if (result.failure != null) {
|
||||||
showAppToastFromSnackBar(context,
|
showAppToastFromSnackBar(
|
||||||
SnackBar(content: Text(result.failure.toString())),
|
context,
|
||||||
|
SnackBar(
|
||||||
|
content: Text(result.failure?.message ?? result.failure.toString()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ref.read(authStateProvider.notifier).checkAuth();
|
if (result.data != null) {
|
||||||
showAppToastFromSnackBar(context,
|
ref.read(authStateProvider.notifier).setUser(result.data!);
|
||||||
|
_fullNameController.text = result.data!.name;
|
||||||
|
_nameController.text = result.data!.name;
|
||||||
|
_emailController.text = result.data!.email;
|
||||||
|
_mobileController.text = result.data!.mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAppToastFromSnackBar(
|
||||||
|
context,
|
||||||
const SnackBar(content: Text('Profile updated')),
|
const SnackBar(content: Text('Profile updated')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _changePassword() async {
|
|
||||||
if (_newPasswordController.text != _confirmPasswordController.text) {
|
|
||||||
showAppToastFromSnackBar(context,
|
|
||||||
const SnackBar(content: Text('Passwords do not match')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final passwordError = Validators.password(_newPasswordController.text);
|
|
||||||
if (passwordError != null) {
|
|
||||||
showAppToastFromSnackBar(context, SnackBar(content: Text(passwordError)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _isChangingPassword = true);
|
|
||||||
final repository = ref.read(authRepositoryProvider);
|
|
||||||
final result = await repository.changePassword(
|
|
||||||
ChangePasswordRequest(
|
|
||||||
currentPassword: _currentPasswordController.text,
|
|
||||||
newPassword: _newPasswordController.text,
|
|
||||||
confirmPassword: _confirmPasswordController.text,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _isChangingPassword = false);
|
|
||||||
|
|
||||||
if (result.failure != null) {
|
|
||||||
showAppToastFromSnackBar(context,
|
|
||||||
SnackBar(content: Text(result.failure.toString())),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_currentPasswordController.clear();
|
|
||||||
_newPasswordController.clear();
|
|
||||||
_confirmPasswordController.clear();
|
|
||||||
showAppToastFromSnackBar(context,
|
|
||||||
const SnackBar(content: Text('Password changed successfully')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final user = ref.watch(authStateProvider).user;
|
final user = ref.watch(authStateProvider).user;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
return const Center(child: Text('Not signed in'));
|
return const Center(child: Text('Not signed in'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final avatarUrl = resolveMediaUrl(user.avatarUrl);
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -155,28 +170,36 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('User Profile', style: Theme.of(context).textTheme.headlineSmall),
|
Text(
|
||||||
|
'User Profile',
|
||||||
|
style: theme.textTheme.headlineSmall,
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Center(
|
Center(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
UserAvatar(
|
||||||
|
name: user.name,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
radius: 48,
|
radius: 48,
|
||||||
backgroundImage:
|
|
||||||
_avatarUrl != null ? NetworkImage(_avatarUrl!) : null,
|
|
||||||
child: _avatarUrl == null
|
|
||||||
? Text(
|
|
||||||
user.name.isNotEmpty ? user.name[0].toUpperCase() : '?',
|
|
||||||
style: const TextStyle(fontSize: 32),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: IconButton.filled(
|
child: IconButton.filled(
|
||||||
onPressed: _pickImage,
|
onPressed:
|
||||||
icon: const Icon(Icons.camera_alt, size: 18),
|
_isUploadingAvatar ? null : _pickAndUploadAvatar,
|
||||||
|
icon: _isUploadingAvatar
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.camera_alt, size: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -188,14 +211,24 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AppTextField(
|
AppTextField(
|
||||||
controller: _nameController,
|
controller: _fullNameController,
|
||||||
label: 'Name',
|
label: 'Full Name',
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Name'),
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'Full Name'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
InputDecorator(
|
AppTextField(
|
||||||
decoration: const InputDecoration(labelText: 'Email'),
|
controller: _nameController,
|
||||||
child: Text(user.email),
|
label: 'Name',
|
||||||
|
validator: (v) =>
|
||||||
|
Validators.required(v, fieldName: 'Name'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
AppTextField(
|
||||||
|
controller: _emailController,
|
||||||
|
label: 'Email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
validator: Validators.email,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
AppTextField(
|
AppTextField(
|
||||||
@ -224,41 +257,6 @@ class _UserProfileScreenState extends ConsumerState<UserProfileScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('Change Password', style: Theme.of(context).textTheme.titleMedium),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _currentPasswordController,
|
|
||||||
label: 'Current Password',
|
|
||||||
obscureText: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _newPasswordController,
|
|
||||||
label: 'New Password',
|
|
||||||
obscureText: true,
|
|
||||||
validator: Validators.password,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppTextField(
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
label: 'Confirm Password',
|
|
||||||
obscureText: true,
|
|
||||||
validator: (v) => Validators.required(v, fieldName: 'Confirm Password'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
AppButton(
|
|
||||||
label: 'Change Password',
|
|
||||||
isLoading: _isChangingPassword,
|
|
||||||
onPressed: _changePassword,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.push(RouteConstants.changePassword),
|
|
||||||
child: const Text('Open full change password screen'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -415,8 +415,10 @@ class PermissionMatrixSaveRow with _$PermissionMatrixSaveRow {
|
|||||||
class UpdateProfileRequest with _$UpdateProfileRequest {
|
class UpdateProfileRequest with _$UpdateProfileRequest {
|
||||||
const factory UpdateProfileRequest({
|
const factory UpdateProfileRequest({
|
||||||
@JsonKey(name: 'full_name') String? fullName,
|
@JsonKey(name: 'full_name') String? fullName,
|
||||||
|
/// Alias for [fullName] — API accepts both `name` and `full_name`.
|
||||||
|
String? name,
|
||||||
|
String? email,
|
||||||
String? mobile,
|
String? mobile,
|
||||||
@JsonKey(name: 'avatar_url') String? avatarUrl,
|
|
||||||
}) = _UpdateProfileRequest;
|
}) = _UpdateProfileRequest;
|
||||||
|
|
||||||
factory UpdateProfileRequest.fromJson(Map<String, dynamic> json) =>
|
factory UpdateProfileRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@ -5031,9 +5031,11 @@ UpdateProfileRequest _$UpdateProfileRequestFromJson(Map<String, dynamic> json) {
|
|||||||
mixin _$UpdateProfileRequest {
|
mixin _$UpdateProfileRequest {
|
||||||
@JsonKey(name: 'full_name')
|
@JsonKey(name: 'full_name')
|
||||||
String? get fullName => throw _privateConstructorUsedError;
|
String? get fullName => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Alias for [fullName] — API accepts both `name` and `full_name`.
|
||||||
|
String? get name => throw _privateConstructorUsedError;
|
||||||
|
String? get email => throw _privateConstructorUsedError;
|
||||||
String? get mobile => throw _privateConstructorUsedError;
|
String? get mobile => throw _privateConstructorUsedError;
|
||||||
@JsonKey(name: 'avatar_url')
|
|
||||||
String? get avatarUrl => throw _privateConstructorUsedError;
|
|
||||||
|
|
||||||
/// Serializes this UpdateProfileRequest to a JSON map.
|
/// Serializes this UpdateProfileRequest to a JSON map.
|
||||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
@ -5054,8 +5056,9 @@ abstract class $UpdateProfileRequestCopyWith<$Res> {
|
|||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
@JsonKey(name: 'full_name') String? fullName,
|
@JsonKey(name: 'full_name') String? fullName,
|
||||||
|
String? name,
|
||||||
|
String? email,
|
||||||
String? mobile,
|
String? mobile,
|
||||||
@JsonKey(name: 'avatar_url') String? avatarUrl,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5078,8 +5081,9 @@ class _$UpdateProfileRequestCopyWithImpl<
|
|||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
Object? fullName = freezed,
|
Object? fullName = freezed,
|
||||||
|
Object? name = freezed,
|
||||||
|
Object? email = freezed,
|
||||||
Object? mobile = freezed,
|
Object? mobile = freezed,
|
||||||
Object? avatarUrl = freezed,
|
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_value.copyWith(
|
_value.copyWith(
|
||||||
@ -5087,14 +5091,18 @@ class _$UpdateProfileRequestCopyWithImpl<
|
|||||||
? _value.fullName
|
? _value.fullName
|
||||||
: fullName // ignore: cast_nullable_to_non_nullable
|
: fullName // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
name: freezed == name
|
||||||
|
? _value.name
|
||||||
|
: name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
email: freezed == email
|
||||||
|
? _value.email
|
||||||
|
: email // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
mobile: freezed == mobile
|
mobile: freezed == mobile
|
||||||
? _value.mobile
|
? _value.mobile
|
||||||
: mobile // ignore: cast_nullable_to_non_nullable
|
: mobile // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
avatarUrl: freezed == avatarUrl
|
|
||||||
? _value.avatarUrl
|
|
||||||
: avatarUrl // ignore: cast_nullable_to_non_nullable
|
|
||||||
as String?,
|
|
||||||
)
|
)
|
||||||
as $Val,
|
as $Val,
|
||||||
);
|
);
|
||||||
@ -5112,8 +5120,9 @@ abstract class _$$UpdateProfileRequestImplCopyWith<$Res>
|
|||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
@JsonKey(name: 'full_name') String? fullName,
|
@JsonKey(name: 'full_name') String? fullName,
|
||||||
|
String? name,
|
||||||
|
String? email,
|
||||||
String? mobile,
|
String? mobile,
|
||||||
@JsonKey(name: 'avatar_url') String? avatarUrl,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5132,8 +5141,9 @@ class __$$UpdateProfileRequestImplCopyWithImpl<$Res>
|
|||||||
@override
|
@override
|
||||||
$Res call({
|
$Res call({
|
||||||
Object? fullName = freezed,
|
Object? fullName = freezed,
|
||||||
|
Object? name = freezed,
|
||||||
|
Object? email = freezed,
|
||||||
Object? mobile = freezed,
|
Object? mobile = freezed,
|
||||||
Object? avatarUrl = freezed,
|
|
||||||
}) {
|
}) {
|
||||||
return _then(
|
return _then(
|
||||||
_$UpdateProfileRequestImpl(
|
_$UpdateProfileRequestImpl(
|
||||||
@ -5141,14 +5151,18 @@ class __$$UpdateProfileRequestImplCopyWithImpl<$Res>
|
|||||||
? _value.fullName
|
? _value.fullName
|
||||||
: fullName // ignore: cast_nullable_to_non_nullable
|
: fullName // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
|
name: freezed == name
|
||||||
|
? _value.name
|
||||||
|
: name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
email: freezed == email
|
||||||
|
? _value.email
|
||||||
|
: email // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
mobile: freezed == mobile
|
mobile: freezed == mobile
|
||||||
? _value.mobile
|
? _value.mobile
|
||||||
: mobile // ignore: cast_nullable_to_non_nullable
|
: mobile // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,
|
as String?,
|
||||||
avatarUrl: freezed == avatarUrl
|
|
||||||
? _value.avatarUrl
|
|
||||||
: avatarUrl // ignore: cast_nullable_to_non_nullable
|
|
||||||
as String?,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -5159,8 +5173,9 @@ class __$$UpdateProfileRequestImplCopyWithImpl<$Res>
|
|||||||
class _$UpdateProfileRequestImpl implements _UpdateProfileRequest {
|
class _$UpdateProfileRequestImpl implements _UpdateProfileRequest {
|
||||||
const _$UpdateProfileRequestImpl({
|
const _$UpdateProfileRequestImpl({
|
||||||
@JsonKey(name: 'full_name') this.fullName,
|
@JsonKey(name: 'full_name') this.fullName,
|
||||||
|
this.name,
|
||||||
|
this.email,
|
||||||
this.mobile,
|
this.mobile,
|
||||||
@JsonKey(name: 'avatar_url') this.avatarUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory _$UpdateProfileRequestImpl.fromJson(Map<String, dynamic> json) =>
|
factory _$UpdateProfileRequestImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
@ -5169,15 +5184,18 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest {
|
|||||||
@override
|
@override
|
||||||
@JsonKey(name: 'full_name')
|
@JsonKey(name: 'full_name')
|
||||||
final String? fullName;
|
final String? fullName;
|
||||||
|
|
||||||
|
/// Alias for [fullName] — API accepts both `name` and `full_name`.
|
||||||
|
@override
|
||||||
|
final String? name;
|
||||||
|
@override
|
||||||
|
final String? email;
|
||||||
@override
|
@override
|
||||||
final String? mobile;
|
final String? mobile;
|
||||||
@override
|
|
||||||
@JsonKey(name: 'avatar_url')
|
|
||||||
final String? avatarUrl;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'UpdateProfileRequest(fullName: $fullName, mobile: $mobile, avatarUrl: $avatarUrl)';
|
return 'UpdateProfileRequest(fullName: $fullName, name: $name, email: $email, mobile: $mobile)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -5187,14 +5205,14 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest {
|
|||||||
other is _$UpdateProfileRequestImpl &&
|
other is _$UpdateProfileRequestImpl &&
|
||||||
(identical(other.fullName, fullName) ||
|
(identical(other.fullName, fullName) ||
|
||||||
other.fullName == fullName) &&
|
other.fullName == fullName) &&
|
||||||
(identical(other.mobile, mobile) || other.mobile == mobile) &&
|
(identical(other.name, name) || other.name == name) &&
|
||||||
(identical(other.avatarUrl, avatarUrl) ||
|
(identical(other.email, email) || other.email == email) &&
|
||||||
other.avatarUrl == avatarUrl));
|
(identical(other.mobile, mobile) || other.mobile == mobile));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType, fullName, mobile, avatarUrl);
|
int get hashCode => Object.hash(runtimeType, fullName, name, email, mobile);
|
||||||
|
|
||||||
/// Create a copy of UpdateProfileRequest
|
/// Create a copy of UpdateProfileRequest
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@ -5217,8 +5235,9 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest {
|
|||||||
abstract class _UpdateProfileRequest implements UpdateProfileRequest {
|
abstract class _UpdateProfileRequest implements UpdateProfileRequest {
|
||||||
const factory _UpdateProfileRequest({
|
const factory _UpdateProfileRequest({
|
||||||
@JsonKey(name: 'full_name') final String? fullName,
|
@JsonKey(name: 'full_name') final String? fullName,
|
||||||
|
final String? name,
|
||||||
|
final String? email,
|
||||||
final String? mobile,
|
final String? mobile,
|
||||||
@JsonKey(name: 'avatar_url') final String? avatarUrl,
|
|
||||||
}) = _$UpdateProfileRequestImpl;
|
}) = _$UpdateProfileRequestImpl;
|
||||||
|
|
||||||
factory _UpdateProfileRequest.fromJson(Map<String, dynamic> json) =
|
factory _UpdateProfileRequest.fromJson(Map<String, dynamic> json) =
|
||||||
@ -5227,11 +5246,14 @@ abstract class _UpdateProfileRequest implements UpdateProfileRequest {
|
|||||||
@override
|
@override
|
||||||
@JsonKey(name: 'full_name')
|
@JsonKey(name: 'full_name')
|
||||||
String? get fullName;
|
String? get fullName;
|
||||||
|
|
||||||
|
/// Alias for [fullName] — API accepts both `name` and `full_name`.
|
||||||
|
@override
|
||||||
|
String? get name;
|
||||||
|
@override
|
||||||
|
String? get email;
|
||||||
@override
|
@override
|
||||||
String? get mobile;
|
String? get mobile;
|
||||||
@override
|
|
||||||
@JsonKey(name: 'avatar_url')
|
|
||||||
String? get avatarUrl;
|
|
||||||
|
|
||||||
/// Create a copy of UpdateProfileRequest
|
/// Create a copy of UpdateProfileRequest
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
|||||||
@ -392,14 +392,16 @@ _$UpdateProfileRequestImpl _$$UpdateProfileRequestImplFromJson(
|
|||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => _$UpdateProfileRequestImpl(
|
) => _$UpdateProfileRequestImpl(
|
||||||
fullName: json['full_name'] as String?,
|
fullName: json['full_name'] as String?,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
email: json['email'] as String?,
|
||||||
mobile: json['mobile'] as String?,
|
mobile: json['mobile'] as String?,
|
||||||
avatarUrl: json['avatar_url'] as String?,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$$UpdateProfileRequestImplToJson(
|
Map<String, dynamic> _$$UpdateProfileRequestImplToJson(
|
||||||
_$UpdateProfileRequestImpl instance,
|
_$UpdateProfileRequestImpl instance,
|
||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'full_name': instance.fullName,
|
'full_name': instance.fullName,
|
||||||
|
'name': instance.name,
|
||||||
|
'email': instance.email,
|
||||||
'mobile': instance.mobile,
|
'mobile': instance.mobile,
|
||||||
'avatar_url': instance.avatarUrl,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -201,3 +201,15 @@ class ForgotPasswordRequest with _$ForgotPasswordRequest {
|
|||||||
factory ForgotPasswordRequest.fromJson(Map<String, dynamic> json) =>
|
factory ForgotPasswordRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ForgotPasswordRequestFromJson(json);
|
_$ForgotPasswordRequestFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class ResetPasswordRequest with _$ResetPasswordRequest {
|
||||||
|
const factory ResetPasswordRequest({
|
||||||
|
required String token,
|
||||||
|
@JsonKey(name: 'new_password') required String newPassword,
|
||||||
|
@JsonKey(name: 'confirm_password') required String confirmPassword,
|
||||||
|
}) = _ResetPasswordRequest;
|
||||||
|
|
||||||
|
factory ResetPasswordRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ResetPasswordRequestFromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
@ -1696,3 +1696,222 @@ abstract class _ForgotPasswordRequest implements ForgotPasswordRequest {
|
|||||||
_$$ForgotPasswordRequestImplCopyWith<_$ForgotPasswordRequestImpl>
|
_$$ForgotPasswordRequestImplCopyWith<_$ForgotPasswordRequestImpl>
|
||||||
get copyWith => throw _privateConstructorUsedError;
|
get copyWith => throw _privateConstructorUsedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ResetPasswordRequest _$ResetPasswordRequestFromJson(Map<String, dynamic> json) {
|
||||||
|
return _ResetPasswordRequest.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$ResetPasswordRequest {
|
||||||
|
String get token => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'new_password')
|
||||||
|
String get newPassword => throw _privateConstructorUsedError;
|
||||||
|
@JsonKey(name: 'confirm_password')
|
||||||
|
String get confirmPassword => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Serializes this ResetPasswordRequest to a JSON map.
|
||||||
|
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||||
|
|
||||||
|
/// Create a copy of ResetPasswordRequest
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
$ResetPasswordRequestCopyWith<ResetPasswordRequest> get copyWith =>
|
||||||
|
throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class $ResetPasswordRequestCopyWith<$Res> {
|
||||||
|
factory $ResetPasswordRequestCopyWith(
|
||||||
|
ResetPasswordRequest value,
|
||||||
|
$Res Function(ResetPasswordRequest) then,
|
||||||
|
) = _$ResetPasswordRequestCopyWithImpl<$Res, ResetPasswordRequest>;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String token,
|
||||||
|
@JsonKey(name: 'new_password') String newPassword,
|
||||||
|
@JsonKey(name: 'confirm_password') String confirmPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class _$ResetPasswordRequestCopyWithImpl<
|
||||||
|
$Res,
|
||||||
|
$Val extends ResetPasswordRequest
|
||||||
|
>
|
||||||
|
implements $ResetPasswordRequestCopyWith<$Res> {
|
||||||
|
_$ResetPasswordRequestCopyWithImpl(this._value, this._then);
|
||||||
|
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Val _value;
|
||||||
|
// ignore: unused_field
|
||||||
|
final $Res Function($Val) _then;
|
||||||
|
|
||||||
|
/// Create a copy of ResetPasswordRequest
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? token = null,
|
||||||
|
Object? newPassword = null,
|
||||||
|
Object? confirmPassword = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_value.copyWith(
|
||||||
|
token: null == token
|
||||||
|
? _value.token
|
||||||
|
: token // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
newPassword: null == newPassword
|
||||||
|
? _value.newPassword
|
||||||
|
: newPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
confirmPassword: null == confirmPassword
|
||||||
|
? _value.confirmPassword
|
||||||
|
: confirmPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
)
|
||||||
|
as $Val,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract class _$$ResetPasswordRequestImplCopyWith<$Res>
|
||||||
|
implements $ResetPasswordRequestCopyWith<$Res> {
|
||||||
|
factory _$$ResetPasswordRequestImplCopyWith(
|
||||||
|
_$ResetPasswordRequestImpl value,
|
||||||
|
$Res Function(_$ResetPasswordRequestImpl) then,
|
||||||
|
) = __$$ResetPasswordRequestImplCopyWithImpl<$Res>;
|
||||||
|
@override
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String token,
|
||||||
|
@JsonKey(name: 'new_password') String newPassword,
|
||||||
|
@JsonKey(name: 'confirm_password') String confirmPassword,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class __$$ResetPasswordRequestImplCopyWithImpl<$Res>
|
||||||
|
extends _$ResetPasswordRequestCopyWithImpl<$Res, _$ResetPasswordRequestImpl>
|
||||||
|
implements _$$ResetPasswordRequestImplCopyWith<$Res> {
|
||||||
|
__$$ResetPasswordRequestImplCopyWithImpl(
|
||||||
|
_$ResetPasswordRequestImpl _value,
|
||||||
|
$Res Function(_$ResetPasswordRequestImpl) _then,
|
||||||
|
) : super(_value, _then);
|
||||||
|
|
||||||
|
/// Create a copy of ResetPasswordRequest
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
@override
|
||||||
|
$Res call({
|
||||||
|
Object? token = null,
|
||||||
|
Object? newPassword = null,
|
||||||
|
Object? confirmPassword = null,
|
||||||
|
}) {
|
||||||
|
return _then(
|
||||||
|
_$ResetPasswordRequestImpl(
|
||||||
|
token: null == token
|
||||||
|
? _value.token
|
||||||
|
: token // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
newPassword: null == newPassword
|
||||||
|
? _value.newPassword
|
||||||
|
: newPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
confirmPassword: null == confirmPassword
|
||||||
|
? _value.confirmPassword
|
||||||
|
: confirmPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
class _$ResetPasswordRequestImpl implements _ResetPasswordRequest {
|
||||||
|
const _$ResetPasswordRequestImpl({
|
||||||
|
required this.token,
|
||||||
|
@JsonKey(name: 'new_password') required this.newPassword,
|
||||||
|
@JsonKey(name: 'confirm_password') required this.confirmPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory _$ResetPasswordRequestImpl.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$$ResetPasswordRequestImplFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final String token;
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'new_password')
|
||||||
|
final String newPassword;
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'confirm_password')
|
||||||
|
final String confirmPassword;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ResetPasswordRequest(token: $token, newPassword: $newPassword, confirmPassword: $confirmPassword)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) ||
|
||||||
|
(other.runtimeType == runtimeType &&
|
||||||
|
other is _$ResetPasswordRequestImpl &&
|
||||||
|
(identical(other.token, token) || other.token == token) &&
|
||||||
|
(identical(other.newPassword, newPassword) ||
|
||||||
|
other.newPassword == newPassword) &&
|
||||||
|
(identical(other.confirmPassword, confirmPassword) ||
|
||||||
|
other.confirmPassword == confirmPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
Object.hash(runtimeType, token, newPassword, confirmPassword);
|
||||||
|
|
||||||
|
/// Create a copy of ResetPasswordRequest
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$$ResetPasswordRequestImplCopyWith<_$ResetPasswordRequestImpl>
|
||||||
|
get copyWith =>
|
||||||
|
__$$ResetPasswordRequestImplCopyWithImpl<_$ResetPasswordRequestImpl>(
|
||||||
|
this,
|
||||||
|
_$identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$$ResetPasswordRequestImplToJson(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _ResetPasswordRequest implements ResetPasswordRequest {
|
||||||
|
const factory _ResetPasswordRequest({
|
||||||
|
required final String token,
|
||||||
|
@JsonKey(name: 'new_password') required final String newPassword,
|
||||||
|
@JsonKey(name: 'confirm_password') required final String confirmPassword,
|
||||||
|
}) = _$ResetPasswordRequestImpl;
|
||||||
|
|
||||||
|
factory _ResetPasswordRequest.fromJson(Map<String, dynamic> json) =
|
||||||
|
_$ResetPasswordRequestImpl.fromJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get token;
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'new_password')
|
||||||
|
String get newPassword;
|
||||||
|
@override
|
||||||
|
@JsonKey(name: 'confirm_password')
|
||||||
|
String get confirmPassword;
|
||||||
|
|
||||||
|
/// Create a copy of ResetPasswordRequest
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
_$$ResetPasswordRequestImplCopyWith<_$ResetPasswordRequestImpl>
|
||||||
|
get copyWith => throw _privateConstructorUsedError;
|
||||||
|
}
|
||||||
|
|||||||
@ -126,3 +126,19 @@ _$ForgotPasswordRequestImpl _$$ForgotPasswordRequestImplFromJson(
|
|||||||
Map<String, dynamic> _$$ForgotPasswordRequestImplToJson(
|
Map<String, dynamic> _$$ForgotPasswordRequestImplToJson(
|
||||||
_$ForgotPasswordRequestImpl instance,
|
_$ForgotPasswordRequestImpl instance,
|
||||||
) => <String, dynamic>{'email': instance.email};
|
) => <String, dynamic>{'email': instance.email};
|
||||||
|
|
||||||
|
_$ResetPasswordRequestImpl _$$ResetPasswordRequestImplFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _$ResetPasswordRequestImpl(
|
||||||
|
token: json['token'] as String,
|
||||||
|
newPassword: json['new_password'] as String,
|
||||||
|
confirmPassword: json['confirm_password'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$$ResetPasswordRequestImplToJson(
|
||||||
|
_$ResetPasswordRequestImpl instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'token': instance.token,
|
||||||
|
'new_password': instance.newPassword,
|
||||||
|
'confirm_password': instance.confirmPassword,
|
||||||
|
};
|
||||||
|
|||||||
@ -101,6 +101,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
|||||||
state = const AuthState(status: AuthStatus.unauthenticated);
|
state = const AuthState(status: AuthStatus.unauthenticated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the cached user (e.g. after profile / avatar update).
|
||||||
|
void setUser(UserModel user) {
|
||||||
|
if (state.status != AuthStatus.authenticated) return;
|
||||||
|
state = state.copyWith(user: user, error: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> refreshCurrentUser() async {
|
||||||
|
final result = await _repository.getCurrentUser();
|
||||||
|
if (result.failure != null || result.data == null) return false;
|
||||||
|
setUser(result.data!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when refresh token expires or refresh fails (401 interceptor).
|
/// Called when refresh token expires or refresh fails (401 interceptor).
|
||||||
void onSessionExpired() {
|
void onSessionExpired() {
|
||||||
state = const AuthState(
|
state = const AuthState(
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import '../../modules/assets/presentation/screens/asset_list_screen.dart';
|
|||||||
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
import '../../modules/auth/presentation/screens/change_password_screen.dart';
|
||||||
import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
|
import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
|
||||||
import '../../modules/auth/presentation/screens/login_screen.dart';
|
import '../../modules/auth/presentation/screens/login_screen.dart';
|
||||||
import '../../modules/auth/presentation/screens/reset_password_screen.dart';
|
|
||||||
import '../../modules/auth/presentation/screens/verify_otp_screen.dart';
|
import '../../modules/auth/presentation/screens/verify_otp_screen.dart';
|
||||||
import '../../modules/master_data/domain/entities/master_definition.dart';
|
import '../../modules/master_data/domain/entities/master_definition.dart';
|
||||||
import '../../modules/master_data/presentation/screens/master_list_screen.dart';
|
import '../../modules/master_data/presentation/screens/master_list_screen.dart';
|
||||||
@ -93,7 +92,13 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.login,
|
path: RouteConstants.login,
|
||||||
builder: (context, state) => _themedRoute(state, const LoginScreen()),
|
builder: (context, state) {
|
||||||
|
final token = state.uri.queryParameters['token'];
|
||||||
|
return _themedRoute(
|
||||||
|
state,
|
||||||
|
LoginScreen(resetToken: token),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.forgotPassword,
|
path: RouteConstants.forgotPassword,
|
||||||
@ -102,8 +107,15 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.resetPassword,
|
path: RouteConstants.resetPassword,
|
||||||
builder: (context, state) =>
|
builder: (context, state) {
|
||||||
_themedRoute(state, const ResetPasswordScreen()),
|
// Email links open `/reset-password?token=…` — show login shell
|
||||||
|
// with the reset-password card face (per API docs).
|
||||||
|
final token = state.uri.queryParameters['token'];
|
||||||
|
return _themedRoute(
|
||||||
|
state,
|
||||||
|
LoginScreen(resetToken: token),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.verifyOtp,
|
path: RouteConstants.verifyOtp,
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'app_card.dart';
|
import 'app_card.dart';
|
||||||
|
|
||||||
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
|
/// Fixed height for every data row in [AppDataTable] and themed [DataTable] widgets.
|
||||||
const double kAppTableRowHeight = 52;
|
const double kAppTableRowHeight = 44;
|
||||||
|
|
||||||
/// Fixed column-filter row height (first row under the header).
|
/// Fixed column-filter row height (first row under the header).
|
||||||
const double kAppTableFilterRowHeight = 52;
|
const double kAppTableFilterRowHeight = 44;
|
||||||
|
|
||||||
/// Horizontal gap between column search fields.
|
/// Horizontal gap between columns (header, filter row, and data cells).
|
||||||
const double kAppTableFilterGap = 8;
|
const double kAppTableColumnGap = 12;
|
||||||
|
|
||||||
class AppDataColumn<T> {
|
class AppDataColumn<T> {
|
||||||
const AppDataColumn({
|
const AppDataColumn({
|
||||||
@ -275,63 +275,76 @@ class _TableHeaderRow<T> extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: columns.map((col) {
|
children: [
|
||||||
final isSorted =
|
for (var i = 0; i < columns.length; i++) ...[
|
||||||
col.sortKey != null && col.sortKey == sortColumn;
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||||
final label = Text(
|
Expanded(
|
||||||
col.label.toUpperCase(),
|
flex: columns[i].flex,
|
||||||
maxLines: 1,
|
child: Padding(
|
||||||
overflow: TextOverflow.ellipsis,
|
padding: columns[i].padding,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
child: Align(
|
||||||
fontWeight: FontWeight.w700,
|
alignment: columns[i].alignment,
|
||||||
letterSpacing: 0.6,
|
child: _buildHeaderCell(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
theme: theme,
|
||||||
),
|
col: columns[i],
|
||||||
);
|
sortColumn: sortColumn,
|
||||||
|
sortAscending: sortAscending,
|
||||||
Widget header = label;
|
onSort: onSort,
|
||||||
if (col.sortKey != null && onSort != null) {
|
),
|
||||||
header = InkWell(
|
),
|
||||||
onTap: () => onSort!(
|
|
||||||
col.sortKey!,
|
|
||||||
isSorted ? !sortAscending : true,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Flexible(child: label),
|
|
||||||
if (isSorted) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
sortAscending
|
|
||||||
? Icons.arrow_upward
|
|
||||||
: Icons.arrow_downward,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Expanded(
|
|
||||||
flex: col.flex,
|
|
||||||
child: Padding(
|
|
||||||
padding: col.padding,
|
|
||||||
child: Align(
|
|
||||||
alignment: col.alignment,
|
|
||||||
child: header,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}).toList(),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildHeaderCell({
|
||||||
|
required ThemeData theme,
|
||||||
|
required AppDataColumn<T> col,
|
||||||
|
required String? sortColumn,
|
||||||
|
required bool sortAscending,
|
||||||
|
required void Function(String column, bool ascending)? onSort,
|
||||||
|
}) {
|
||||||
|
final isSorted = col.sortKey != null && col.sortKey == sortColumn;
|
||||||
|
final label = Text(
|
||||||
|
col.label.toUpperCase(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (col.sortKey == null || onSort == null) return label;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => onSort(
|
||||||
|
col.sortKey!,
|
||||||
|
isSorted ? !sortAscending : true,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(child: label),
|
||||||
|
if (isSorted) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
sortAscending ? Icons.arrow_upward : Icons.arrow_downward,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed first row under the header — one search field per searchable column.
|
/// Fixed first row under the header — one search field per searchable column.
|
||||||
@ -370,7 +383,7 @@ class _TableFilterRow<T> extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < columns.length; i++) ...[
|
for (var i = 0; i < columns.length; i++) ...[
|
||||||
if (i > 0) const SizedBox(width: kAppTableFilterGap),
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: columns[i].flex,
|
flex: columns[i].flex,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@ -527,21 +540,24 @@ class _TableDataRow<T> extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: columns.map((col) {
|
children: [
|
||||||
return Expanded(
|
for (var i = 0; i < columns.length; i++) ...[
|
||||||
flex: col.flex,
|
if (i > 0) const SizedBox(width: kAppTableColumnGap),
|
||||||
child: Padding(
|
Expanded(
|
||||||
padding: col.padding,
|
flex: columns[i].flex,
|
||||||
child: Align(
|
child: Padding(
|
||||||
alignment: col.alignment,
|
padding: columns[i].padding,
|
||||||
child: _TableCellSlot(
|
child: Align(
|
||||||
alignment: col.alignment,
|
alignment: columns[i].alignment,
|
||||||
child: col.cellBuilder(context, row),
|
child: _TableCellSlot(
|
||||||
|
alignment: columns[i].alignment,
|
||||||
|
child: columns[i].cellBuilder(context, row),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
}).toList(),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import '../utils/navigation_utils.dart';
|
|||||||
import 'app_sidebar.dart';
|
import 'app_sidebar.dart';
|
||||||
import 'app_top_nav.dart';
|
import 'app_top_nav.dart';
|
||||||
import 'theme_keyed_subtree.dart';
|
import 'theme_keyed_subtree.dart';
|
||||||
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
class AppShell extends ConsumerStatefulWidget {
|
class AppShell extends ConsumerStatefulWidget {
|
||||||
const AppShell({super.key, required this.child});
|
const AppShell({super.key, required this.child});
|
||||||
@ -73,7 +74,10 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
tooltip: 'All Screens',
|
tooltip: 'All Screens',
|
||||||
onPressed: () => context.go(RouteConstants.screenGallery),
|
onPressed: () => context.go(RouteConstants.screenGallery),
|
||||||
),
|
),
|
||||||
_UserMenu(userName: user?.name),
|
_UserMenu(
|
||||||
|
userName: user?.name,
|
||||||
|
avatarUrl: user?.avatarUrl,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: _AppDrawer(
|
drawer: _AppDrawer(
|
||||||
@ -179,9 +183,10 @@ class _AppDrawer extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _UserMenu extends ConsumerWidget {
|
class _UserMenu extends ConsumerWidget {
|
||||||
const _UserMenu({this.userName});
|
const _UserMenu({this.userName, this.avatarUrl});
|
||||||
|
|
||||||
final String? userName;
|
final String? userName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -191,9 +196,10 @@ class _UserMenu extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
UserAvatar(
|
||||||
|
name: userName ?? 'User',
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
child: Text((userName ?? 'U')[0].toUpperCase()),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
if (!context.isMobile) Text(userName ?? 'User'),
|
if (!context.isMobile) Text(userName ?? 'User'),
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import '../models/user_model.dart';
|
|||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../routes/menu_config.dart' as menu;
|
import '../routes/menu_config.dart' as menu;
|
||||||
import 'sidebar_logo.dart';
|
import 'sidebar_logo.dart';
|
||||||
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
const _sidebarExpandedWidth = 280.0;
|
const _sidebarExpandedWidth = 280.0;
|
||||||
const _sidebarCollapsedWidth = 72.0;
|
const _sidebarCollapsedWidth = 72.0;
|
||||||
@ -380,6 +381,11 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
final user = widget.user;
|
final user = widget.user;
|
||||||
final name = user?.name ?? 'User';
|
final name = user?.name ?? 'User';
|
||||||
final email = user?.email ?? '';
|
final email = user?.email ?? '';
|
||||||
|
final avatar = UserAvatar(
|
||||||
|
name: name,
|
||||||
|
avatarUrl: user?.avatarUrl,
|
||||||
|
radius: 18,
|
||||||
|
);
|
||||||
|
|
||||||
if (isNarrow) {
|
if (isNarrow) {
|
||||||
return Padding(
|
return Padding(
|
||||||
@ -388,7 +394,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
child: _UserProfileMenu(
|
child: _UserProfileMenu(
|
||||||
userName: name,
|
userName: name,
|
||||||
menuOffset: const Offset(-8, -210),
|
menuOffset: const Offset(-8, -210),
|
||||||
child: _UserAvatar(name: name),
|
child: avatar,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -404,7 +410,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_UserAvatar(name: name),
|
avatar,
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -947,29 +953,6 @@ class _ThemeToggleOption extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _UserAvatar extends StatelessWidget {
|
|
||||||
const _UserAvatar({required this.name});
|
|
||||||
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return CircleAvatar(
|
|
||||||
radius: 18,
|
|
||||||
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
|
|
||||||
child: Text(
|
|
||||||
name.isNotEmpty ? name[0].toUpperCase() : 'U',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _UserProfileMenu extends ConsumerWidget {
|
class _UserProfileMenu extends ConsumerWidget {
|
||||||
const _UserProfileMenu({
|
const _UserProfileMenu({
|
||||||
this.userName,
|
this.userName,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import '../models/user_model.dart';
|
|||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../routes/menu_config.dart' as menu;
|
import '../routes/menu_config.dart' as menu;
|
||||||
import 'sidebar_logo.dart';
|
import 'sidebar_logo.dart';
|
||||||
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
class AppTopNav extends ConsumerWidget {
|
class AppTopNav extends ConsumerWidget {
|
||||||
const AppTopNav({
|
const AppTopNav({
|
||||||
@ -111,7 +112,10 @@ class AppTopNav extends ConsumerWidget {
|
|||||||
tooltip: 'All Screens',
|
tooltip: 'All Screens',
|
||||||
onPressed: () => context.go(RouteConstants.screenGallery),
|
onPressed: () => context.go(RouteConstants.screenGallery),
|
||||||
),
|
),
|
||||||
_TopNavUserMenu(userName: user?.name),
|
_TopNavUserMenu(
|
||||||
|
userName: user?.name,
|
||||||
|
avatarUrl: user?.avatarUrl,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -120,9 +124,10 @@ class AppTopNav extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TopNavUserMenu extends ConsumerWidget {
|
class _TopNavUserMenu extends ConsumerWidget {
|
||||||
const _TopNavUserMenu({this.userName});
|
const _TopNavUserMenu({this.userName, this.avatarUrl});
|
||||||
|
|
||||||
final String? userName;
|
final String? userName;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -133,9 +138,10 @@ class _TopNavUserMenu extends ConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
UserAvatar(
|
||||||
|
name: userName ?? 'User',
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
child: Text((userName ?? 'U')[0].toUpperCase()),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(userName ?? 'User'),
|
Text(userName ?? 'User'),
|
||||||
|
|||||||
52
lib/shared/widgets/user_avatar.dart
Normal file
52
lib/shared/widgets/user_avatar.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/utils/media_url.dart';
|
||||||
|
|
||||||
|
/// Circular user avatar with network image fallback to name initial.
|
||||||
|
class UserAvatar extends StatelessWidget {
|
||||||
|
const UserAvatar({
|
||||||
|
super.key,
|
||||||
|
required this.name,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.radius = 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final double radius;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final resolved = resolveMediaUrl(avatarUrl);
|
||||||
|
final initial = name.trim().isNotEmpty ? name.trim()[0].toUpperCase() : 'U';
|
||||||
|
|
||||||
|
final fallback = Text(
|
||||||
|
initial,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: radius * 0.85,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||||
|
backgroundImage: null,
|
||||||
|
child: resolved == null || resolved.isEmpty
|
||||||
|
? fallback
|
||||||
|
: ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: resolved,
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Center(child: fallback),
|
||||||
|
errorWidget: (_, __, ___) => Center(child: fallback),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user