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>
This commit is contained in:
@@ -99,6 +99,42 @@ class ApiClient {
|
||||
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.
|
||||
|
||||
@@ -14,6 +14,7 @@ class ApiEndpoints {
|
||||
static const String login = "$baseUrl/user/login/";
|
||||
static const String logout = "$baseUrl/user/logout/";
|
||||
static const String status = "$baseUrl/user/status/";
|
||||
static const String updateProfile = "$baseUrl/user/update-profile/";
|
||||
|
||||
// Events
|
||||
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
||||
@@ -22,6 +23,8 @@ class ApiEndpoints {
|
||||
static const String eventImages = "$baseUrl/events/event-images/"; // event-images
|
||||
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
||||
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
||||
static const String featuredEvents = "$baseUrl/events/featured-events/";
|
||||
static const String topEvents = "$baseUrl/events/top-events/";
|
||||
|
||||
// Bookings
|
||||
// static const String bookEvent = "$baseUrl/events/book-event/";
|
||||
@@ -38,6 +41,9 @@ class ApiEndpoints {
|
||||
// Node.js gamification server (same host as reviews)
|
||||
static const String _nodeBase = "https://app.eventifyplus.com/api";
|
||||
|
||||
// File upload (Node.js — routes to OneDrive or GDrive via STORAGE_BACKEND env)
|
||||
static const String uploadFile = "$_nodeBase/v1/upload/file";
|
||||
|
||||
// Gamification / Contributor Module
|
||||
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||
|
||||
@@ -60,6 +60,18 @@ class AuthService {
|
||||
// Save phone if provided (optional)
|
||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||
|
||||
// Save profile photo from login response
|
||||
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||
if (rawPhoto.isNotEmpty) {
|
||||
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||
await prefs.setString('profileImage_$savedEmail', photoUrl);
|
||||
await prefs.setString('profileImage', photoUrl);
|
||||
}
|
||||
|
||||
// Save Eventify ID
|
||||
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||
|
||||
PostHogService.instance.identify(savedEmail, properties: {
|
||||
'username': displayCandidate,
|
||||
'login_method': 'email',
|
||||
@@ -178,6 +190,18 @@ class AuthService {
|
||||
}
|
||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||
|
||||
// Save profile photo from Google login response
|
||||
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||
if (rawPhoto.isNotEmpty) {
|
||||
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||
await prefs.setString('profileImage_$serverEmail', photoUrl);
|
||||
await prefs.setString('profileImage', photoUrl);
|
||||
}
|
||||
|
||||
// Save Eventify ID
|
||||
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||
|
||||
PostHogService.instance.identify(serverEmail, properties: {
|
||||
'username': displayName,
|
||||
'login_method': 'google',
|
||||
|
||||
@@ -77,6 +77,10 @@ class EventModel {
|
||||
final String? contributorName;
|
||||
final String? contributorTier;
|
||||
|
||||
// Curation flags
|
||||
final bool isFeatured;
|
||||
final bool isTopEvent;
|
||||
|
||||
EventModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -105,6 +109,8 @@ class EventModel {
|
||||
this.contributorId,
|
||||
this.contributorName,
|
||||
this.contributorTier,
|
||||
this.isFeatured = false,
|
||||
this.isTopEvent = false,
|
||||
});
|
||||
|
||||
/// Safely parse a double from backend (may arrive as String or num)
|
||||
@@ -167,6 +173,8 @@ class EventModel {
|
||||
contributorId: j['contributor_id']?.toString(),
|
||||
contributorName: j['contributor_name'] as String?,
|
||||
contributorTier: j['contributor_tier'] as String?,
|
||||
isFeatured: j['is_featured'] == true || j['is_featured']?.toString().toLowerCase() == 'true',
|
||||
isTopEvent: j['is_top_event'] == true || j['is_top_event']?.toString().toLowerCase() == 'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,32 @@ class EventsService {
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Featured events for the home screen hero carousel.
|
||||
Future<List<EventModel>> getFeaturedEvents() async {
|
||||
final res = await _api.post(ApiEndpoints.featuredEvents, requiresAuth: false);
|
||||
final events = res['events'] ?? res['data'] ?? [];
|
||||
if (events is List) {
|
||||
return events
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Top events for the home screen top events section.
|
||||
Future<List<EventModel>> getTopEvents() async {
|
||||
final res = await _api.post(ApiEndpoints.topEvents, requiresAuth: false);
|
||||
final events = res['events'] ?? res['data'] ?? [];
|
||||
if (events is List) {
|
||||
return events
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
||||
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
||||
|
||||
@@ -152,11 +152,28 @@ class GamificationService {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit Contribution
|
||||
// POST /v1/gamification/submit-event body: event data
|
||||
// 1. Upload each image to /v1/upload/file → get back { url, fileId, ... }
|
||||
// 2. POST /v1/gamification/submit-event with `media` (uploaded objects)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||
final email = await _getUserEmail();
|
||||
final body = <String, dynamic>{'user_id': email, ...data};
|
||||
|
||||
// Upload images if present
|
||||
final rawPaths = (data['images'] as List?)?.cast<String>() ?? [];
|
||||
final List<Map<String, dynamic>> uploadedMedia = [];
|
||||
|
||||
for (final path in rawPaths) {
|
||||
final result = await _api.uploadFile(ApiEndpoints.uploadFile, path);
|
||||
uploadedMedia.add(result);
|
||||
}
|
||||
|
||||
// Build submission body — use `media` (server canonical field)
|
||||
final body = <String, dynamic>{
|
||||
'user_id': email,
|
||||
...Map.from(data)..remove('images'),
|
||||
if (uploadedMedia.isNotEmpty) 'media': uploadedMedia,
|
||||
};
|
||||
|
||||
await _api.post(
|
||||
ApiEndpoints.contributeSubmit,
|
||||
body: body,
|
||||
|
||||
@@ -46,6 +46,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
// backend-driven
|
||||
List<EventModel> _allEvents = []; // master copy, never filtered
|
||||
List<EventModel> _events = [];
|
||||
List<EventModel> _featuredEvents = [];
|
||||
List<EventModel> _topEventsList = [];
|
||||
List<EventTypeModel> _types = [];
|
||||
int _selectedTypeId = -1; // -1 == All
|
||||
bool _categoriesExpanded = false;
|
||||
@@ -62,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
super.initState();
|
||||
_heroPageNotifier = ValueNotifier(0);
|
||||
_loadUserDataAndEvents();
|
||||
_loadCuratedEvents();
|
||||
_startAutoScroll();
|
||||
PostHogService.instance.screen('Home');
|
||||
}
|
||||
@@ -112,7 +115,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
_userLng = prefs.getDouble('user_lng');
|
||||
|
||||
try {
|
||||
// Fetch types and events in parallel for faster loading.
|
||||
// Fetch types and location-based events in parallel.
|
||||
// Prefer haversine (lat/lng) when GPS coords are available; fall back to pincode.
|
||||
final results = await Future.wait([
|
||||
_events_service_getEventTypesSafe(),
|
||||
@@ -189,6 +192,24 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads featured carousel + top events once globally — no pincode, never re-fetched on location change.
|
||||
Future<void> _loadCuratedEvents() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_eventsService.getFeaturedEvents(),
|
||||
_eventsService.getTopEvents(),
|
||||
]);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_featuredEvents = results[0] as List<EventModel>;
|
||||
_topEventsList = results[1] as List<EventModel>;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-critical — fallback getters handle empty lists gracefully
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
await _loadUserDataAndEvents();
|
||||
}
|
||||
@@ -583,8 +604,51 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
}
|
||||
|
||||
|
||||
// Get hero events (first 4 events for the carousel)
|
||||
List<EventModel> get _heroEvents => _events.take(6).toList();
|
||||
// Featured events for the hero carousel — from dedicated endpoint, fallback to first 6
|
||||
List<EventModel> get _heroEvents =>
|
||||
_featuredEvents.isNotEmpty ? _featuredEvents : _allEvents.take(6).toList();
|
||||
|
||||
// Top events respecting the active date filter — from dedicated endpoint, fallback to date-filtered all
|
||||
List<EventModel> get _topEventsFiltered {
|
||||
if (_topEventsList.isEmpty) return _allFilteredByDate;
|
||||
if (_selectedDateFilter.isEmpty) return _topEventsList;
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
DateTime filterStart;
|
||||
DateTime filterEnd;
|
||||
switch (_selectedDateFilter) {
|
||||
case 'Today':
|
||||
filterStart = today;
|
||||
filterEnd = today;
|
||||
break;
|
||||
case 'Tomorrow':
|
||||
filterStart = today.add(const Duration(days: 1));
|
||||
filterEnd = filterStart;
|
||||
break;
|
||||
case 'This week':
|
||||
filterStart = today;
|
||||
filterEnd = today.add(Duration(days: 7 - today.weekday));
|
||||
break;
|
||||
case 'Date':
|
||||
if (_selectedCustomDate == null) return _topEventsList;
|
||||
filterStart = DateTime(_selectedCustomDate!.year, _selectedCustomDate!.month, _selectedCustomDate!.day);
|
||||
filterEnd = filterStart;
|
||||
break;
|
||||
default:
|
||||
return _topEventsList;
|
||||
}
|
||||
return _topEventsList.where((e) {
|
||||
try {
|
||||
final s = DateTime.parse(e.startDate);
|
||||
final eEnd = DateTime.parse(e.endDate);
|
||||
final eStart = DateTime(s.year, s.month, s.day);
|
||||
final eEndDay = DateTime(eEnd.year, eEnd.month, eEnd.day);
|
||||
return !eEndDay.isBefore(filterStart) && !eStart.isAfter(filterEnd);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _formatDate(String dateStr) {
|
||||
try {
|
||||
@@ -1641,12 +1705,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: _allFilteredByDate.isEmpty && _loading
|
||||
child: _topEventsFiltered.isEmpty && _loading
|
||||
? SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton()))),
|
||||
)
|
||||
: _allFilteredByDate.isEmpty
|
||||
: _topEventsFiltered.isEmpty
|
||||
? Center(child: Text(
|
||||
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
|
||||
style: const TextStyle(color: Color(0xFF9CA3AF)),
|
||||
@@ -1654,10 +1718,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
: PageView.builder(
|
||||
controller: PageController(viewportFraction: 0.85),
|
||||
physics: const PageScrollPhysics(),
|
||||
itemCount: _allFilteredByDate.length,
|
||||
itemCount: _topEventsFiltered.length,
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildTopEventCard(_allFilteredByDate[index]),
|
||||
child: _buildTopEventCard(_topEventsFiltered[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user