table alignment

This commit is contained in:
Surendiran 2026-07-15 15:42:34 +05:30
parent f871af752b
commit c462374125
24 changed files with 1542 additions and 441 deletions

View File

@ -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';

View File

@ -17,6 +17,7 @@ class AuthInterceptor extends Interceptor {
ApiEndpoints.refreshToken,
ApiEndpoints.logout,
ApiEndpoints.forgotPassword,
ApiEndpoints.resetPassword,
ApiEndpoints.verifyOtp,
};

View File

@ -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<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 {
final response = await dio.post<Map<String, dynamic>>(
ApiEndpoints.verifyOtp,

View File

@ -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<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
Future<Result<LoginResponse>> verifyOtp(OtpVerifyRequest request) async {
return safeApiCall(() async {

View File

@ -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<Result<UserModel>> getCurrentUser();
Future<Result<AuthTokens>> refreshSession();
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<void>> changePassword(ChangePasswordRequest request);
Future<bool> isAuthenticated();

View File

@ -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<ChangePasswordScreen> createState() => _ChangePasswordScreenState();
ConsumerState<ChangePasswordScreen> createState() =>
_ChangePasswordScreenState();
}
class _ChangePasswordScreenState extends State<ChangePasswordScreen> {
class _ChangePasswordScreenState extends ConsumerState<ChangePasswordScreen> {
final _formKey = GlobalKey<FormState>();
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<ChangePasswordScreen> {
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
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. Youll 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),
),
),
);

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
enum _AuthBackFace { forgot, reset }
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
final _loginFormKey = GlobalKey<FormState>();
final _forgotFormKey = GlobalKey<FormState>();
final _resetFormKey = GlobalKey<FormState>();
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<double> _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<LoginScreen>
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<LoginScreen>
_emailController.dispose();
_passwordController.dispose();
_forgotEmailController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.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 {
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<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 _ensureCardHeightLocked();
await _flipController.forward();
}
void _flipToSignIn() {
if (_flipController.isAnimating || _isForgotLoading || !_showingForgot) {
Future<void> _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<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({
required LoginColors colors,
required Widget child,
@ -415,62 +588,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
],
),
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<LoginScreen>
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<LoginScreen>
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

View File

@ -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',

View File

@ -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(

View File

@ -143,10 +143,12 @@ class UserRemoteDataSource {
Future<ManagedUserModel> 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<String, dynamic>);
return ManagedUserModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Map<String, dynamic> _exportQueryToMap(UserListQuery query) {

View File

@ -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<UserProfileScreen> {
final _formKey = GlobalKey<FormState>();
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<UserProfileScreen> {
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<void> _pickImage() async {
Future<void> _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<UserProfileScreen> {
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<UserProfileScreen> {
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<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
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<UserProfileScreen> {
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<UserProfileScreen> {
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<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'),
),
],
),
),

View File

@ -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<String, dynamic> json) =>

View File

@ -5031,9 +5031,11 @@ UpdateProfileRequest _$UpdateProfileRequestFromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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.

View File

@ -392,14 +392,16 @@ _$UpdateProfileRequestImpl _$$UpdateProfileRequestImplFromJson(
Map<String, dynamic> 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<String, dynamic> _$$UpdateProfileRequestImplToJson(
_$UpdateProfileRequestImpl instance,
) => <String, dynamic>{
'full_name': instance.fullName,
'name': instance.name,
'email': instance.email,
'mobile': instance.mobile,
'avatar_url': instance.avatarUrl,
};

View File

@ -201,3 +201,15 @@ class ForgotPasswordRequest with _$ForgotPasswordRequest {
factory ForgotPasswordRequest.fromJson(Map<String, dynamic> 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);
}

View File

@ -1696,3 +1696,222 @@ abstract class _ForgotPasswordRequest implements ForgotPasswordRequest {
_$$ForgotPasswordRequestImplCopyWith<_$ForgotPasswordRequestImpl>
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;
}

View File

@ -126,3 +126,19 @@ _$ForgotPasswordRequestImpl _$$ForgotPasswordRequestImplFromJson(
Map<String, dynamic> _$$ForgotPasswordRequestImplToJson(
_$ForgotPasswordRequestImpl instance,
) => <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,
};

View File

@ -101,6 +101,19 @@ class AuthNotifier extends StateNotifier<AuthState> {
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).
void onSessionExpired() {
state = const AuthState(

View File

@ -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<GoRouter>((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<GoRouter>((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,

View File

@ -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<T> {
const AppDataColumn({
@ -275,63 +275,76 @@ class _TableHeaderRow<T> 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<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.
@ -370,7 +383,7 @@ class _TableFilterRow<T> 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<T> 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(),
],
],
),
),
),

View File

@ -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<AppShell> {
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'),

View File

@ -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<AppSidebar> {
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<AppSidebar> {
child: _UserProfileMenu(
userName: name,
menuOffset: const Offset(-8, -210),
child: _UserAvatar(name: name),
child: avatar,
),
),
);
@ -404,7 +410,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
),
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,

View File

@ -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'),

View 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),
),
),
);
}
}