Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
146
lib/features/explore/data/repositories/network_repository.dart
Normal file
146
lib/features/explore/data/repositories/network_repository.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import 'package:unionflow_mobile_apps/core/network/api_client.dart';
|
||||
import 'package:unionflow_mobile_apps/core/utils/logger.dart';
|
||||
import '../../domain/entities/network_item.dart';
|
||||
|
||||
/// Repository pour la recherche réseau (membres + organisations).
|
||||
/// Délègue la recherche au backend Quarkus.
|
||||
@lazySingleton
|
||||
class NetworkRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
NetworkRepository(this._apiClient);
|
||||
|
||||
List<dynamic> _parseListResponse(dynamic data) {
|
||||
if (data is List) return data;
|
||||
if (data is Map && data.containsKey('content')) {
|
||||
final content = data['content'];
|
||||
return content is List ? content : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Recherche de membres (GET /api/membres/recherche?q=...)
|
||||
Future<List<NetworkItem>> searchMembers(String query, {int page = 0, int size = 20}) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'/api/membres/recherche',
|
||||
queryParameters: {'q': query.trim(), 'page': page, 'size': size},
|
||||
);
|
||||
final data = _parseListResponse(response.data);
|
||||
return data.map((json) => _memberFromJson(json as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 400) return [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche d'organisations (GET /api/organisations/recherche?nom=...)
|
||||
Future<List<NetworkItem>> searchOrganizations(String query, {int page = 0, int size = 20}) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
try {
|
||||
final response = await _apiClient.get(
|
||||
'/api/organisations/recherche',
|
||||
queryParameters: {'nom': query.trim(), 'page': page, 'size': size},
|
||||
);
|
||||
final data = _parseListResponse(response.data);
|
||||
return data.map((json) => _organisationFromJson(json as Map<String, dynamic>)).toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 400) return [];
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recherche globale : membres + organisations (deux appels parallèles).
|
||||
/// Si [followedIds] est fourni, les membres dont l'id est dans le set auront [isConnected: true].
|
||||
Future<List<NetworkItem>> search(String query, {int page = 0, int size = 10, Set<String>? followedIds}) async {
|
||||
if (query.trim().isEmpty) return [];
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
searchMembers(query, page: page, size: size),
|
||||
searchOrganizations(query, page: page, size: size),
|
||||
]);
|
||||
final members = results[0].map((m) {
|
||||
if (followedIds != null && followedIds.contains(m.id)) return m.copyWith(isConnected: true);
|
||||
return m;
|
||||
}).toList();
|
||||
final orgs = results[1];
|
||||
return [...members, ...orgs];
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Liste des ids des membres suivis par l'utilisateur connecté (GET /api/membres/me/suivis).
|
||||
Future<List<String>> getFollowedIds() async {
|
||||
try {
|
||||
final response = await _apiClient.get('/api/membres/me/suivis');
|
||||
if (response.statusCode != 200) return [];
|
||||
final data = response.data;
|
||||
if (data is! List) return [];
|
||||
return data.map((e) => e.toString()).toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) return [];
|
||||
AppLogger.error('NetworkRepository: getFollowedIds échoué', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Suivre un membre (POST /api/membres/{id}/suivre). Retourne true si following après l'appel.
|
||||
Future<bool> follow(String memberId) async {
|
||||
try {
|
||||
final response = await _apiClient.post('/api/membres/$memberId/suivre');
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return (response.data as Map)['following'] == true;
|
||||
}
|
||||
return false;
|
||||
} on DioException catch (e, st) {
|
||||
AppLogger.error('NetworkRepository: follow échoué', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ne plus suivre un membre (DELETE /api/membres/{id}/suivre). Retourne false (plus suivi).
|
||||
Future<bool> unfollow(String memberId) async {
|
||||
try {
|
||||
final response = await _apiClient.delete('/api/membres/$memberId/suivre');
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
return (response.data as Map)['following'] == true;
|
||||
}
|
||||
return false;
|
||||
} on DioException catch (e, st) {
|
||||
AppLogger.error('NetworkRepository: unfollow échoué', error: e, stackTrace: st);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static NetworkItem _memberFromJson(Map<String, dynamic> json) {
|
||||
final id = json['id']?.toString() ?? '';
|
||||
final prenom = json['prenom']?.toString() ?? '';
|
||||
final nom = json['nom']?.toString() ?? '';
|
||||
final name = '$prenom $nom'.trim().isEmpty ? (json['numeroMembre']?.toString() ?? id) : '$prenom $nom'.trim();
|
||||
return NetworkItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: json['profession']?.toString() ?? json['statutCompteLibelle']?.toString(),
|
||||
avatarUrl: null,
|
||||
type: 'Member',
|
||||
isConnected: false,
|
||||
);
|
||||
}
|
||||
|
||||
static NetworkItem _organisationFromJson(Map<String, dynamic> json) {
|
||||
final id = json['id']?.toString() ?? '';
|
||||
return NetworkItem(
|
||||
id: id,
|
||||
name: json['nom']?.toString() ?? json['nomCourt']?.toString() ?? 'Organisation',
|
||||
subtitle: json['typeOrganisationLibelle']?.toString() ?? json['statutLibelle']?.toString(),
|
||||
avatarUrl: null,
|
||||
type: 'Organization',
|
||||
isConnected: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
41
lib/features/explore/domain/entities/network_item.dart
Normal file
41
lib/features/explore/domain/entities/network_item.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Entité représentant un membre ou une organisation dans la recherche réseau.
|
||||
class NetworkItem extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final String? avatarUrl;
|
||||
final String type; // 'Member', 'Organization'
|
||||
final bool isConnected;
|
||||
|
||||
const NetworkItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.subtitle,
|
||||
this.avatarUrl,
|
||||
required this.type,
|
||||
this.isConnected = false,
|
||||
});
|
||||
|
||||
NetworkItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? subtitle,
|
||||
String? avatarUrl,
|
||||
String? type,
|
||||
bool? isConnected,
|
||||
}) {
|
||||
return NetworkItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
subtitle: subtitle ?? this.subtitle,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
type: type ?? this.type,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, subtitle, avatarUrl, type, isConnected];
|
||||
}
|
||||
83
lib/features/explore/presentation/bloc/network_bloc.dart
Normal file
83
lib/features/explore/presentation/bloc/network_bloc.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../../core/utils/logger.dart';
|
||||
import '../../data/repositories/network_repository.dart';
|
||||
import 'network_event.dart';
|
||||
import 'network_state.dart';
|
||||
|
||||
@injectable
|
||||
class NetworkBloc extends Bloc<NetworkEvent, NetworkState> {
|
||||
final NetworkRepository _repository;
|
||||
|
||||
NetworkBloc(this._repository) : super(NetworkInitial()) {
|
||||
on<LoadNetworkRequested>(_onLoadNetworkRequested);
|
||||
on<SearchNetworkRequested>(_onSearchNetworkRequested);
|
||||
on<ToggleFollowRequested>(_onToggleFollowRequested);
|
||||
}
|
||||
|
||||
Future<void> _onToggleFollowRequested(ToggleFollowRequested event, Emitter<NetworkState> emit) async {
|
||||
if (state is! NetworkLoaded) return;
|
||||
final current = state as NetworkLoaded;
|
||||
NetworkItem? item;
|
||||
for (final i in current.items) {
|
||||
if (i.id == event.itemId) {
|
||||
item = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (item == null) return;
|
||||
// Seuls les membres (type Member) sont persistés côté backend ; Organisation reste local.
|
||||
if (item.type != 'Member') {
|
||||
final items = current.items.map((i) {
|
||||
if (i.id == event.itemId) return i.copyWith(isConnected: !i.isConnected);
|
||||
return i;
|
||||
}).toList();
|
||||
emit(NetworkLoaded(items: items, currentQuery: current.currentQuery));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final bool newFollowing = item.isConnected
|
||||
? await _repository.unfollow(event.itemId)
|
||||
: await _repository.follow(event.itemId);
|
||||
final items = current.items.map((i) {
|
||||
if (i.id == event.itemId) return i.copyWith(isConnected: newFollowing);
|
||||
return i;
|
||||
}).toList();
|
||||
emit(NetworkLoaded(items: items, currentQuery: current.currentQuery));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NetworkBloc: toggle follow échoué', error: e, stackTrace: st);
|
||||
emit(const NetworkError('Impossible de mettre à jour le suivi. Réessayez.'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadNetworkRequested(LoadNetworkRequested event, Emitter<NetworkState> emit) async {
|
||||
emit(NetworkLoading());
|
||||
try {
|
||||
final followedIds = await _repository.getFollowedIds();
|
||||
final items = await _repository.search('', followedIds: followedIds.toSet());
|
||||
emit(NetworkLoaded(items: items, currentQuery: ''));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NetworkBloc: chargement réseau échoué', error: e, stackTrace: st);
|
||||
emit(NetworkError('Erreur chargement réseau : $e'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearchNetworkRequested(SearchNetworkRequested event, Emitter<NetworkState> emit) async {
|
||||
emit(NetworkLoading());
|
||||
try {
|
||||
if (event.query.trim().isEmpty) {
|
||||
final followedIds = await _repository.getFollowedIds();
|
||||
final items = await _repository.search('', followedIds: followedIds.toSet());
|
||||
emit(NetworkLoaded(items: items, currentQuery: ''));
|
||||
return;
|
||||
}
|
||||
final followedIds = await _repository.getFollowedIds();
|
||||
final items = await _repository.search(event.query, followedIds: followedIds.toSet());
|
||||
emit(NetworkLoaded(items: items, currentQuery: event.query));
|
||||
} catch (e, st) {
|
||||
AppLogger.error('NetworkBloc: recherche réseau échouée', error: e, stackTrace: st);
|
||||
emit(NetworkError('Erreur de recherche : $e'));
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lib/features/explore/presentation/bloc/network_event.dart
Normal file
29
lib/features/explore/presentation/bloc/network_event.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class NetworkEvent extends Equatable {
|
||||
const NetworkEvent();
|
||||
|
||||
@override
|
||||
List<Object> get props => [];
|
||||
}
|
||||
|
||||
class LoadNetworkRequested extends NetworkEvent {}
|
||||
|
||||
class SearchNetworkRequested extends NetworkEvent {
|
||||
final String query;
|
||||
|
||||
const SearchNetworkRequested(this.query);
|
||||
|
||||
@override
|
||||
List<Object> get props => [query];
|
||||
}
|
||||
|
||||
/// Bascule Suivre / Ne plus suivre pour un item (membre ou organisation).
|
||||
class ToggleFollowRequested extends NetworkEvent {
|
||||
final String itemId;
|
||||
|
||||
const ToggleFollowRequested(this.itemId);
|
||||
|
||||
@override
|
||||
List<Object> get props => [itemId];
|
||||
}
|
||||
38
lib/features/explore/presentation/bloc/network_state.dart
Normal file
38
lib/features/explore/presentation/bloc/network_state.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../domain/entities/network_item.dart';
|
||||
|
||||
export '../../domain/entities/network_item.dart';
|
||||
|
||||
abstract class NetworkState extends Equatable {
|
||||
const NetworkState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class NetworkInitial extends NetworkState {}
|
||||
|
||||
class NetworkLoading extends NetworkState {}
|
||||
|
||||
class NetworkLoaded extends NetworkState {
|
||||
final List<NetworkItem> items;
|
||||
final String currentQuery;
|
||||
|
||||
const NetworkLoaded({
|
||||
required this.items,
|
||||
this.currentQuery = '',
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [items, currentQuery];
|
||||
}
|
||||
|
||||
class NetworkError extends NetworkState {
|
||||
final String message;
|
||||
|
||||
const NetworkError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
Reference in New Issue
Block a user