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);
|
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
|
/// GET request
|
||||||
///
|
///
|
||||||
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
/// - 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 login = "$baseUrl/user/login/";
|
||||||
static const String logout = "$baseUrl/user/logout/";
|
static const String logout = "$baseUrl/user/logout/";
|
||||||
static const String status = "$baseUrl/user/status/";
|
static const String status = "$baseUrl/user/status/";
|
||||||
|
static const String updateProfile = "$baseUrl/user/update-profile/";
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
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 eventImages = "$baseUrl/events/event-images/"; // event-images
|
||||||
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
||||||
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
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
|
// Bookings
|
||||||
// static const String bookEvent = "$baseUrl/events/book-event/";
|
// static const String bookEvent = "$baseUrl/events/book-event/";
|
||||||
@@ -38,6 +41,9 @@ class ApiEndpoints {
|
|||||||
// Node.js gamification server (same host as reviews)
|
// Node.js gamification server (same host as reviews)
|
||||||
static const String _nodeBase = "https://app.eventifyplus.com/api";
|
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
|
// Gamification / Contributor Module
|
||||||
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||||
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ class AuthService {
|
|||||||
// Save phone if provided (optional)
|
// Save phone if provided (optional)
|
||||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
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: {
|
PostHogService.instance.identify(savedEmail, properties: {
|
||||||
'username': displayCandidate,
|
'username': displayCandidate,
|
||||||
'login_method': 'email',
|
'login_method': 'email',
|
||||||
@@ -178,6 +190,18 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
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: {
|
PostHogService.instance.identify(serverEmail, properties: {
|
||||||
'username': displayName,
|
'username': displayName,
|
||||||
'login_method': 'google',
|
'login_method': 'google',
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ class EventModel {
|
|||||||
final String? contributorName;
|
final String? contributorName;
|
||||||
final String? contributorTier;
|
final String? contributorTier;
|
||||||
|
|
||||||
|
// Curation flags
|
||||||
|
final bool isFeatured;
|
||||||
|
final bool isTopEvent;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -105,6 +109,8 @@ class EventModel {
|
|||||||
this.contributorId,
|
this.contributorId,
|
||||||
this.contributorName,
|
this.contributorName,
|
||||||
this.contributorTier,
|
this.contributorTier,
|
||||||
|
this.isFeatured = false,
|
||||||
|
this.isTopEvent = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Safely parse a double from backend (may arrive as String or num)
|
/// Safely parse a double from backend (may arrive as String or num)
|
||||||
@@ -167,6 +173,8 @@ class EventModel {
|
|||||||
contributorId: j['contributor_id']?.toString(),
|
contributorId: j['contributor_id']?.toString(),
|
||||||
contributorName: j['contributor_name'] as String?,
|
contributorName: j['contributor_name'] as String?,
|
||||||
contributorTier: j['contributor_tier'] 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;
|
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/)
|
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
||||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
||||||
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
||||||
|
|||||||
@@ -152,11 +152,28 @@ class GamificationService {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Submit Contribution
|
// 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 {
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
final email = await _getUserEmail();
|
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(
|
await _api.post(
|
||||||
ApiEndpoints.contributeSubmit,
|
ApiEndpoints.contributeSubmit,
|
||||||
body: body,
|
body: body,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
// backend-driven
|
// backend-driven
|
||||||
List<EventModel> _allEvents = []; // master copy, never filtered
|
List<EventModel> _allEvents = []; // master copy, never filtered
|
||||||
List<EventModel> _events = [];
|
List<EventModel> _events = [];
|
||||||
|
List<EventModel> _featuredEvents = [];
|
||||||
|
List<EventModel> _topEventsList = [];
|
||||||
List<EventTypeModel> _types = [];
|
List<EventTypeModel> _types = [];
|
||||||
int _selectedTypeId = -1; // -1 == All
|
int _selectedTypeId = -1; // -1 == All
|
||||||
bool _categoriesExpanded = false;
|
bool _categoriesExpanded = false;
|
||||||
@@ -62,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
super.initState();
|
super.initState();
|
||||||
_heroPageNotifier = ValueNotifier(0);
|
_heroPageNotifier = ValueNotifier(0);
|
||||||
_loadUserDataAndEvents();
|
_loadUserDataAndEvents();
|
||||||
|
_loadCuratedEvents();
|
||||||
_startAutoScroll();
|
_startAutoScroll();
|
||||||
PostHogService.instance.screen('Home');
|
PostHogService.instance.screen('Home');
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
_userLng = prefs.getDouble('user_lng');
|
_userLng = prefs.getDouble('user_lng');
|
||||||
|
|
||||||
try {
|
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.
|
// Prefer haversine (lat/lng) when GPS coords are available; fall back to pincode.
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
_events_service_getEventTypesSafe(),
|
_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 {
|
Future<void> _refresh() async {
|
||||||
await _loadUserDataAndEvents();
|
await _loadUserDataAndEvents();
|
||||||
}
|
}
|
||||||
@@ -583,8 +604,51 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Get hero events (first 4 events for the carousel)
|
// Featured events for the hero carousel — from dedicated endpoint, fallback to first 6
|
||||||
List<EventModel> get _heroEvents => _events.take(6).toList();
|
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) {
|
String _formatDate(String dateStr) {
|
||||||
try {
|
try {
|
||||||
@@ -1641,12 +1705,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: _allFilteredByDate.isEmpty && _loading
|
child: _topEventsFiltered.isEmpty && _loading
|
||||||
? SingleChildScrollView(
|
? SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton()))),
|
child: Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton()))),
|
||||||
)
|
)
|
||||||
: _allFilteredByDate.isEmpty
|
: _topEventsFiltered.isEmpty
|
||||||
? Center(child: Text(
|
? Center(child: Text(
|
||||||
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
|
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
|
||||||
style: const TextStyle(color: Color(0xFF9CA3AF)),
|
style: const TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
@@ -1654,10 +1718,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
: PageView.builder(
|
: PageView.builder(
|
||||||
controller: PageController(viewportFraction: 0.85),
|
controller: PageController(viewportFraction: 0.85),
|
||||||
physics: const PageScrollPhysics(),
|
physics: const PageScrollPhysics(),
|
||||||
itemCount: _allFilteredByDate.length,
|
itemCount: _topEventsFiltered.length,
|
||||||
itemBuilder: (context, index) => Padding(
|
itemBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: _buildTopEventCard(_allFilteredByDate[index]),
|
child: _buildTopEventCard(_topEventsFiltered[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user