- Remove avatar handling from profile update to prevent overwriting DB with display URL - Skip ensureParent for .avatars to speed up upload - Add change detection for display name save button - Update API client to not send avatarUrl in profile update
270 lines
7.0 KiB
Dart
270 lines
7.0 KiB
Dart
import 'package:dio/dio.dart';
|
|
import 'package:http_parser/http_parser.dart';
|
|
import '../models/api_error.dart';
|
|
import '../blocs/session/session_bloc.dart';
|
|
import '../blocs/session/session_event.dart';
|
|
import '../blocs/session/session_state.dart';
|
|
|
|
class ApiClient {
|
|
late final Dio _dio;
|
|
final SessionBloc _sessionBloc;
|
|
|
|
ApiClient(this._sessionBloc, {String baseUrl = 'https://go.b0esche.cloud'}) {
|
|
_dio = Dio(
|
|
BaseOptions(
|
|
baseUrl: baseUrl,
|
|
connectTimeout: const Duration(seconds: 10),
|
|
receiveTimeout: const Duration(
|
|
seconds: 120,
|
|
), // Increased for file uploads and org operations
|
|
),
|
|
);
|
|
|
|
_dio.interceptors.add(
|
|
InterceptorsWrapper(
|
|
onRequest: (options, handler) {
|
|
// Add JWT if available
|
|
final token = _getCurrentToken();
|
|
if (token != null) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
}
|
|
return handler.next(options);
|
|
},
|
|
onError: (error, handler) async {
|
|
if (error.response?.statusCode == 401) {
|
|
final path = error.requestOptions.path;
|
|
// Do not expire session for auth endpoints; show inline error instead
|
|
final isAuthEndpoint = path.startsWith('/auth/');
|
|
if (!isAuthEndpoint) {
|
|
// Session expired, trigger logout
|
|
_sessionBloc.add(SessionExpired());
|
|
}
|
|
}
|
|
return handler.next(error);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
String get baseUrl => _dio.options.baseUrl;
|
|
|
|
String? get currentToken => _getCurrentToken();
|
|
|
|
String? _getCurrentToken() {
|
|
// Get from SessionBloc state
|
|
final state = _sessionBloc.state;
|
|
if (state is SessionActive) {
|
|
return state.token;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Future<T> get<T>(
|
|
String path, {
|
|
Map<String, dynamic>? queryParameters,
|
|
required T Function(dynamic data) fromJson,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
|
return fromJson(response.data);
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getRaw(
|
|
String path, {
|
|
Map<String, dynamic>? queryParameters,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<T> post<T>(
|
|
String path, {
|
|
dynamic data,
|
|
required T Function(dynamic data) fromJson,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.post(path, data: data);
|
|
|
|
return fromJson(response.data);
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<List<int>> getBytes(String path) async {
|
|
try {
|
|
final response = await _dio.get(
|
|
path,
|
|
options: Options(responseType: ResponseType.bytes),
|
|
);
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> postRaw(String path, {dynamic data}) async {
|
|
try {
|
|
final response = await _dio.post(path, data: data);
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<T> patch<T>(
|
|
String path, {
|
|
dynamic data,
|
|
required T Function(dynamic data) fromJson,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.patch(path, data: data);
|
|
return fromJson(response.data);
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<T> put<T>(
|
|
String path, {
|
|
dynamic data,
|
|
required T Function(dynamic data) fromJson,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.put(path, data: data);
|
|
return fromJson(response.data);
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> putRaw(String path, {dynamic data}) async {
|
|
try {
|
|
final response = await _dio.put(path, data: data);
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<void> delete(String path) async {
|
|
try {
|
|
await _dio.delete(path);
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<List<T>> getList<T>(
|
|
String path, {
|
|
Map<String, dynamic>? queryParameters,
|
|
required T Function(dynamic data) fromJson,
|
|
}) async {
|
|
try {
|
|
final response = await _dio.get(path, queryParameters: queryParameters);
|
|
final data = response.data;
|
|
if (data == null) return [];
|
|
return (data as List).map(fromJson).toList();
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
// User profile methods
|
|
Future<Map<String, dynamic>> getUserProfile() async {
|
|
return getRaw('/user/profile');
|
|
}
|
|
|
|
Future<Map<String, dynamic>> updateUserProfile({
|
|
required String displayName,
|
|
String? email,
|
|
}) async {
|
|
final data = <String, dynamic>{'displayName': displayName};
|
|
if (email != null) data['email'] = email;
|
|
|
|
return putRaw('/user/profile', data: data);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> changePassword({
|
|
required String currentPassword,
|
|
required String newPassword,
|
|
}) async {
|
|
return postRaw(
|
|
'/user/change-password',
|
|
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
|
|
);
|
|
}
|
|
|
|
// Avatar upload
|
|
Future<Map<String, dynamic>> uploadAvatar(
|
|
List<int> imageBytes,
|
|
String filename, {
|
|
ProgressCallback? onSendProgress,
|
|
}) async {
|
|
final formData = FormData.fromMap({
|
|
'avatar': MultipartFile.fromBytes(
|
|
imageBytes,
|
|
filename: filename,
|
|
contentType: MediaType('image', filename.split('.').last),
|
|
),
|
|
});
|
|
|
|
try {
|
|
final response = await _dio.post(
|
|
'/user/avatar',
|
|
data: formData,
|
|
onSendProgress: onSendProgress,
|
|
);
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> deleteAccount() async {
|
|
try {
|
|
final response = await _dio.delete('/user/account');
|
|
return response.data;
|
|
} on DioException catch (e) {
|
|
throw _handleError(e);
|
|
}
|
|
}
|
|
|
|
ApiError _handleError(DioException e) {
|
|
final status = e.response?.statusCode;
|
|
final data = e.response?.data;
|
|
|
|
// Handle network errors
|
|
if (e.type == DioExceptionType.connectionError ||
|
|
e.type == DioExceptionType.connectionTimeout ||
|
|
e.type == DioExceptionType.receiveTimeout ||
|
|
e.type == DioExceptionType.sendTimeout) {
|
|
return ApiError(
|
|
code: 'NETWORK_ERROR',
|
|
message: 'Network error. Please check your connection and try again.',
|
|
status: status,
|
|
);
|
|
}
|
|
|
|
// Only try to extract code/message if data is a Map
|
|
String code = 'UNKNOWN';
|
|
String message = 'Unknown error';
|
|
|
|
if (data is Map<String, dynamic>) {
|
|
code = data['code'] ?? 'UNKNOWN';
|
|
message = data['message'] ?? 'Unknown error';
|
|
} else if (data != null) {
|
|
message = data.toString();
|
|
}
|
|
|
|
return ApiError(code: code, message: message, status: status);
|
|
}
|
|
}
|