Files
Eventify-frontend/lib/core/api/api_client.dart
Sicherhaven c40e600937 feat(contribute): upload event images to OneDrive before submission
- ApiClient.uploadFile() — multipart POST to /v1/upload/file (60s timeout)
- ApiEndpoints.uploadFile — points to Node.js upload endpoint
- GamificationService.submitContribution() now uploads each picked image
  to OneDrive via the server upload pipeline, then passes the returned
  { fileId, url, ... } objects as `media` in the submission body
  (replaces broken behaviour of sending local device paths as strings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:12:49 +05:30

428 lines
15 KiB
Dart

// lib/core/api/api_client.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../storage/token_storage.dart';
class ApiClient {
static const Duration _timeout = Duration(seconds: 10);
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
static const bool _developmentMode = false;
/// POST request
///
/// - `url` should be a fully qualified endpoint (ApiEndpoints.*)
/// - `body` is the JSON object to send (Map)
/// - when `requiresAuth == true` token & username are added to the request body
Future<Map<String, dynamic>> post(
String url, {
Map<String, dynamic>? body,
bool requiresAuth = true,
}) async {
final headers = <String, String>{
'Content-Type': 'application/json',
};
final Map<String, dynamic> finalBody = await _buildAuthBody(body, requiresAuth);
late http.Response response;
try {
response = await http
.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(finalBody),
)
.timeout(_timeout);
} catch (e) {
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
// Development mode: return mock responses for common endpoints on network errors
if (_developmentMode) {
if (url.contains('/user/login/')) {
if (kDebugMode) debugPrint('Development mode: returning mock login response');
final email = finalBody['username'] ?? 'test@example.com';
return {
'token': 'mock_token_${DateTime.now().millisecondsSinceEpoch}',
'username': email,
'email': email,
'phone_number': '+1234567890',
};
} else if (url.contains('/user/register/')) {
if (kDebugMode) debugPrint('Development mode: returning mock register response');
final email = finalBody['email'] ?? 'test@example.com';
return {
'token': 'mock_token_${DateTime.now().millisecondsSinceEpoch}',
'username': email,
'email': email,
'phone_number': finalBody['phone_number'] ?? '+1234567890',
};
} else if (url.contains('/events/type-list/')) {
if (kDebugMode) debugPrint('Development mode: returning mock event types');
return {
'event_types': [
{'id': 1, 'event_type': 'Concert', 'event_type_icon': null},
{'id': 2, 'event_type': 'Workshop', 'event_type_icon': null},
{'id': 3, 'event_type': 'Festival', 'event_type_icon': null},
{'id': 4, 'event_type': 'Sports', 'event_type_icon': null},
{'id': 5, 'event_type': 'Conference', 'event_type_icon': null},
{'id': 6, 'event_type': 'Exhibition', 'event_type_icon': null},
],
};
} else if (url.contains('/events/pincode-events/')) {
if (kDebugMode) debugPrint('Development mode: returning mock events');
return {'events': _mockEvents};
} else if (url.contains('/events/event-details/')) {
if (kDebugMode) debugPrint('Development mode: returning mock event detail');
final eventId = finalBody['event_id'] ?? 1;
final match = _mockEvents.where((e) => e['id'] == eventId);
return match.isNotEmpty
? Map<String, dynamic>.from(match.first)
: Map<String, dynamic>.from(_mockEvents.first);
} else if (url.contains('/events/events-by-month-year/')) {
if (kDebugMode) debugPrint('Development mode: returning mock calendar');
return {
'total_number_of_events': 3,
'dates': ['2026-04-05', '2026-04-12', '2026-04-20'],
'date_events': [
{'date': '2026-04-05', 'count': 1},
{'date': '2026-04-12', 'count': 2},
{'date': '2026-04-20', 'count': 1},
],
};
}
}
throw Exception('Network error: $e');
}
return _handleResponse(url, response, finalBody);
}
/// Upload a single file as multipart/form-data.
///
/// Returns the `file` object from the server response:
/// `{ fileId, url, name, type, mimeType, size, backend }`
Future<Map<String, dynamic>> uploadFile(String url, String filePath) async {
final request = http.MultipartRequest('POST', Uri.parse(url));
request.files.add(await http.MultipartFile.fromPath('file', filePath));
late http.StreamedResponse streamed;
try {
streamed = await request.send().timeout(const Duration(seconds: 60));
} catch (e) {
throw Exception('Upload network error: $e');
}
final body = await streamed.stream.bytesToString();
dynamic decoded;
try {
decoded = jsonDecode(body);
} catch (_) {
throw Exception('Upload response parse error');
}
if (streamed.statusCode >= 200 && streamed.statusCode < 300) {
if (decoded is Map<String, dynamic> && decoded['file'] is Map) {
return Map<String, dynamic>.from(decoded['file'] as Map);
}
return decoded is Map<String, dynamic> ? decoded : {};
}
final msg = (decoded is Map && decoded['message'] is String)
? decoded['message'] as String
: 'Upload failed (${streamed.statusCode})';
throw Exception(msg);
}
/// GET request
///
/// - If requiresAuth==true, token & username will be attached as query parameters.
/// - `params` will be appended as query parameters.
Future<Map<String, dynamic>> get(
String url, {
Map<String, dynamic>? params,
bool requiresAuth = true,
}) async {
// build final query params including auth if needed
final originalUri = Uri.parse(url);
final queryParams = <String, String>{...originalUri.queryParameters};
if (requiresAuth) {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (token != null && username != null) {
queryParams['token'] = token;
queryParams['username'] = username;
}
// Guest mode: proceed without token — let backend decide
}
if (params != null) {
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
}
final uri = originalUri.replace(queryParameters: queryParams);
late http.Response response;
try {
response = await http.get(uri).timeout(_timeout);
} catch (e) {
if (kDebugMode) debugPrint('ApiClient.get network error: $e');
throw Exception('Network error: $e');
}
return _handleResponse(url, response, queryParams);
}
// ---------------------------------------------------------------------------
// Mock event data for development / offline mode
// ---------------------------------------------------------------------------
static final List<Map<String, dynamic>> _mockEvents = [
{
'id': 1,
'name': 'Tech Innovation Summit 2026',
'title': 'Tech Innovation Summit',
'description':
'Join industry leaders for a two-day summit exploring the latest breakthroughs in AI, cloud computing, and sustainable technology. Featuring keynote speakers, hands-on workshops, and networking sessions.',
'start_date': '2026-04-15',
'end_date': '2026-04-16',
'start_time': '09:00',
'end_time': '18:00',
'pincode': '680001',
'place': 'Thekkinkadu Maidanam',
'is_bookable': true,
'event_type': 5,
'thumb_img': 'https://picsum.photos/seed/event1/600/400',
'images': [
{'is_primary': true, 'image': 'https://picsum.photos/seed/event1a/800/500'},
{'is_primary': false, 'image': 'https://picsum.photos/seed/event1b/800/500'},
],
'important_information': 'Please carry a valid photo ID for entry.',
'venue_name': 'Maidanam Grounds',
'event_status': 'active',
'latitude': 10.5276,
'longitude': 76.2144,
'location_name': 'Thrissur',
'important_info': [
{'title': 'Entry', 'value': 'Free with registration'},
{'title': 'Parking', 'value': 'Available on-site'},
],
},
{
'id': 2,
'name': 'Sunset Music Festival',
'title': 'Sunset Music Festival',
'description':
'An open-air music festival featuring live performances from top artists across genres. Enjoy food stalls, art installations, and an unforgettable sunset experience.',
'start_date': '2026-04-20',
'end_date': '2026-04-20',
'start_time': '16:00',
'end_time': '23:00',
'pincode': '400001',
'place': 'Marine Drive Amphitheatre',
'is_bookable': true,
'event_type': 1,
'thumb_img': 'https://picsum.photos/seed/event2/600/400',
'images': [
{'is_primary': true, 'image': 'https://picsum.photos/seed/event2a/800/500'},
],
'venue_name': 'Marine Drive Amphitheatre',
'event_status': 'active',
'latitude': 18.9432,
'longitude': 72.8235,
'location_name': 'Mumbai',
'important_info': [
{'title': 'Age Limit', 'value': '16+'},
],
},
{
'id': 3,
'name': 'Creative Design Workshop',
'title': 'Hands-on Design Workshop',
'description':
'A full-day workshop on UI/UX design principles, prototyping in Figma, and building design systems. Perfect for beginners and intermediate designers.',
'start_date': '2026-05-03',
'end_date': '2026-05-03',
'start_time': '10:00',
'end_time': '17:00',
'pincode': '110001',
'place': 'Design Hub Co-working',
'is_bookable': true,
'event_type': 2,
'thumb_img': 'https://picsum.photos/seed/event3/600/400',
'images': [
{'is_primary': true, 'image': 'https://picsum.photos/seed/event3a/800/500'},
],
'venue_name': 'Design Hub',
'event_status': 'active',
'latitude': 28.6139,
'longitude': 77.2090,
'location_name': 'New Delhi',
'important_info': [
{'title': 'Bring', 'value': 'Laptop with Figma installed'},
{'title': 'Seats', 'value': '30 max'},
],
},
{
'id': 4,
'name': 'Marathon for a Cause',
'title': 'City Marathon 2026',
'description':
'Run for fitness, run for charity! Choose from 5K, 10K, or full marathon routes through the city. All proceeds support local education initiatives.',
'start_date': '2026-04-12',
'end_date': '2026-04-12',
'start_time': '05:30',
'end_time': '12:00',
'pincode': '600001',
'place': 'Marina Beach Road',
'is_bookable': true,
'event_type': 4,
'thumb_img': 'https://picsum.photos/seed/event4/600/400',
'images': [
{'is_primary': true, 'image': 'https://picsum.photos/seed/event4a/800/500'},
],
'venue_name': 'Marina Beach',
'event_status': 'active',
'latitude': 13.0500,
'longitude': 80.2824,
'location_name': 'Chennai',
'important_info': [
{'title': 'Registration', 'value': 'Closes April 10'},
],
},
{
'id': 5,
'name': 'Art & Culture Exhibition',
'title': 'Contemporary Art Exhibition',
'description':
'Explore contemporary artworks from emerging and established artists. The exhibition features paintings, sculptures, and digital art installations.',
'start_date': '2026-05-10',
'end_date': '2026-05-15',
'start_time': '11:00',
'end_time': '20:00',
'pincode': '500001',
'place': 'Salar Jung Museum Grounds',
'is_bookable': true,
'event_type': 6,
'thumb_img': 'https://picsum.photos/seed/event5/600/400',
'images': [
{'is_primary': true, 'image': 'https://picsum.photos/seed/event5a/800/500'},
{'is_primary': false, 'image': 'https://picsum.photos/seed/event5b/800/500'},
],
'venue_name': 'Salar Jung Museum',
'event_status': 'active',
'latitude': 17.3713,
'longitude': 78.4804,
'location_name': 'Hyderabad',
'important_info': [
{'title': 'Entry Fee', 'value': '₹200'},
{'title': 'Photography', 'value': 'Allowed without flash'},
],
},
];
/// Build request body and attach token + username if available
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
final Map<String, dynamic> finalBody = {};
if (requiresAuth) {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (token != null && username != null) {
finalBody['token'] = token;
finalBody['username'] = username;
}
// Guest mode: proceed without token — let backend decide
}
if (body != null) finalBody.addAll(body);
return finalBody;
}
/// Centralized response handling and error parsing
Map<String, dynamic> _handleResponse(String url, http.Response response, Map<String, dynamic> requestBody) {
dynamic decoded;
try {
decoded = jsonDecode(response.body);
} catch (e) {
decoded = response.body;
}
if (kDebugMode) {
debugPrint('API -> $url');
debugPrint('Status: ${response.statusCode}');
debugPrint('Request body: ${jsonEncode(requestBody)}');
debugPrint('Response body: ${response.body}');
}
if (response.statusCode >= 200 && response.statusCode < 300) {
if (decoded is Map<String, dynamic>) return decoded;
return {'data': decoded};
}
// Build human-friendly message from common server patterns
String errorMessage = 'Request failed (status ${response.statusCode})';
if (decoded is Map) {
// 1) If there's an explicit top-level 'message' (string), prefer it
if (decoded.containsKey('message') && decoded['message'] is String) {
errorMessage = decoded['message'] as String;
}
// 2) If 'errors' exists and is a map, collect inner messages
else if (decoded.containsKey('errors')) {
final errs = decoded['errors'];
final messages = <String>[];
if (errs is String) {
messages.add(errs);
} else if (errs is List) {
for (final e in errs) messages.add(e.toString());
} else if (errs is Map) {
// collect first-level messages (prefer the text, not the key)
errs.forEach((k, v) {
if (v is List && v.isNotEmpty) {
messages.add(v.first.toString());
} else if (v is String) {
messages.add(v);
} else {
messages.add(v.toString());
}
});
} else {
messages.add(errs.toString());
}
if (messages.isNotEmpty) {
errorMessage = messages.join(' | ');
}
}
// 3) If '__all__' present (DRF default), show it
else if (decoded.containsKey('__all__')) {
final all = decoded['__all__'];
if (all is List) {
errorMessage = all.join(' | ');
} else {
errorMessage = all.toString();
}
}
// 4) fallback - join map values' messages (prefer strings inside lists)
else {
final messages = <String>[];
decoded.forEach((k, v) {
if (v is List && v.isNotEmpty) {
messages.add(v.first.toString());
} else {
messages.add(v.toString());
}
});
errorMessage = messages.isNotEmpty ? messages.join(' | ') : decoded.toString();
}
} else if (decoded is String) {
errorMessage = decoded;
}
throw Exception(errorMessage);
}
}