From c40e600937e07ff90f1476984ed1000d364ecbf7 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Wed, 8 Apr 2026 21:12:49 +0530 Subject: [PATCH] feat(contribute): upload event images to OneDrive before submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/core/api/api_client.dart | 36 +++++++++ lib/core/api/api_endpoints.dart | 6 ++ lib/features/auth/services/auth_service.dart | 24 ++++++ lib/features/events/models/event_models.dart | 8 ++ .../events/services/events_service.dart | 26 +++++++ .../services/gamification_service.dart | 21 ++++- lib/screens/home_screen.dart | 78 +++++++++++++++++-- 7 files changed, 190 insertions(+), 9 deletions(-) diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart index 8aaa314..3a56fef 100644 --- a/lib/core/api/api_client.dart +++ b/lib/core/api/api_client.dart @@ -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> 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. diff --git a/lib/core/api/api_endpoints.dart b/lib/core/api/api_endpoints.dart index a25d179..8a89eda 100644 --- a/lib/core/api/api_endpoints.dart +++ b/lib/core/api/api_endpoints.dart @@ -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"; diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index c93f21c..81b01e0 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -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', diff --git a/lib/features/events/models/event_models.dart b/lib/features/events/models/event_models.dart index 3c481f9..64fcb47 100644 --- a/lib/features/events/models/event_models.dart +++ b/lib/features/events/models/event_models.dart @@ -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', ); } } diff --git a/lib/features/events/services/events_service.dart b/lib/features/events/services/events_service.dart index 3ad66c4..607f7cb 100644 --- a/lib/features/events/services/events_service.dart +++ b/lib/features/events/services/events_service.dart @@ -129,6 +129,32 @@ class EventsService { return list; } + /// Featured events for the home screen hero carousel. + Future> 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((e) => EventModel.fromJson(e)) + .toList(); + } + return []; + } + + /// Top events for the home screen top events section. + Future> 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((e) => EventModel.fromJson(e)) + .toList(); + } + return []; + } + /// Events by month and year for calendar (POST to /events/events-by-month-year/) Future> getEventsByMonthYear(String month, int year) async { final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false); diff --git a/lib/features/gamification/services/gamification_service.dart b/lib/features/gamification/services/gamification_service.dart index b13fdd7..f5364de 100644 --- a/lib/features/gamification/services/gamification_service.dart +++ b/lib/features/gamification/services/gamification_service.dart @@ -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 submitContribution(Map data) async { final email = await _getUserEmail(); - final body = {'user_id': email, ...data}; + + // Upload images if present + final rawPaths = (data['images'] as List?)?.cast() ?? []; + final List> 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 = { + 'user_id': email, + ...Map.from(data)..remove('images'), + if (uploadedMedia.isNotEmpty) 'media': uploadedMedia, + }; + await _api.post( ApiEndpoints.contributeSubmit, body: body, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0dd7682..b65fb6f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -46,6 +46,8 @@ class _HomeScreenState extends State with SingleTickerProviderStateM // backend-driven List _allEvents = []; // master copy, never filtered List _events = []; + List _featuredEvents = []; + List _topEventsList = []; List _types = []; int _selectedTypeId = -1; // -1 == All bool _categoriesExpanded = false; @@ -62,6 +64,7 @@ class _HomeScreenState extends State with SingleTickerProviderStateM super.initState(); _heroPageNotifier = ValueNotifier(0); _loadUserDataAndEvents(); + _loadCuratedEvents(); _startAutoScroll(); PostHogService.instance.screen('Home'); } @@ -112,7 +115,7 @@ class _HomeScreenState extends State 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 with SingleTickerProviderStateM } } + /// Loads featured carousel + top events once globally — no pincode, never re-fetched on location change. + Future _loadCuratedEvents() async { + try { + final results = await Future.wait([ + _eventsService.getFeaturedEvents(), + _eventsService.getTopEvents(), + ]); + if (mounted) { + setState(() { + _featuredEvents = results[0] as List; + _topEventsList = results[1] as List; + }); + } + } catch (_) { + // Non-critical — fallback getters handle empty lists gracefully + } + } + Future _refresh() async { await _loadUserDataAndEvents(); } @@ -583,8 +604,51 @@ class _HomeScreenState extends State with SingleTickerProviderStateM } - // Get hero events (first 4 events for the carousel) - List get _heroEvents => _events.take(6).toList(); + // Featured events for the hero carousel — from dedicated endpoint, fallback to first 6 + List 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 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 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 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]), ), ), ),