From c462374125354184308c3646107d6802fb8e444f Mon Sep 17 00:00:00 2001 From: SurendarSuri30 Date: Wed, 15 Jul 2026 15:42:34 +0530 Subject: [PATCH] table alignment --- lib/core/constants/api_endpoints.dart | 3 + lib/core/network/auth_interceptor.dart | 1 + .../datasources/auth_remote_data_source.dart | 46 ++ .../repositories/auth_repository_impl.dart | 29 + .../domain/repositories/auth_repository.dart | 7 + .../screens/change_password_screen.dart | 534 ++++++++++++++++-- .../presentation/screens/login_screen.dart | 471 ++++++++++----- .../screens/purchase_order_list_screen.dart | 9 +- .../screens/depreciation_report_screen.dart | 6 +- .../datasources/user_remote_data_source.dart | 6 +- .../screens/user_profile_screen.dart | 244 ++++---- lib/shared/models/user_management_models.dart | 4 +- .../user_management_models.freezed.dart | 76 ++- .../models/user_management_models.g.dart | 6 +- lib/shared/models/user_model.dart | 12 + lib/shared/models/user_model.freezed.dart | 219 +++++++ lib/shared/models/user_model.g.dart | 16 + lib/shared/providers/auth_provider.dart | 13 + lib/shared/routes/app_router.dart | 20 +- lib/shared/widgets/app_data_table.dart | 148 ++--- lib/shared/widgets/app_shell.dart | 14 +- lib/shared/widgets/app_sidebar.dart | 33 +- lib/shared/widgets/app_top_nav.dart | 14 +- lib/shared/widgets/user_avatar.dart | 52 ++ 24 files changed, 1542 insertions(+), 441 deletions(-) create mode 100644 lib/shared/widgets/user_avatar.dart diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 3b878e9..92facc7 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -6,9 +6,12 @@ class ApiEndpoints { static const String logout = '/auth/logout'; static const String refreshToken = '/auth/refresh'; static const String forgotPassword = '/auth/forgot-password'; + static const String resetPassword = '/auth/reset-password'; static const String changePassword = '/auth/change-password'; static const String verifyOtp = '/auth/verify-otp'; static const String me = '/auth/me'; + static const String updateProfile = '/auth/profile'; + static const String profileAvatar = '/auth/profile/avatar'; // Companies static const String companies = '/companies'; diff --git a/lib/core/network/auth_interceptor.dart b/lib/core/network/auth_interceptor.dart index 7105e81..75d0aa4 100644 --- a/lib/core/network/auth_interceptor.dart +++ b/lib/core/network/auth_interceptor.dart @@ -17,6 +17,7 @@ class AuthInterceptor extends Interceptor { ApiEndpoints.refreshToken, ApiEndpoints.logout, ApiEndpoints.forgotPassword, + ApiEndpoints.resetPassword, ApiEndpoints.verifyOtp, }; diff --git a/lib/modules/auth/data/datasources/auth_remote_data_source.dart b/lib/modules/auth/data/datasources/auth_remote_data_source.dart index e9ec9ca..c885ca6 100644 --- a/lib/modules/auth/data/datasources/auth_remote_data_source.dart +++ b/lib/modules/auth/data/datasources/auth_remote_data_source.dart @@ -4,6 +4,7 @@ import '../../../../core/constants/api_endpoints.dart'; import '../../../../core/network/api_envelope.dart'; import '../../../../core/services/permission_matrix_api_parser.dart'; import '../../../../core/utils/jwt_utils.dart'; +import '../../../../core/utils/media_url.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_model.dart'; @@ -80,6 +81,51 @@ class AuthRemoteDataSource { ); } + Future resetPassword(ResetPasswordRequest request) async { + final response = await dio.post>( + ApiEndpoints.resetPassword, + data: request.toJson(), + ); + ApiEnvelope.ensureSuccess( + response.data ?? const {}, + statusCode: response.statusCode, + ); + } + + Future updateProfile(UpdateProfileRequest request) async { + final payload = request.toJson()..removeWhere((_, v) => v == null); + final response = await dio.put>( + ApiEndpoints.updateProfile, + data: payload, + ); + final data = ApiEnvelope.data(response); + return UserModel.fromLoginJson(data); + } + + Future uploadProfileAvatar({ + required List bytes, + required String filename, + }) async { + final formData = FormData.fromMap({ + 'avatar': MultipartFile.fromBytes(bytes, filename: filename), + }); + final response = await dio.post>( + 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 verifyOtp(OtpVerifyRequest request) async { final response = await dio.post>( ApiEndpoints.verifyOtp, diff --git a/lib/modules/auth/data/repositories/auth_repository_impl.dart b/lib/modules/auth/data/repositories/auth_repository_impl.dart index ede24c5..b52b7be 100644 --- a/lib/modules/auth/data/repositories/auth_repository_impl.dart +++ b/lib/modules/auth/data/repositories/auth_repository_impl.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/network/api_handler.dart'; import '../../../../core/network/dio_client.dart'; import '../../../../core/network/token_storage.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_model.dart'; import '../../domain/repositories/auth_repository.dart'; import '../datasources/auth_remote_data_source.dart'; @@ -79,6 +80,34 @@ class AuthRepositoryImpl implements AuthRepository { return safeApiCall(() => remote.forgotPassword(request)); } + @override + Future> resetPassword(ResetPasswordRequest request) async { + return safeApiCall(() => remote.resetPassword(request)); + } + + @override + Future> 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> uploadProfileAvatar({ + required List bytes, + required String filename, + }) async { + return safeApiCall( + () => remote.uploadProfileAvatar(bytes: bytes, filename: filename), + ); + } + @override Future> verifyOtp(OtpVerifyRequest request) async { return safeApiCall(() async { diff --git a/lib/modules/auth/domain/repositories/auth_repository.dart b/lib/modules/auth/domain/repositories/auth_repository.dart index 79a9c0e..2dd67f8 100644 --- a/lib/modules/auth/domain/repositories/auth_repository.dart +++ b/lib/modules/auth/domain/repositories/auth_repository.dart @@ -1,4 +1,5 @@ import '../../../../core/network/api_handler.dart'; +import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/models/user_model.dart'; abstract class AuthRepository { @@ -7,6 +8,12 @@ abstract class AuthRepository { Future> getCurrentUser(); Future> refreshSession(); Future> forgotPassword(ForgotPasswordRequest request); + Future> resetPassword(ResetPasswordRequest request); + Future> updateProfile(UpdateProfileRequest request); + Future> uploadProfileAvatar({ + required List bytes, + required String filename, + }); Future> verifyOtp(OtpVerifyRequest request); Future> changePassword(ChangePasswordRequest request); Future isAuthenticated(); diff --git a/lib/modules/auth/presentation/screens/change_password_screen.dart b/lib/modules/auth/presentation/screens/change_password_screen.dart index 5d5d926..42e7a0c 100644 --- a/lib/modules/auth/presentation/screens/change_password_screen.dart +++ b/lib/modules/auth/presentation/screens/change_password_screen.dart @@ -1,23 +1,39 @@ 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 '../../../../shared/models/user_model.dart'; +import '../../../../shared/providers/auth_provider.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/app_text_field.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}); @override - State createState() => _ChangePasswordScreenState(); + ConsumerState createState() => + _ChangePasswordScreenState(); } -class _ChangePasswordScreenState extends State { +class _ChangePasswordScreenState extends ConsumerState { final _formKey = GlobalKey(); final _currentController = TextEditingController(); final _newController = TextEditingController(); final _confirmController = TextEditingController(); + bool _isLoading = false; + bool _obscureCurrent = true; + bool _obscureNew = true; + bool _obscureConfirm = true; + String? _errorMessage; + @override void dispose() { _currentController.dispose(); @@ -26,58 +42,476 @@ class _ChangePasswordScreenState extends State { super.dispose(); } + void _clearForm() { + _currentController.clear(); + _newController.clear(); + _confirmController.clear(); + _formKey.currentState?.reset(); + setState(() { + _errorMessage = null; + _obscureCurrent = true; + _obscureNew = true; + _obscureConfirm = true; + }); + } + + Future _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 Widget build(BuildContext context) { + final colors = LoginColors.of(context); + final canPop = context.canPop(); + return Scaffold( - appBar: AppBar(title: const Text('Change Password')), - body: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Form( - key: _formKey, - child: Column( - children: [ - 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(); - } - }, - ), - ], + backgroundColor: colors.bg, + body: Stack( + children: [ + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [colors.bgGradA, colors.bg], + ), ), ), ), + 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), ), ), ); diff --git a/lib/modules/auth/presentation/screens/login_screen.dart b/lib/modules/auth/presentation/screens/login_screen.dart index 899faa7..9681cfb 100644 --- a/lib/modules/auth/presentation/screens/login_screen.dart +++ b/lib/modules/auth/presentation/screens/login_screen.dart @@ -24,33 +24,47 @@ import '../widgets/login_colors.dart'; import '../widgets/login_hero_panel.dart'; 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 ConsumerState createState() => _LoginScreenState(); } +enum _AuthBackFace { forgot, reset } + class _LoginScreenState extends ConsumerState with SingleTickerProviderStateMixin { final _loginFormKey = GlobalKey(); final _forgotFormKey = GlobalKey(); + final _resetFormKey = GlobalKey(); final _cardMeasureKey = GlobalKey(); final _emailController = TextEditingController(); final _passwordController = TextEditingController(); final _forgotEmailController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); late final AnimationController _flipController; late final Animation _flipAnimation; bool _isLoading = false; bool _isForgotLoading = false; + bool _isResetLoading = false; bool _obscurePassword = true; + bool _obscureNewPassword = true; + bool _obscureConfirmPassword = true; bool _rememberMe = false; String? _forgotSuccessMessage; String? _forgotErrorMessage; + String? _resetErrorMessage; double? _cardHeight; + _AuthBackFace _backFace = _AuthBackFace.forgot; + String? _resetToken; - bool get _showingForgot => _flipAnimation.value > 0.5; + bool get _showingBack => _flipAnimation.value > 0.5; @override void initState() { @@ -64,6 +78,14 @@ class _LoginScreenState extends ConsumerState curve: 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()); } @@ -100,6 +122,8 @@ class _LoginScreenState extends ConsumerState _emailController.dispose(); _passwordController.dispose(); _forgotEmailController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); super.dispose(); } @@ -158,34 +182,108 @@ class _LoginScreenState extends ConsumerState ); } + Future _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 _ensureCardHeightLocked() async { + if (_cardHeight != null) return; + await Future.delayed(Duration.zero); + if (!mounted) return; + final box = + _cardMeasureKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + setState(() => _cardHeight = box.size.height); + } + } + Future _flipToForgot() async { - if (_flipController.isAnimating || _isLoading || _showingForgot) return; + if (_flipController.isAnimating || _isLoading || _showingBack) return; if (_forgotEmailController.text.isEmpty && _emailController.text.trim().isNotEmpty) { _forgotEmailController.text = _emailController.text.trim(); } setState(() { + _backFace = _AuthBackFace.forgot; _forgotSuccessMessage = null; _forgotErrorMessage = null; }); - // Lock card height from the sign-in face before flipping. - if (_cardHeight == null) { - await Future.delayed(Duration.zero); - if (!mounted) return; - final box = - _cardMeasureKey.currentContext?.findRenderObject() as RenderBox?; - if (box != null && box.hasSize) { - setState(() => _cardHeight = box.size.height); - } - } + await _ensureCardHeightLocked(); await _flipController.forward(); } - void _flipToSignIn() { - if (_flipController.isAnimating || _isForgotLoading || !_showingForgot) { + Future _flipToSignIn() async { + if (_flipController.isAnimating || + _isForgotLoading || + _isResetLoading || + !_showingBack) { 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({ @@ -269,6 +367,81 @@ class _LoginScreenState extends ConsumerState ); } + 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({ required LoginColors colors, required Widget child, @@ -415,62 +588,19 @@ class _LoginScreenState extends ConsumerState ], ), const SizedBox(height: 14), - _primaryButtonTheme( - colors: colors, - child: AppButton( - label: _isLoading ? 'Signing in…' : 'Sign in', - isLoading: _isLoading, - onPressed: _login, + _primaryButtonTheme( + colors: colors, + child: AppButton( + label: _isLoading ? 'Signing in…' : 'Sign in', + isLoading: _isLoading, + onPressed: _login, + ), ), - ), - 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, - ), - ), - ], - ), - 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: 26), + _secureAccessFooter(colors), + const SizedBox(height: 22), + _versionLabel(colors), + if (DevConfig.screenPreviewEnabled) ...[ const SizedBox(height: 20), Divider(color: colors.outlineSoft), const SizedBox(height: 14), @@ -591,69 +721,151 @@ class _LoginScreenState extends ConsumerState const Spacer() else const SizedBox(height: 22), - 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, + _backToSignInButton(colors), + const SizedBox(height: 18), + _secureAccessFooter(colors), + const SizedBox(height: 22), + _versionLabel(colors), + ], + ), + ); + } + + Widget _buildResetFace({ + 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), - 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, - ), - ), - ], - ), - const SizedBox(height: 22), - Text( - '${AppConstants.appName} · v${AppConstants.appVersion}', - textAlign: TextAlign.center, + TextFormField( + controller: _confirmPasswordController, + obscureText: _obscureConfirmPassword, + autofillHints: const [AutofillHints.newPassword], + validator: (v) { + if (v != _newPasswordController.text) { + return 'Passwords do not match'; + } + return Validators.required(v, fieldName: 'Confirm password'); + }, style: GoogleFonts.inter( - fontSize: 11, - letterSpacing: 0.3, - color: colors.onSurfaceVariant, + fontSize: 14.5, + color: colors.headingColor, + ), + 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 child: _buildSignInFace(colors: colors, logoUrl: logoUrl), ), ); - final backChild = _buildForgotFace( - colors: colors, - logoUrl: logoUrl, - ); + final backChild = _backFace == _AuthBackFace.reset + ? _buildResetFace(colors: colors, logoUrl: logoUrl) + : _buildForgotFace(colors: colors, logoUrl: logoUrl); final back = _cardShell( colors: colors, child: _cardHeight != null diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart index 23c294b..8c449b8 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_list_screen.dart @@ -352,8 +352,13 @@ class _PoDataTable extends StatelessWidget { label: 'Total', flex: 1, searchText: (order) => CurrencyFormatter.format(order.totalAmount), - cellBuilder: (_, order) => - Text(CurrencyFormatter.format(order.totalAmount)), + cellBuilder: (_, order) => SizedBox( + width: double.infinity, + child: AppTableCell.text( + CurrencyFormatter.format(order.totalAmount), + textAlign: TextAlign.right, + ), + ), ), AppDataColumn( label: 'Status', diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart index 45436c8..09122d8 100644 --- a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -668,7 +668,10 @@ class _ReportTable extends StatelessWidget { label: 'Method', flex: 2, searchText: (row) => row.depreciationMethod ?? '', - cellBuilder: (_, row) => AppTableCell.text(row.depreciationMethod), + cellBuilder: (_, row) => AppTableCell.text( + row.depreciationMethod, + placeholder: '-', + ), ), AppDataColumn( label: 'Rate %', @@ -678,6 +681,7 @@ class _ReportTable extends StatelessWidget { cellBuilder: (_, row) => AppTableCell.text( row.depreciationRate?.toStringAsFixed(2), textAlign: TextAlign.right, + placeholder: '-', ), ), AppDataColumn( diff --git a/lib/modules/users/data/datasources/user_remote_data_source.dart b/lib/modules/users/data/datasources/user_remote_data_source.dart index ce22e42..14a2b93 100644 --- a/lib/modules/users/data/datasources/user_remote_data_source.dart +++ b/lib/modules/users/data/datasources/user_remote_data_source.dart @@ -143,10 +143,12 @@ class UserRemoteDataSource { Future updateProfile(UpdateProfileRequest request) async { final response = await dio.put( - ApiEndpoints.me, + ApiEndpoints.updateProfile, data: request.toJson()..removeWhere((_, v) => v == null), ); - return ManagedUserModel.fromJson(response.data['data'] as Map); + return ManagedUserModel.fromJson( + response.data['data'] as Map, + ); } Map _exportQueryToMap(UserListQuery query) { diff --git a/lib/modules/users/presentation/screens/user_profile_screen.dart b/lib/modules/users/presentation/screens/user_profile_screen.dart index cdc8506..cf214b7 100644 --- a/lib/modules/users/presentation/screens/user_profile_screen.dart +++ b/lib/modules/users/presentation/screens/user_profile_screen.dart @@ -1,18 +1,16 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.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 '../../../../shared/models/user_management_models.dart'; -import '../../../../shared/models/user_model.dart'; import '../../../../shared/providers/auth_provider.dart'; import '../../../../shared/widgets/app_button.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/user_avatar.dart'; +import '../../../auth/data/repositories/auth_repository_impl.dart'; class UserProfileScreen extends ConsumerStatefulWidget { const UserProfileScreen({super.key}); @@ -23,14 +21,12 @@ class UserProfileScreen extends ConsumerStatefulWidget { class _UserProfileScreenState extends ConsumerState { final _formKey = GlobalKey(); + final _fullNameController = TextEditingController(); final _nameController = TextEditingController(); + final _emailController = TextEditingController(); final _mobileController = TextEditingController(); - final _currentPasswordController = TextEditingController(); - final _newPasswordController = TextEditingController(); - final _confirmPasswordController = TextEditingController(); - String? _avatarUrl; bool _isUpdatingProfile = false; - bool _isChangingPassword = false; + bool _isUploadingAvatar = false; @override void initState() { @@ -41,30 +37,76 @@ class _UserProfileScreenState extends ConsumerState { void _loadUser() { final user = ref.read(authStateProvider).user; if (user == null) return; + _fullNameController.text = user.name; _nameController.text = user.name; + _emailController.text = user.email; _mobileController.text = user.mobile; - _avatarUrl = user.avatarUrl; } @override void dispose() { + _fullNameController.dispose(); _nameController.dispose(); + _emailController.dispose(); _mobileController.dispose(); - _currentPasswordController.dispose(); - _newPasswordController.dispose(); - _confirmPasswordController.dispose(); super.dispose(); } - Future _pickImage() async { + Future _pickAndUploadAvatar() async { final result = await FilePicker.pickFiles( - type: FileType.image, - withData: false, + type: FileType.custom, + allowedExtensions: const ['jpg', 'jpeg', 'png', 'webp'], + withData: true, ); if (result == null || result.files.isEmpty) return; - setState(() => _avatarUrl = result.files.first.path); - showAppToastFromSnackBar(context, - const SnackBar(content: Text('Image selected. Upload will use avatar_url on save.')), + + final file = result.files.first; + 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 { if (!_formKey.currentState!.validate()) return; setState(() => _isUpdatingProfile = true); - final useCase = ref.read(updateProfileUseCaseProvider); - final result = await useCase( + final repository = ref.read(authRepositoryProvider); + final result = await repository.updateProfile( UpdateProfileRequest( - fullName: _nameController.text.trim(), + fullName: _fullNameController.text.trim(), + name: _nameController.text.trim(), + email: _emailController.text.trim(), mobile: _mobileController.text.trim(), - avatarUrl: _avatarUrl, ), ); @@ -85,68 +128,40 @@ class _UserProfileScreenState extends ConsumerState { setState(() => _isUpdatingProfile = false); if (result.failure != null) { - showAppToastFromSnackBar(context, - SnackBar(content: Text(result.failure.toString())), + showAppToastFromSnackBar( + context, + SnackBar( + content: Text(result.failure?.message ?? result.failure.toString()), + ), ); return; } - await ref.read(authStateProvider.notifier).checkAuth(); - showAppToastFromSnackBar(context, + if (result.data != null) { + 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')), ); } - Future _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 Widget build(BuildContext context) { final user = ref.watch(authStateProvider).user; + final theme = Theme.of(context); if (user == null) { return const Center(child: Text('Not signed in')); } + final avatarUrl = resolveMediaUrl(user.avatarUrl); + return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Center( @@ -155,28 +170,36 @@ class _UserProfileScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('User Profile', style: Theme.of(context).textTheme.headlineSmall), + Text( + 'User Profile', + style: theme.textTheme.headlineSmall, + ), const SizedBox(height: 24), Center( child: Stack( + clipBehavior: Clip.none, children: [ - CircleAvatar( + UserAvatar( + name: user.name, + avatarUrl: avatarUrl, 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( bottom: 0, right: 0, child: IconButton.filled( - onPressed: _pickImage, - icon: const Icon(Icons.camera_alt, size: 18), + onPressed: + _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 { child: Column( children: [ AppTextField( - controller: _nameController, - label: 'Name', - validator: (v) => Validators.required(v, fieldName: 'Name'), + controller: _fullNameController, + label: 'Full Name', + validator: (v) => + Validators.required(v, fieldName: 'Full Name'), ), const SizedBox(height: 16), - InputDecorator( - decoration: const InputDecoration(labelText: 'Email'), - child: Text(user.email), + AppTextField( + controller: _nameController, + 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), AppTextField( @@ -224,41 +257,6 @@ class _UserProfileScreenState extends ConsumerState { ], ), ), - 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'), - ), ], ), ), diff --git a/lib/shared/models/user_management_models.dart b/lib/shared/models/user_management_models.dart index f4698b9..39a9fda 100644 --- a/lib/shared/models/user_management_models.dart +++ b/lib/shared/models/user_management_models.dart @@ -415,8 +415,10 @@ class PermissionMatrixSaveRow with _$PermissionMatrixSaveRow { class UpdateProfileRequest with _$UpdateProfileRequest { const factory UpdateProfileRequest({ @JsonKey(name: 'full_name') String? fullName, + /// Alias for [fullName] — API accepts both `name` and `full_name`. + String? name, + String? email, String? mobile, - @JsonKey(name: 'avatar_url') String? avatarUrl, }) = _UpdateProfileRequest; factory UpdateProfileRequest.fromJson(Map json) => diff --git a/lib/shared/models/user_management_models.freezed.dart b/lib/shared/models/user_management_models.freezed.dart index dd20cdf..fc0fc80 100644 --- a/lib/shared/models/user_management_models.freezed.dart +++ b/lib/shared/models/user_management_models.freezed.dart @@ -5031,9 +5031,11 @@ UpdateProfileRequest _$UpdateProfileRequestFromJson(Map json) { mixin _$UpdateProfileRequest { @JsonKey(name: 'full_name') 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; - @JsonKey(name: 'avatar_url') - String? get avatarUrl => throw _privateConstructorUsedError; /// Serializes this UpdateProfileRequest to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -5054,8 +5056,9 @@ abstract class $UpdateProfileRequestCopyWith<$Res> { @useResult $Res call({ @JsonKey(name: 'full_name') String? fullName, + String? name, + String? email, String? mobile, - @JsonKey(name: 'avatar_url') String? avatarUrl, }); } @@ -5078,8 +5081,9 @@ class _$UpdateProfileRequestCopyWithImpl< @override $Res call({ Object? fullName = freezed, + Object? name = freezed, + Object? email = freezed, Object? mobile = freezed, - Object? avatarUrl = freezed, }) { return _then( _value.copyWith( @@ -5087,14 +5091,18 @@ class _$UpdateProfileRequestCopyWithImpl< ? _value.fullName : fullName // ignore: cast_nullable_to_non_nullable 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 ? _value.mobile : mobile // ignore: cast_nullable_to_non_nullable as String?, - avatarUrl: freezed == avatarUrl - ? _value.avatarUrl - : avatarUrl // ignore: cast_nullable_to_non_nullable - as String?, ) as $Val, ); @@ -5112,8 +5120,9 @@ abstract class _$$UpdateProfileRequestImplCopyWith<$Res> @useResult $Res call({ @JsonKey(name: 'full_name') String? fullName, + String? name, + String? email, String? mobile, - @JsonKey(name: 'avatar_url') String? avatarUrl, }); } @@ -5132,8 +5141,9 @@ class __$$UpdateProfileRequestImplCopyWithImpl<$Res> @override $Res call({ Object? fullName = freezed, + Object? name = freezed, + Object? email = freezed, Object? mobile = freezed, - Object? avatarUrl = freezed, }) { return _then( _$UpdateProfileRequestImpl( @@ -5141,14 +5151,18 @@ class __$$UpdateProfileRequestImplCopyWithImpl<$Res> ? _value.fullName : fullName // ignore: cast_nullable_to_non_nullable 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 ? _value.mobile : mobile // ignore: cast_nullable_to_non_nullable 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 { const _$UpdateProfileRequestImpl({ @JsonKey(name: 'full_name') this.fullName, + this.name, + this.email, this.mobile, - @JsonKey(name: 'avatar_url') this.avatarUrl, }); factory _$UpdateProfileRequestImpl.fromJson(Map json) => @@ -5169,15 +5184,18 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest { @override @JsonKey(name: 'full_name') final String? fullName; + + /// Alias for [fullName] — API accepts both `name` and `full_name`. + @override + final String? name; + @override + final String? email; @override final String? mobile; - @override - @JsonKey(name: 'avatar_url') - final String? avatarUrl; @override String toString() { - return 'UpdateProfileRequest(fullName: $fullName, mobile: $mobile, avatarUrl: $avatarUrl)'; + return 'UpdateProfileRequest(fullName: $fullName, name: $name, email: $email, mobile: $mobile)'; } @override @@ -5187,14 +5205,14 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest { other is _$UpdateProfileRequestImpl && (identical(other.fullName, fullName) || other.fullName == fullName) && - (identical(other.mobile, mobile) || other.mobile == mobile) && - (identical(other.avatarUrl, avatarUrl) || - other.avatarUrl == avatarUrl)); + (identical(other.name, name) || other.name == name) && + (identical(other.email, email) || other.email == email) && + (identical(other.mobile, mobile) || other.mobile == mobile)); } @JsonKey(includeFromJson: false, includeToJson: false) @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 /// with the given fields replaced by the non-null parameter values. @@ -5217,8 +5235,9 @@ class _$UpdateProfileRequestImpl implements _UpdateProfileRequest { abstract class _UpdateProfileRequest implements UpdateProfileRequest { const factory _UpdateProfileRequest({ @JsonKey(name: 'full_name') final String? fullName, + final String? name, + final String? email, final String? mobile, - @JsonKey(name: 'avatar_url') final String? avatarUrl, }) = _$UpdateProfileRequestImpl; factory _UpdateProfileRequest.fromJson(Map json) = @@ -5227,11 +5246,14 @@ abstract class _UpdateProfileRequest implements UpdateProfileRequest { @override @JsonKey(name: 'full_name') String? get fullName; + + /// Alias for [fullName] — API accepts both `name` and `full_name`. + @override + String? get name; + @override + String? get email; @override String? get mobile; - @override - @JsonKey(name: 'avatar_url') - String? get avatarUrl; /// Create a copy of UpdateProfileRequest /// with the given fields replaced by the non-null parameter values. diff --git a/lib/shared/models/user_management_models.g.dart b/lib/shared/models/user_management_models.g.dart index 04bcf84..138ffb3 100644 --- a/lib/shared/models/user_management_models.g.dart +++ b/lib/shared/models/user_management_models.g.dart @@ -392,14 +392,16 @@ _$UpdateProfileRequestImpl _$$UpdateProfileRequestImplFromJson( Map json, ) => _$UpdateProfileRequestImpl( fullName: json['full_name'] as String?, + name: json['name'] as String?, + email: json['email'] as String?, mobile: json['mobile'] as String?, - avatarUrl: json['avatar_url'] as String?, ); Map _$$UpdateProfileRequestImplToJson( _$UpdateProfileRequestImpl instance, ) => { 'full_name': instance.fullName, + 'name': instance.name, + 'email': instance.email, 'mobile': instance.mobile, - 'avatar_url': instance.avatarUrl, }; diff --git a/lib/shared/models/user_model.dart b/lib/shared/models/user_model.dart index 49e2b74..00a1b0b 100644 --- a/lib/shared/models/user_model.dart +++ b/lib/shared/models/user_model.dart @@ -201,3 +201,15 @@ class ForgotPasswordRequest with _$ForgotPasswordRequest { factory ForgotPasswordRequest.fromJson(Map 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 json) => + _$ResetPasswordRequestFromJson(json); +} diff --git a/lib/shared/models/user_model.freezed.dart b/lib/shared/models/user_model.freezed.dart index 6a00ba9..64f155b 100644 --- a/lib/shared/models/user_model.freezed.dart +++ b/lib/shared/models/user_model.freezed.dart @@ -1696,3 +1696,222 @@ abstract class _ForgotPasswordRequest implements ForgotPasswordRequest { _$$ForgotPasswordRequestImplCopyWith<_$ForgotPasswordRequestImpl> get copyWith => throw _privateConstructorUsedError; } + +ResetPasswordRequest _$ResetPasswordRequestFromJson(Map 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 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 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 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 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 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; +} diff --git a/lib/shared/models/user_model.g.dart b/lib/shared/models/user_model.g.dart index 370386c..1fe13c2 100644 --- a/lib/shared/models/user_model.g.dart +++ b/lib/shared/models/user_model.g.dart @@ -126,3 +126,19 @@ _$ForgotPasswordRequestImpl _$$ForgotPasswordRequestImplFromJson( Map _$$ForgotPasswordRequestImplToJson( _$ForgotPasswordRequestImpl instance, ) => {'email': instance.email}; + +_$ResetPasswordRequestImpl _$$ResetPasswordRequestImplFromJson( + Map json, +) => _$ResetPasswordRequestImpl( + token: json['token'] as String, + newPassword: json['new_password'] as String, + confirmPassword: json['confirm_password'] as String, +); + +Map _$$ResetPasswordRequestImplToJson( + _$ResetPasswordRequestImpl instance, +) => { + 'token': instance.token, + 'new_password': instance.newPassword, + 'confirm_password': instance.confirmPassword, +}; diff --git a/lib/shared/providers/auth_provider.dart b/lib/shared/providers/auth_provider.dart index c71d0b6..ce6e04c 100644 --- a/lib/shared/providers/auth_provider.dart +++ b/lib/shared/providers/auth_provider.dart @@ -101,6 +101,19 @@ class AuthNotifier extends StateNotifier { 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 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). void onSessionExpired() { state = const AuthState( diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 04202c5..984a32c 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -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/forgot_password_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/master_data/domain/entities/master_definition.dart'; import '../../modules/master_data/presentation/screens/master_list_screen.dart'; @@ -93,7 +92,13 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( 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( path: RouteConstants.forgotPassword, @@ -102,8 +107,15 @@ final routerProvider = Provider((ref) { ), GoRoute( path: RouteConstants.resetPassword, - builder: (context, state) => - _themedRoute(state, const ResetPasswordScreen()), + builder: (context, state) { + // 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( path: RouteConstants.verifyOtp, diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index ec970b5..a3204a6 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -3,13 +3,13 @@ import 'package:flutter/material.dart'; import 'app_card.dart'; /// 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). -const double kAppTableFilterRowHeight = 52; +const double kAppTableFilterRowHeight = 44; -/// Horizontal gap between column search fields. -const double kAppTableFilterGap = 8; +/// Horizontal gap between columns (header, filter row, and data cells). +const double kAppTableColumnGap = 12; class AppDataColumn { const AppDataColumn({ @@ -275,63 +275,76 @@ class _TableHeaderRow extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( - children: columns.map((col) { - 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, - ), - ); - - Widget header = label; - 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, + children: [ + for (var i = 0; i < columns.length; i++) ...[ + if (i > 0) const SizedBox(width: kAppTableColumnGap), + Expanded( + flex: columns[i].flex, + child: Padding( + padding: columns[i].padding, + child: Align( + alignment: columns[i].alignment, + child: _buildHeaderCell( + theme: theme, + col: columns[i], + sortColumn: sortColumn, + sortAscending: sortAscending, + onSort: onSort, + ), + ), ), ), - ); - }).toList(), + ], + ], ), ), ), ), ); } + + Widget _buildHeaderCell({ + required ThemeData theme, + required AppDataColumn 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. @@ -370,7 +383,7 @@ class _TableFilterRow extends StatelessWidget { child: Row( children: [ for (var i = 0; i < columns.length; i++) ...[ - if (i > 0) const SizedBox(width: kAppTableFilterGap), + if (i > 0) const SizedBox(width: kAppTableColumnGap), Expanded( flex: columns[i].flex, child: Padding( @@ -527,21 +540,24 @@ class _TableDataRow extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( crossAxisAlignment: CrossAxisAlignment.center, - children: columns.map((col) { - return Expanded( - flex: col.flex, - child: Padding( - padding: col.padding, - child: Align( - alignment: col.alignment, - child: _TableCellSlot( - alignment: col.alignment, - child: col.cellBuilder(context, row), + children: [ + for (var i = 0; i < columns.length; i++) ...[ + if (i > 0) const SizedBox(width: kAppTableColumnGap), + Expanded( + flex: columns[i].flex, + child: Padding( + padding: columns[i].padding, + child: Align( + alignment: columns[i].alignment, + child: _TableCellSlot( + alignment: columns[i].alignment, + child: columns[i].cellBuilder(context, row), + ), ), ), ), - ); - }).toList(), + ], + ], ), ), ), diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart index 69067eb..2151155 100644 --- a/lib/shared/widgets/app_shell.dart +++ b/lib/shared/widgets/app_shell.dart @@ -16,6 +16,7 @@ import '../utils/navigation_utils.dart'; import 'app_sidebar.dart'; import 'app_top_nav.dart'; import 'theme_keyed_subtree.dart'; +import 'user_avatar.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({super.key, required this.child}); @@ -73,7 +74,10 @@ class _AppShellState extends ConsumerState { tooltip: 'All Screens', onPressed: () => context.go(RouteConstants.screenGallery), ), - _UserMenu(userName: user?.name), + _UserMenu( + userName: user?.name, + avatarUrl: user?.avatarUrl, + ), ], ), drawer: _AppDrawer( @@ -179,9 +183,10 @@ class _AppDrawer extends ConsumerWidget { } class _UserMenu extends ConsumerWidget { - const _UserMenu({this.userName}); + const _UserMenu({this.userName, this.avatarUrl}); final String? userName; + final String? avatarUrl; @override Widget build(BuildContext context, WidgetRef ref) { @@ -191,9 +196,10 @@ class _UserMenu extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - CircleAvatar( + UserAvatar( + name: userName ?? 'User', + avatarUrl: avatarUrl, radius: 16, - child: Text((userName ?? 'U')[0].toUpperCase()), ), const SizedBox(width: 8), if (!context.isMobile) Text(userName ?? 'User'), diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index b0ff48a..d5fc96e 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -15,6 +15,7 @@ import '../models/user_model.dart'; import '../providers/auth_provider.dart'; import '../routes/menu_config.dart' as menu; import 'sidebar_logo.dart'; +import 'user_avatar.dart'; const _sidebarExpandedWidth = 280.0; const _sidebarCollapsedWidth = 72.0; @@ -380,6 +381,11 @@ class _AppSidebarState extends ConsumerState { final user = widget.user; final name = user?.name ?? 'User'; final email = user?.email ?? ''; + final avatar = UserAvatar( + name: name, + avatarUrl: user?.avatarUrl, + radius: 18, + ); if (isNarrow) { return Padding( @@ -388,7 +394,7 @@ class _AppSidebarState extends ConsumerState { child: _UserProfileMenu( userName: name, menuOffset: const Offset(-8, -210), - child: _UserAvatar(name: name), + child: avatar, ), ), ); @@ -404,7 +410,7 @@ class _AppSidebarState extends ConsumerState { ), child: Row( children: [ - _UserAvatar(name: name), + avatar, const SizedBox(width: 10), Expanded( 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 { const _UserProfileMenu({ this.userName, diff --git a/lib/shared/widgets/app_top_nav.dart b/lib/shared/widgets/app_top_nav.dart index c688c15..ec4abc2 100644 --- a/lib/shared/widgets/app_top_nav.dart +++ b/lib/shared/widgets/app_top_nav.dart @@ -12,6 +12,7 @@ import '../models/user_model.dart'; import '../providers/auth_provider.dart'; import '../routes/menu_config.dart' as menu; import 'sidebar_logo.dart'; +import 'user_avatar.dart'; class AppTopNav extends ConsumerWidget { const AppTopNav({ @@ -111,7 +112,10 @@ class AppTopNav extends ConsumerWidget { tooltip: 'All Screens', 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 { - const _TopNavUserMenu({this.userName}); + const _TopNavUserMenu({this.userName, this.avatarUrl}); final String? userName; + final String? avatarUrl; @override Widget build(BuildContext context, WidgetRef ref) { @@ -133,9 +138,10 @@ class _TopNavUserMenu extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - CircleAvatar( + UserAvatar( + name: userName ?? 'User', + avatarUrl: avatarUrl, radius: 16, - child: Text((userName ?? 'U')[0].toUpperCase()), ), const SizedBox(width: 8), Text(userName ?? 'User'), diff --git a/lib/shared/widgets/user_avatar.dart b/lib/shared/widgets/user_avatar.dart new file mode 100644 index 0000000..1e50489 --- /dev/null +++ b/lib/shared/widgets/user_avatar.dart @@ -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), + ), + ), + ); + } +}