- 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>
428 lines
15 KiB
Dart
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);
|
|
}
|
|
}
|