Backend: Rewrote EventListAPI to query per-type with DB-level LIMIT instead of loading all 734 events into memory. Added slim serializer (32KB vs 154KB). Added DB indexes on event_type_id and pincode. Frontend: Category chips now filter locally from _allEvents (instant, no API call). Top Events and category sections always show all types regardless of selected category. Added TTL caching for event types (30min) and events (5min). Reduced API timeout from 30s to 10s. Added memCacheHeight to all CachedNetworkImage widgets. Batched setState calls from 5 to 2 during startup. Cached _eventDates getter. Switched baseUrl to em.eventifyplus.com (Django via Nginx+SSL). Added initialEvent param to LearnMoreScreen for instant detail views. Resolved relative media URLs for category icons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
389 lines
14 KiB
Dart
389 lines
14 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);
|
|
}
|
|
|
|
/// 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 Map<String, dynamic> finalParams = {};
|
|
|
|
if (requiresAuth) {
|
|
final token = await TokenStorage.getToken();
|
|
final username = await TokenStorage.getUsername();
|
|
if (token != null && username != null) {
|
|
finalParams['token'] = token;
|
|
finalParams['username'] = username;
|
|
}
|
|
// Guest mode: proceed without token — let backend decide
|
|
}
|
|
|
|
if (params != null) finalParams.addAll(params);
|
|
|
|
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
|
|
|
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, finalParams);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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': '560001',
|
|
'place': 'Bengaluru International Exhibition Centre',
|
|
'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': 'BIEC Hall 2',
|
|
'event_status': 'active',
|
|
'latitude': 13.0147,
|
|
'longitude': 77.5636,
|
|
'location_name': 'Bengaluru',
|
|
'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);
|
|
}
|
|
}
|