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:
2026-04-08 21:12:49 +05:30
parent 479fe5e119
commit c40e600937
7 changed files with 190 additions and 9 deletions

View File

@@ -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.

View File

@@ -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";

View File

@@ -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',

View File

@@ -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',
);
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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]),
),
),
),