table alignment
This commit is contained in:
parent
f871af752b
commit
c462374125
@ -6,9 +6,12 @@ class ApiEndpoints {
|
||||
static const String logout = '/auth/logout';
|
||||
static const String 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';
|
||||
|
||||
@ -17,6 +17,7 @@ class AuthInterceptor extends Interceptor {
|
||||
ApiEndpoints.refreshToken,
|
||||
ApiEndpoints.logout,
|
||||
ApiEndpoints.forgotPassword,
|
||||
ApiEndpoints.resetPassword,
|
||||
ApiEndpoints.verifyOtp,
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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. You’ll need to sign in again afterward.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13.5,
|
||||
height: 1.45,
|
||||
color: colors.subtitleColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
TextFormField(
|
||||
controller: _currentController,
|
||||
obscureText: _obscureCurrent,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
],
|
||||
validator: (v) => Validators.required(
|
||||
v,
|
||||
fieldName: 'Current password',
|
||||
),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Current password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: _visibilityToggle(
|
||||
colors: colors,
|
||||
obscure: _obscureCurrent,
|
||||
onPressed: () => setState(
|
||||
() =>
|
||||
_obscureCurrent = !_obscureCurrent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextFormField(
|
||||
controller: _newController,
|
||||
obscureText: _obscureNew,
|
||||
autofillHints: const [
|
||||
AutofillHints.newPassword
|
||||
],
|
||||
validator: Validators.password,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'New password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: _visibilityToggle(
|
||||
colors: colors,
|
||||
obscure: _obscureNew,
|
||||
onPressed: () => setState(
|
||||
() => _obscureNew = !_obscureNew,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
TextFormField(
|
||||
controller: _confirmController,
|
||||
obscureText: _obscureConfirm,
|
||||
autofillHints: const [
|
||||
AutofillHints.newPassword
|
||||
],
|
||||
validator: (v) {
|
||||
if (v != _newController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return Validators.password(v);
|
||||
},
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14.5,
|
||||
color: colors.headingColor,
|
||||
),
|
||||
decoration: _fieldDecoration(
|
||||
colors: colors,
|
||||
label: 'Confirm password',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
suffix: _visibilityToggle(
|
||||
colors: colors,
|
||||
obscure: _obscureConfirm,
|
||||
onPressed: () => setState(
|
||||
() =>
|
||||
_obscureConfirm = !_obscureConfirm,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: colors.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
elevatedButtonTheme:
|
||||
ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.primary,
|
||||
foregroundColor: colors.onPrimary,
|
||||
disabledBackgroundColor: colors.primary
|
||||
.withValues(alpha: 0.75),
|
||||
minimumSize:
|
||||
const Size(double.infinity, 52),
|
||||
elevation: 0,
|
||||
shadowColor: colors.primary
|
||||
.withValues(alpha: 0.35),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
textStyle: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: AppButton(
|
||||
label: _isLoading
|
||||
? 'Updating…'
|
||||
: 'Update Password',
|
||||
isLoading: _isLoading,
|
||||
onPressed: _submit,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: colors.outlineSoft,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
),
|
||||
child: Text(
|
||||
'SECURE ACCESS',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11.5,
|
||||
letterSpacing: 0.6,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: colors.outlineSoft,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.verified_user_outlined,
|
||||
size: 14,
|
||||
color: colors.success,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Your data is protected and encrypted',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AmbientBlob extends StatelessWidget {
|
||||
const _AmbientBlob({required this.color, required this.size});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -24,33 +24,47 @@ import '../widgets/login_colors.dart';
|
||||
import '../widgets/login_hero_panel.dart';
|
||||
|
||||
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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'),
|
||||
|
||||
52
lib/shared/widgets/user_avatar.dart
Normal file
52
lib/shared/widgets/user_avatar.dart
Normal file
@ -0,0 +1,52 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/utils/media_url.dart';
|
||||
|
||||
/// Circular user avatar with network image fallback to name initial.
|
||||
class UserAvatar extends StatelessWidget {
|
||||
const UserAvatar({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.avatarUrl,
|
||||
this.radius = 18,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String? avatarUrl;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final resolved = resolveMediaUrl(avatarUrl);
|
||||
final initial = name.trim().isNotEmpty ? name.trim()[0].toUpperCase() : 'U';
|
||||
|
||||
final fallback = Text(
|
||||
initial,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: radius * 0.85,
|
||||
),
|
||||
);
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||
backgroundImage: null,
|
||||
child: resolved == null || resolved.isEmpty
|
||||
? fallback
|
||||
: ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: resolved,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Center(child: fallback),
|
||||
errorWidget: (_, __, ___) => Center(child: fallback),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user