feat: Adds user image field and profile page

Adds an `image` field to the user data structure to support profile pictures.

Introduces a new profile page and sets up navigation from the home screen.

Changes the product list page provider to auto dispose for improved resource management.
This commit is contained in:
mandreshope 2025-07-04 12:17:13 +03:00
parent 6c3f2b80b0
commit b1a4005235
11 changed files with 458 additions and 14 deletions

View File

@ -11,6 +11,7 @@ part 'user_struct.g.dart';
abstract class UserStruct with _$UserStruct {
factory UserStruct({
String? id,
String? image,
String? firstName,
String? lastName,
String? email,

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$UserStruct {
String? get id; String? get firstName; String? get lastName; String? get email; String? get phone;
String? get id; String? get image; String? get firstName; String? get lastName; String? get email; String? get phone;
/// Create a copy of UserStruct
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -29,16 +29,16 @@ $UserStructCopyWith<UserStruct> get copyWith => _$UserStructCopyWithImpl<UserStr
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is UserStruct&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.email, email) || other.email == email)&&(identical(other.phone, phone) || other.phone == phone));
return identical(this, other) || (other.runtimeType == runtimeType&&other is UserStruct&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.email, email) || other.email == email)&&(identical(other.phone, phone) || other.phone == phone));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,firstName,lastName,email,phone);
int get hashCode => Object.hash(runtimeType,id,image,firstName,lastName,email,phone);
@override
String toString() {
return 'UserStruct(id: $id, firstName: $firstName, lastName: $lastName, email: $email, phone: $phone)';
return 'UserStruct(id: $id, image: $image, firstName: $firstName, lastName: $lastName, email: $email, phone: $phone)';
}
@ -49,7 +49,7 @@ abstract mixin class $UserStructCopyWith<$Res> {
factory $UserStructCopyWith(UserStruct value, $Res Function(UserStruct) _then) = _$UserStructCopyWithImpl;
@useResult
$Res call({
String? id, String? firstName, String? lastName, String? email, String? phone
String? id, String? image, String? firstName, String? lastName, String? email, String? phone
});
@ -66,9 +66,10 @@ class _$UserStructCopyWithImpl<$Res>
/// Create a copy of UserStruct
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? firstName = freezed,Object? lastName = freezed,Object? email = freezed,Object? phone = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? image = freezed,Object? firstName = freezed,Object? lastName = freezed,Object? email = freezed,Object? phone = freezed,}) {
return _then(_self.copyWith(
id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String?,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
@ -84,10 +85,11 @@ as String?,
@JsonSerializable()
class _UserStruct implements UserStruct {
_UserStruct({this.id, this.firstName, this.lastName, this.email, this.phone});
_UserStruct({this.id, this.image, this.firstName, this.lastName, this.email, this.phone});
factory _UserStruct.fromJson(Map<String, dynamic> json) => _$UserStructFromJson(json);
@override final String? id;
@override final String? image;
@override final String? firstName;
@override final String? lastName;
@override final String? email;
@ -106,16 +108,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserStruct&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.email, email) || other.email == email)&&(identical(other.phone, phone) || other.phone == phone));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UserStruct&&(identical(other.id, id) || other.id == id)&&(identical(other.image, image) || other.image == image)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.email, email) || other.email == email)&&(identical(other.phone, phone) || other.phone == phone));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,firstName,lastName,email,phone);
int get hashCode => Object.hash(runtimeType,id,image,firstName,lastName,email,phone);
@override
String toString() {
return 'UserStruct(id: $id, firstName: $firstName, lastName: $lastName, email: $email, phone: $phone)';
return 'UserStruct(id: $id, image: $image, firstName: $firstName, lastName: $lastName, email: $email, phone: $phone)';
}
@ -126,7 +128,7 @@ abstract mixin class _$UserStructCopyWith<$Res> implements $UserStructCopyWith<$
factory _$UserStructCopyWith(_UserStruct value, $Res Function(_UserStruct) _then) = __$UserStructCopyWithImpl;
@override @useResult
$Res call({
String? id, String? firstName, String? lastName, String? email, String? phone
String? id, String? image, String? firstName, String? lastName, String? email, String? phone
});
@ -143,9 +145,10 @@ class __$UserStructCopyWithImpl<$Res>
/// Create a copy of UserStruct
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? firstName = freezed,Object? lastName = freezed,Object? email = freezed,Object? phone = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? image = freezed,Object? firstName = freezed,Object? lastName = freezed,Object? email = freezed,Object? phone = freezed,}) {
return _then(_UserStruct(
id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String?,image: freezed == image ? _self.image : image // ignore: cast_nullable_to_non_nullable
as String?,firstName: freezed == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
as String?,lastName: freezed == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
as String?,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable

View File

@ -8,6 +8,7 @@ part of 'user_struct.dart';
_UserStruct _$UserStructFromJson(Map<String, dynamic> json) => _UserStruct(
id: json['id'] as String?,
image: json['image'] as String?,
firstName: json['firstName'] as String?,
lastName: json['lastName'] as String?,
email: json['email'] as String?,
@ -17,6 +18,7 @@ _UserStruct _$UserStructFromJson(Map<String, dynamic> json) => _UserStruct(
Map<String, dynamic> _$UserStructToJson(_UserStruct instance) =>
<String, dynamic>{
'id': instance.id,
'image': instance.image,
'firstName': instance.firstName,
'lastName': instance.lastName,
'email': instance.email,

View File

@ -60,8 +60,14 @@ class _HomePageState extends ConsumerState<HomePage> {
),
Divider(),
ListTile(
onTap: () {
ProfileRoute().push(context);
},
leading: Icon(Icons.person),
title: Text('Profil', style: AppTheme.of(context).bodyLarge),
title: Text(
'Mon Profil',
style: AppTheme.of(context).bodyLarge,
),
),
ListTile(
onTap: () {

View File

@ -4,3 +4,4 @@ export 'product_form_page/product_form_page.dart';
export 'scanner_page/scanner_page.dart';
export 'splash_page/splash_page.dart';
export 'product_list_page/product_list_page.dart';
export 'profile/profile_page.dart';

View File

@ -9,7 +9,10 @@ part 'product_list_page_model.freezed.dart';
/// The provider for the AuthViewModel, using Riverpod's StateNotifierProvider
/// with autoDispose to manage the lifecycle of the view model.
final productListPageModelProvider =
StateNotifierProvider<ProductListPageModel, ProductListPageState>((ref) {
StateNotifierProvider.autoDispose<
ProductListPageModel,
ProductListPageState
>((ref) {
return ProductListPageModel();
});

View File

@ -0,0 +1,178 @@
import 'package:barcode_scanner/backend/schema/user/user_struct.dart';
import 'package:barcode_scanner/components/primary_button_component.dart';
import 'package:barcode_scanner/pages/profile/profile_page_model.dart';
import 'package:barcode_scanner/themes/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProfilePage extends ConsumerStatefulWidget {
const ProfilePage({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _ProfilePageState();
}
class _ProfilePageState extends ConsumerState<ProfilePage> {
final _formKey = GlobalKey<FormState>();
final TextEditingController phone = TextEditingController();
final TextEditingController firstName = TextEditingController();
final TextEditingController lastName = TextEditingController();
final TextEditingController email = TextEditingController();
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((_) {
ref
.read(profilePageModelProvider.notifier)
.getMe(
onSuccess: (value) {
phone.text = value?.phone ?? '';
firstName.text = value?.firstName ?? '';
lastName.text = value?.lastName ?? '';
email.text = value?.email ?? '';
},
);
});
}
@override
void dispose() {
phone.dispose();
firstName.dispose();
lastName.dispose();
email.dispose();
super.dispose();
}
InputDecoration _inputStyle(String label) {
return InputDecoration(
labelText: label,
filled: true,
fillColor: AppTheme.of(context).secondaryBackground,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(profilePageModelProvider);
final user = state.user;
return Scaffold(
backgroundColor: AppTheme.of(context).primaryBackground,
appBar: AppBar(
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: AppTheme.of(context).primaryText,
),
onPressed: () {
Navigator.of(context).pop();
},
),
title: Text('Mon Profil', style: AppTheme.of(context).titleLarge),
centerTitle: true,
backgroundColor: AppTheme.of(context).primaryBackground,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Avatar
CircleAvatar(
radius: 50,
backgroundImage: user?.image != null
? NetworkImage(user!.image!)
: const AssetImage('assets/images/default_avatar.png')
as ImageProvider,
),
const SizedBox(height: 16),
// Nom
Text(
user?.fullName.isNotEmpty == true
? user?.fullName ?? ''
: 'Nom non renseigné',
style: AppTheme.of(context).titleLarge,
),
const SizedBox(height: 8),
Text(
user?.email ?? 'Adresse email non renseignée',
style: AppTheme.of(
context,
).bodyMedium.override(color: AppTheme.of(context).secondaryText),
),
const SizedBox(height: 24),
Divider(),
const SizedBox(height: 24),
Form(
key: _formKey,
child: Column(
children: [
/// Nom du produit
TextFormField(
controller: firstName,
decoration: _inputStyle("Prénom"),
validator: (value) => (value == null || value.isEmpty)
? 'Champ requis'
: null,
),
const SizedBox(height: 16),
/// Code barre / code produit
TextFormField(
controller: lastName,
decoration: _inputStyle("Nom"),
validator: (value) => (value == null || value.isEmpty)
? 'Champ requis'
: null,
),
const SizedBox(height: 16),
/// Description
TextFormField(
controller: phone,
decoration: _inputStyle("Phone"),
),
const SizedBox(height: 16),
/// Prix
TextFormField(
controller: email,
decoration: _inputStyle("Email"),
keyboardType: TextInputType.emailAddress,
validator: (value) => (value == null || value.isEmpty)
? 'Champ requis'
: null,
),
const SizedBox(height: 24),
/// Bouton sauvegarder
SizedBox(
width: 200,
child: PrimaryButtonComponent(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Traitement ici
}
},
text: 'Modifier',
),
),
],
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,39 @@
import 'package:barcode_scanner/backend/objectbox/entities/product/product_entity.dart';
import 'package:barcode_scanner/backend/objectbox/objectbox_manager.dart';
import 'package:barcode_scanner/backend/schema/user/user_struct.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_page_model.freezed.dart';
/// The provider for the AuthViewModel, using Riverpod's StateNotifierProvider
/// with autoDispose to manage the lifecycle of the view model.
final profilePageModelProvider =
StateNotifierProvider.autoDispose<ProfilePageModel, ProfilePageState>((
ref,
) {
return ProfilePageModel();
});
class ProfilePageModel extends StateNotifier<ProfilePageState> {
/// Constructor initializes the TaskRepository using the provider reference.
ProfilePageModel() : super(const ProfilePageState());
final productStore = objectboxManager.store.box<ProductEntity>();
Future getMe({Function(UserStruct? value)? onSuccess}) async {
state = state.copyWith(loading: true);
final me = await UserStruct(id: '1').getFromLocalStorage();
onSuccess?.call(me);
state = state.copyWith(user: me, loading: false);
}
}
@freezed
abstract class ProfilePageState with _$ProfilePageState {
const factory ProfilePageState({
UserStruct? user,
@Default(false) bool loading,
}) = _ProfilePageState;
}

View File

@ -0,0 +1,181 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'profile_page_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProfilePageState implements DiagnosticableTreeMixin {
UserStruct? get user; bool get loading;
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProfilePageStateCopyWith<ProfilePageState> get copyWith => _$ProfilePageStateCopyWithImpl<ProfilePageState>(this as ProfilePageState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ProfilePageState'))
..add(DiagnosticsProperty('user', user))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProfilePageState&&(identical(other.user, user) || other.user == user)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,user,loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ProfilePageState(user: $user, loading: $loading)';
}
}
/// @nodoc
abstract mixin class $ProfilePageStateCopyWith<$Res> {
factory $ProfilePageStateCopyWith(ProfilePageState value, $Res Function(ProfilePageState) _then) = _$ProfilePageStateCopyWithImpl;
@useResult
$Res call({
UserStruct? user, bool loading
});
$UserStructCopyWith<$Res>? get user;
}
/// @nodoc
class _$ProfilePageStateCopyWithImpl<$Res>
implements $ProfilePageStateCopyWith<$Res> {
_$ProfilePageStateCopyWithImpl(this._self, this._then);
final ProfilePageState _self;
final $Res Function(ProfilePageState) _then;
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? user = freezed,Object? loading = null,}) {
return _then(_self.copyWith(
user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as UserStruct?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$UserStructCopyWith<$Res>? get user {
if (_self.user == null) {
return null;
}
return $UserStructCopyWith<$Res>(_self.user!, (value) {
return _then(_self.copyWith(user: value));
});
}
}
/// @nodoc
class _ProfilePageState with DiagnosticableTreeMixin implements ProfilePageState {
const _ProfilePageState({this.user, this.loading = false});
@override final UserStruct? user;
@override@JsonKey() final bool loading;
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProfilePageStateCopyWith<_ProfilePageState> get copyWith => __$ProfilePageStateCopyWithImpl<_ProfilePageState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ProfilePageState'))
..add(DiagnosticsProperty('user', user))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProfilePageState&&(identical(other.user, user) || other.user == user)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,user,loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ProfilePageState(user: $user, loading: $loading)';
}
}
/// @nodoc
abstract mixin class _$ProfilePageStateCopyWith<$Res> implements $ProfilePageStateCopyWith<$Res> {
factory _$ProfilePageStateCopyWith(_ProfilePageState value, $Res Function(_ProfilePageState) _then) = __$ProfilePageStateCopyWithImpl;
@override @useResult
$Res call({
UserStruct? user, bool loading
});
@override $UserStructCopyWith<$Res>? get user;
}
/// @nodoc
class __$ProfilePageStateCopyWithImpl<$Res>
implements _$ProfilePageStateCopyWith<$Res> {
__$ProfilePageStateCopyWithImpl(this._self, this._then);
final _ProfilePageState _self;
final $Res Function(_ProfilePageState) _then;
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? user = freezed,Object? loading = null,}) {
return _then(_ProfilePageState(
user: freezed == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as UserStruct?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of ProfilePageState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$UserStructCopyWith<$Res>? get user {
if (_self.user == null) {
return null;
}
return $UserStructCopyWith<$Res>(_self.user!, (value) {
return _then(_self.copyWith(user: value));
});
}
}
// dart format on

View File

@ -13,6 +13,7 @@ const String _homePage = 'HomePage';
const String _scannerPage = 'ScannerPage';
const String _productFormPage = 'ProductFormPage';
const String _productListPage = 'ProductListPage';
const String _profilePage = "ProfilePage";
// Groupe des routes sécurisées
@TypedGoRoute<SecureRoute>(
@ -22,6 +23,7 @@ const String _productListPage = 'ProductListPage';
TypedGoRoute<ScannerRoute>(path: _scannerPage),
TypedGoRoute<ProductFormRoute>(path: _productFormPage),
TypedGoRoute<ProductListRoute>(path: _productListPage),
TypedGoRoute<ProfileRoute>(path: _profilePage),
],
)
class SecureRoute extends GoRouteData with _$SecureRoute {
@ -70,3 +72,10 @@ class ProductListRoute extends GoRouteData with _$ProductListRoute {
@override
Widget build(BuildContext context, GoRouterState state) => ProductListPage();
}
class ProfileRoute extends GoRouteData with _$ProfileRoute {
const ProfileRoute();
@override
Widget build(BuildContext context, GoRouterState state) => ProfilePage();
}

View File

@ -25,6 +25,7 @@ RouteBase get $secureRoute => GoRouteData.$route(
factory: _$ProductListRoute._fromState,
),
GoRouteData.$route(path: 'ProfilePage', factory: _$ProfileRoute._fromState),
],
);
@ -134,3 +135,23 @@ mixin _$ProductListRoute on GoRouteData {
@override
void replace(BuildContext context) => context.replace(location);
}
mixin _$ProfileRoute on GoRouteData {
static ProfileRoute _fromState(GoRouterState state) => const ProfileRoute();
@override
String get location => GoRouteData.$location('/SecurePage/ProfilePage');
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}