// 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> post( String url, { Map? body, bool requiresAuth = true, }) async { final headers = { 'Content-Type': 'application/json', }; final Map 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.from(match.first) : Map.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> 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 && decoded['file'] is Map) { return Map.from(decoded['file'] as Map); } return decoded is Map ? 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> get( String url, { Map? params, bool requiresAuth = true, }) async { // build final query params including auth if needed final originalUri = Uri.parse(url); final queryParams = {...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> _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> _buildAuthBody(Map? body, bool requiresAuth) async { final Map 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 _handleResponse(String url, http.Response response, Map 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) 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 = []; 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 = []; 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); } }