Compare commits

..

62 Commits

Author SHA1 Message Date
3484fa9885 fix(android): add SplashActivity with video intro — fix NPE by moving insetsController after setContentView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:10:50 +05:30
7867e6c728 chore: restore AndroidManifest/Info.plist changes + resolve stash conflicts
Merges in-progress manifest/plist changes (stashed before merge).
Resolves trivial comment conflicts in api_endpoints.dart and auth_service.dart —
both retained backend.eventifyplus.com URL and Google OAuth serverClientId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:44:57 +05:30
98a5d541aa merge: v2.0.4+24 login fixes + backend URL fix
- Google OAuth serverClientId wired (639347358523-mtkm...apps.googleusercontent.com)
- Timeout 10s→25s + retry on SocketException/TimeoutException
- Forgot Password glassmorphism bottom sheet with safe-degrade
- Same-page signup AnimatedSwitcher (mobile + desktop); delete old RegisterScreen classes
- Guest SnackBar removed from HomeScreen; LoginScreen clearSnackBars() guard
- baseUrl: em.eventifyplus.com → backend.eventifyplus.com (broken TLS fix — real root cause)
- Version: 2.0.4+24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:44:10 +05:30
b9efe18669 fix: switch baseUrl to backend.eventifyplus.com (broken TLS on em.eventifyplus.com)
em.eventifyplus.com / uat.eventifyplus.com DNS points to K8s with broken TLS cert.
backend.eventifyplus.com → EC2 174.129.72.160 with valid Let's Encrypt cert.
This fixes the root cause of "Unable to connect" on all API calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:42:39 +05:30
ebe654f9c3 fix: v2.0.4+24 — login fixes, signup toggle, forgot-password, guest SnackBar, Google OAuth
- Google Sign-In: wire serverClientId (639347358523-mtkm...apps.googleusercontent.com) so idToken is returned on Android
- Email login: raise timeout 10s→25s, add single retry on SocketException/TimeoutException
- Forgot Password: real glassmorphism bottom sheet with safe-degrade SnackBar (endpoint missing on backend)
- Create Account: same-page AnimatedSwitcher toggle with glassmorphism signup form; delete old RegisterScreen
- Desktop parity: DesktopLoginScreen same-page toggle; delete DesktopRegisterScreen
- Guest mode: remove ScaffoldMessenger SnackBar from HomeScreen outer catch (inner _safe wrappers already return [])
- LoginScreen: clearSnackBars() on postFrameCallback to prevent carried-over SnackBars from prior screens
- ProGuard: add Google Sign-In + OkHttp keep rules
- Version bump: 2.0.0+20 → 2.0.4+24; settings _appVersion → 2.0.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:40:17 +05:30
f3250737bd chore: bump version to 2.0.3+23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:14:07 +05:30
754b04dc05 perf: fix image loading performance across all screens
- Replace Image.network (no cache) with CachedNetworkImage in contributor_profile_screen
- Replace NetworkImage (no cache) with CachedNetworkImageProvider in desktop_topbar and contribute_screen (leaderboard avatars)
- Add maxWidthDiskCache + maxHeightDiskCache to all 23 CachedNetworkImage calls
- Add missing memCacheWidth/Height to review_card (36x36 avatar) and learn_more related events (140x100)
- Add dynamic memCache sizing to tier_avatar_ring based on widget size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:03:03 +05:30
5e00e431e3 docs: split 2.0.0 and 2.0.1 in CHANGELOG
2.0.0 = main release (image upload, profile form, coming-soon sweep, build fix)
2.0.1 = hotfix for Sign in with Google regression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:39:37 +05:30
b2f0943797 docs: add v2.0.1 release notes to CHANGELOG
Documents image upload pipeline, OneDrive integration, full personal
info profile form, demo→coming-soon label sweep, and Android build
version fix (versionCode/versionName now sourced from pubspec).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:59:56 +05:30
6990b62645 fix(android): read versionCode/versionName from flutter pubspec instead of hardcoded values
Was hardcoded to versionCode=17, versionName="1.6.1(p)" — overriding
pubspec.yaml and causing Play Store rejection. Now reads flutter.versionCode
and flutter.versionName so pubspec.yaml is the single source of truth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:36:25 +05:30
c85564efc8 chore: bump version to 2.0.0+20 (version name 2.0, build code 20)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:31:57 +05:30
593fc9dcf9 chore: bump app version to 2.0(b) in settings screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:29:08 +05:30
6b6f08fd26 chore: replace all '(demo)' labels with '(coming soon)'
Affects snackbar messages in booking_screen, tickets_booked_screen,
calendar_screen, settings_screen. Also updates Privacy Policy subtitle
from 'Demo app' to 'Coming Soon'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:25:07 +05:30
d0762668d6 feat(profile): full personal info form in edit profile sheet
Adds all fields to the edit profile bottom sheet:
- First Name / Last Name (side by side), Email, Phone
- Location section: Home District (locked with "Next change" date),
  Place, Pincode, State, Country
- Saves all fields via update-profile API and persists to prefs
- Loads existing values from prefs on open; refreshes from status API
  on every profile open so fields stay in sync with server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:22:25 +05:30
9f1de2bead fix(upload): set explicit MIME type on multipart upload
Without a content type header, http package defaults to
application/octet-stream which the server rejects. Derive MIME
from file extension using a lookup map (Dart 2 compatible).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:17:55 +05:30
c40e600937 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>
2026-04-08 21:12:49 +05:30
479fe5e119 feat(share): rebuild share rank with dart:ui Canvas generator
Replace RepaintBoundary widget capture approach with a pure
dart:ui PictureRecorder + Canvas implementation.

- Add share_card_generator.dart: generates 1080×1920 PNG via
  Canvas without embedding any widget in the tree
- Remove share_rank_card.dart (widget approach no longer needed)
- Remove GlobalKey, _buildHiddenShareCard, RepaintBoundary,
  _fmtEp from profile_screen.dart
- Simplify desktop + mobile Stacks to direct ScrollViews
- Fix Android GPU compositing timing crash (no retry needed)
- Add avatarImage.dispose() to prevent GPU memory leak
- Guard byteData null return with StateError
- Replace MaterialIcons bolt with Unicode  (tree-shake safe)
- Align tier in share text with tier rendered on card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:20:36 +05:30
Rishad7594
bbef5b376d ... 2026-04-08 19:25:43 +05:30
aefb381ed3 feat(share): update share rank caption and add URL
Share sheet now pre-fills:
"I'm a BRONZE Explorer on Eventify Plus! 7 EP earned.
Let's connect on the platform for more.

https://app.eventifyplus.com"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:16:33 +05:30
Rishad7594
d921ac2b78 ... 2026-04-08 08:05:29 +05:30
4c57391bbd fix: leaderboard empty on first open — decouple from loadAll()
- Add isLeaderboardLoading flag separate from isLoading
- Add loadLeaderboard() method that fires independently of loadAll TTL
- Remove leaderboard from loadAll() Future.wait (failures in dashboard/shop
  no longer silently zero-out leaderboard data)
- setDistrict / setTimePeriod now use isLeaderboardLoading
- contribute_screen calls loadLeaderboard() alongside loadAll() on mount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:35:51 +05:30
Rishad7594
7bc396bdde Update default location to Thrissur and remove Whitefield, Bengaluru 2026-04-07 20:49:40 +05:30
685c6755d8 chore: remove _notes and .obsidian from git tracking
- Untrack _notes/ vault and .obsidian/ config
- Update .gitignore to exclude both permanently
2026-04-06 22:05:39 +05:30
b8fcd29aff chore: remove vibe-coding artifacts from tracking, sync notes and obsidian config
- Untrack .claude/launch.json (Claude Code config)
- Add .claude/, .mcp.json, CLAUDE.md, and AI-generated CSVs to .gitignore
- Add _notes/ vault (architecture, API, tasks, bugs, infra docs)
- Add .obsidian/ plugin/vault config (workspace state excluded)
- Sync CHANGELOG.md
2026-04-06 22:04:06 +05:30
b24df66b31 feat: rewrite contribute tab to match web app (app.eventifyplus.com/contribute)
Complete UI rewrite of contribute_screen.dart:
- 3 tabs (My Events, Submit Event, Reward Shop) replacing old 4-tab
  layout (Contribute, Leaderboard, Achievements, Shop)
- Compact stats bar: tier pill + liquid EP + RP + share button
- Horizontal tier roadmap showing Bronze→Diamond progression
- Animated tab glider with elastic curve
- Submit form matching web: Event Name, Category, District, Date+Time,
  Description (with EP hint), Location Coordinates (manual lat/lng OR
  Google Maps URL extraction), Media Upload (5 images, 2 EP each)
- My Events tab with status badges (Approved/Pending/Rejected)
- Reward Shop "Coming Soon" with ghost teaser cards
- Color palette matching web: #0F45CF primary, #ea580c RP orange
- File reduced from 2681 to 1093 lines (59% smaller)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:31:25 +05:30
c6c313854d feat: city selection now uses haversine radius filtering (10km)
Enrich kerala_pincodes.json with lat/lng for all 463 entries via
pgeocode offline DB (453 exact matches + 10 district centroids).

Update SearchScreen _LocationItem to carry lat/lng fields, load them
from JSON on init, and pass them through every selection path
(_selectWithPincode, _selectAndClose, search result onTap).

Result: selecting Chavakkad (or any Kerala city) now pops
{label, pincode, lat:10.59322, lng:76.0297} → home_screen saves coords
to prefs → getEventsByLocation sends lat/lng to Django → haversine
filtering returns events within 10km radius, expanding to 25/50/100km
if fewer than 6 events found.
2026-04-04 19:10:07 +05:30
8481b14a7a fix: resolve 3 console errors (RenderFlex overflow, gamification 404s, CORS)
- Wrap Top Events skeleton Row in SingleChildScrollView to fix 225px
  RenderFlex overflow when 3x 200px skeletons exceed container width
- Fix gamification service using POST for GET endpoints: dashboard,
  leaderboard, and shop/items all use router.get() on the Node.js server
- CORS: add http://localhost:8080 to CORS_ALLOWED_ORIGINS (applied live
  to eventify-django container + local settings.py)
2026-04-04 18:56:40 +05:30
42b71beae2 feat: PostHog analytics wiring across all key screens
- Commit untracked posthog_service.dart (fire-and-forget HTTP client,
  EU data residency, already used by auth for identify/reset)
- screen() calls: Home, Contribute, Profile, EventDetail (with event_id)
- capture('event_tapped') on hero carousel card tap (source: hero_carousel)
- capture('book_now_tapped') in _navigateToCheckout (event_id + name)
- capture('review_submitted') in _handleSubmit (event_id + rating)
- Covers all 4 expansion items from security audit finding 8.2
2026-04-04 18:45:19 +05:30
a32ead31c2 fix: LOC — location filter never applied to event API calls
Root cause: SearchScreen popped with a plain city label string; the
pincode was available in search results but discarded. home_screen only
saved the display label to prefs and never updated the 'pincode' key,
so every API call always sent {pincode:'all'} regardless of selection.

GPS path had the same issue — lat/lng were obtained but thrown away
after reverse-geocoding; only the label was passed back.

Fix:
- SearchScreen now pops with Map<String,dynamic> {label, pincode,
  lat?, lng?} instead of a plain String
- Pincode results return their pincode; GPS returns actual coordinates;
  popular city chips look up the first matching pincode from the
  Kerala pincodes DB (fallback 'all' if not found)
- home_screen._openLocationSearch() saves pincode + lat/lng to prefs
  and updates _pincode/_userLat/_userLng in state
- home_screen._loadUserDataAndEvents() prefers getEventsByLocation
  (haversine) when GPS coords are saved, falls back to getEventsByPincode
- EventsService gains getEventsByLocation(lat, lng) which sends
  latitude/longitude/radius_km to the existing Django haversine endpoint
  and auto-expands radius 10→25→50→100 km until ≥ 6 events found
2026-04-04 18:43:02 +05:30
bb06bd8ac6 feat: UX-005 — Hero transitions, fade screen load, AnimatedList leaderboard stagger 2026-04-04 17:49:37 +05:30
d3d7d04305 feat: UX-002 — BouncingLoader widget replacing CircularProgressIndicator in key screens 2026-04-04 17:41:57 +05:30
3729ee0abf feat: REV-004 — spring elasticOut animation on review submit success 2026-04-04 17:39:32 +05:30
e3f501ae4b feat: REV-003 — stagger slide/fade animations on review list 2026-04-04 17:38:39 +05:30
ec607209aa feat: REV-001 — DiceBear Notionists avatars on review cards 2026-04-04 17:35:29 +05:30
7cd64883e2 feat: HOME-007 — server-side event title/description search (q param) 2026-04-04 17:33:56 +05:30
e9752c3d61 feat: Phase 3 — 26 medium-priority gaps implemented
P3-A/K  Profile: Eventify ID glassmorphic badge (tap-to-copy), DiceBear
        Notionists avatar via TierAvatarRing, district picker (14 pills)
        with 183-day cooldown lock, multipart photo upload to server
P3-B    Home: Top Events converted to PageView scroll-snap
        (viewportFraction 0.9 + PageScrollPhysics)
P3-C    Event detail: contributor widget (tier ring + name + navigation),
        related events horizontal row; added getEventsByCategory() to
        EventsService; added contributorId/Name/Tier fields to EventModel
P3-D    Kerala pincodes: 463-entry JSON (all 14 districts), registered as
        asset, async-loaded in SearchScreen replacing hardcoded 32 cities
P3-E    Checkout: promo code field + Apply/Remove button in Step 2,
        discountAmount subtracted from total, applyPromo()/resetPromo()
        methods in CheckoutProvider
P3-F/G  Gamification: reward cycle countdown + EP→RP progress bar (blue→
        amber) in contribute + profile screens; TierAvatarRing in podium
        and all leaderboard rows; GlassCard current-user stats card at
        top of leaderboard tab
P3-H    New ContributorProfileScreen: tier ring, stats, submission grid
        with status chips; getDashboardForUser() in GamificationService;
        wired from leaderboard row taps
P3-I    Achievements: 11 default badges (up from 6), 6 new icon map
        entries; progress % labels already confirmed present
P3-J    Reviews: CustomPainter circular arc rating ring (amber, 84px)
        replaces large rating number in ReviewSummary
P3-L    Share rank card: RepaintBoundary → PNG capture → Share.shareXFiles;
        share button wired in profile header and leaderboard tab
P3-M    SafeArea audit: home bottom nav, contribute/achievements scroll
        padding, profile CustomScrollView top inset

New files: tier_avatar_ring.dart, glass_card.dart,
  eventify_bottom_sheet.dart, contributor_profile_screen.dart,
  share_rank_card.dart, assets/data/kerala_pincodes.json
New dep:   path_provider ^2.1.0
2026-04-04 17:17:36 +05:30
e365361451 feat: Phase 2 — 11 high-priority gaps implemented across home, auth, gamification, profile, and event detail
Phase 2 gaps completed:
- HOME-001: Hero slider pause-on-touch (GestureDetector wraps PageView)
- HOME-003: Calendar bottom sheet with TableCalendar (replaces custom dialog)
- AUTH-004: District dropdown on signup (14 Kerala districts)
- EVT-003: Mobile sticky "Book Now" bar + desktop CTA wired to CheckoutScreen
- ACH-001: Real achievements from dashboard API with fallback defaults
- GAM-002: 3-card EP row (Lifetime EP / Liquid EP / Reward Points)
- GAM-005: Horizontal tier roadmap Bronze→Silver→Gold→Platinum→Diamond
- CTR-001: Submission status chips (PENDING/APPROVED/REJECTED)
- CTR-002: +EP badge on approved submissions
- PROF-003: Gamification cards on profile screen with Consumer<GamificationProvider>
- UX-001: Shimmer skeleton loaders (shimmer ^3.0.0) replacing CircularProgressIndicator

Already complete (verified, no changes needed):
- HOME-002: Category shelves already built in _buildTypeSection()
- LDR-002: Podium visualization already built (_buildPodium / _buildDesktopPodium)
- BOOK-004: UPI handled natively by Razorpay SDK

Deferred: LOC-001/002 (needs Django haversine endpoint)
Skipped: AUTH-002 (OTP needs SMS provider decision)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:51:30 +05:30
8955febd00 feat: Phase 1 critical gaps — gamification API, Razorpay checkout, Google OAuth, notifications
- Fix gamification endpoints to use Node.js server (app.eventifyplus.com)
- Replace 6 mock gamification methods with real API calls (dashboard, leaderboard, shop, redeem, submit)
- Add booking models, service, payment service (Razorpay), checkout provider
- Add 3-step CheckoutScreen with Razorpay native modal integration
- Add Google OAuth login (Flutter + Django backend)
- Add full notifications system (Django model + 3 endpoints + Flutter UI)
- Register CheckoutProvider, NotificationProvider in main.dart MultiProvider
- Wire notification bell in HomeScreen app bar
- Add razorpay_flutter ^1.3.7 and google_sign_in ^6.2.2 packages
2026-04-04 15:46:53 +05:30
bc12fe70aa security: sanitize all error messages shown to users
Created centralized userFriendlyError() utility that converts raw
exceptions into clean, user-friendly messages. Strips hostnames,
ports, OS error codes, HTTP status codes, stack traces, and Django
field names. Maps network/timeout/auth/server errors to plain
English messages.

Fixed 16 locations across 10 files:
- home_screen, calendar_screen, learn_more_screen (SnackBar/Text)
- login_screen, desktop_login_screen (SnackBar)
- profile_screen, contribute_screen, search_screen (SnackBar)
- review_form, review_section (inline error text)
- gamification_provider (error field)

Also removed double-wrapped exceptions in ReviewService (rethrow
instead of throw Exception('Failed to...: $e')).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:15:02 +05:30
81872070e4 fix: ensure important information loads for all events (guest + auth)
Changed getEventDetails to requiresAuth: false so guests can fetch
full event details without auth tokens. Added retry logic (2 attempts
with 1s delay) to _loadFullDetails for reliability on slow networks.
This ensures important_information, images, and other detail-only
fields are always fetched in the background after initial display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:22:30 +05:30
6c533614b3 fix: important information now displays correctly on event details
Fixed HTML parser to strip <style> and <script> blocks entirely
(including their content) before extracting text. Previously, CSS
rules like "td {border: 1px solid...}" leaked into the parsed output.
Also added </div>, </p>, </li> as newline separators so div-wrapped
content (common in Django admin rich text) parses into separate items.
Added debug logging to _loadFullDetails for troubleshooting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:13:38 +05:30
2fc45e0c5b style: add visible borders to category chips in Events Around You
Unselected chips now have a 1.5px light gray (#E5E7EB) border so they
stand out against the white background. Selected chips get a matching
primary blue border. Also slightly increased shadow opacity for better
depth perception. Replaced deprecated withOpacity calls with withValues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:01:46 +05:30
34a39ada31 feat: fix View All buttons and category selection UX
- "View All" on "Events Around You" header now toggles between
  horizontal scroll and expanded wrap grid showing all categories
- Tapping a category chip replaces all shelf sections with a
  filtered vertical list of events for that category only
- Tapping "All Events" restores the shelf layout for all categories
- "View All" on each shelf header (Music, Festivals, etc.) selects
  that category in the chips and shows its filtered event list
- Added AnimatedSwitcher for smooth transition between views
- Added AnimatedCrossFade for chip expand/collapse animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:32:54 +05:30
206602fca6 fix: fetch full event details in background to show important information
When navigating from the home screen, LearnMoreScreen now shows the
pre-loaded event data instantly, then silently fetches full details
from the event-details API in the background. This fills in fields
missing from the slim list endpoint (important_information, images,
important_info) without showing a loading spinner.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:20:15 +05:30
ee97c54f73 refactor: simplify venue map section, ready for native Google Maps SDK
Cleaned up _buildVenueSection: removed broken static map URL (empty
API key), removed unused map controls (directional pad, satellite
toggle). Native GoogleMap widget on mobile, simple fallback on web.
Pending: Google Maps API key in AndroidManifest.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:09:50 +05:30
1badeff966 feat: add complete review/rating system for events
New feature: Users can view, submit, and interact with event reviews.

Components added:
- ReviewModel, ReviewStatsModel, ReviewListResponse (models)
- ReviewService with getReviews, submitReview, markHelpful, flagReview
- StarRatingInput (interactive 5-star picker with labels)
- StarDisplay (read-only fractional star display)
- ReviewSummary (average rating + distribution bars)
- ReviewForm (star picker + comment field + submit/update)
- ReviewCard (avatar, timestamp, expandable comment, helpful/flag)
- ReviewSection (main container with pagination and state mgmt)

Integration:
- Added to LearnMoreScreen (both mobile and desktop layouts)
- Review API endpoints point to app.eventifyplus.com Node.js backend
- EventModel updated with averageRating/reviewCount fields

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:04:37 +05:30
a7f3b215e4 perf: optimize loading time — paginated API, slim payloads, local category filtering
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>
2026-03-30 10:05:23 +05:30
c32f343558 fix: allow guests to view event details by passing pre-loaded data
LearnMoreScreen now accepts an optional initialEvent parameter so it
can render immediately from already-loaded data instead of re-fetching
from the event-details API. This fixes the guest-mode flow where the
unauthenticated API call was failing. Also changed getEventDetails to
requiresAuth: true so logged-in users send their token when the API
path is used.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:25:40 +05:30
1e90f5fc4b fix: reverse geocode stored coordinates to place names
When lat,lng coordinates are stored in SharedPreferences from
a previous session, reverse geocode them to a human-readable
location name (e.g. "Whitefield, Bengaluru") instead of showing
raw numbers like "10.57376,76.01188".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:30:05 +05:30
bc6fde1b90 feat: rebuild desktop UI to match Figma + website, hero slider improvements
- Desktop sidebar (262px, blue gradient, white pill nav), topbar (search + bell + avatar), responsive shell rewritten
- Desktop homepage: immersive hero with Ken Burns animation, pill category chips, date badge cards matching mvnew.eventifyplus.com/home
- Desktop calendar: 60/40 two-column layout with white background
- Desktop profile: full-width banner + 3-column event grids
- Desktop learn more: hero image + about/venue columns + gallery strip
- Desktop settings/contribute: polished to match design system
- Mobile hero slider: RepaintBoundary, animated dots with 44px tap targets, 5s auto-scroll, 8s post-swipe delay, shimmer loading, dynamic event type badge, human-readable dates
- Guest access: requiresAuth false on read endpoints
- Location fix: show place names instead of lat/lng coordinates
- Version 1.6.1+17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:28:19 +05:30
9dd78be03e fix: make Continue as Guest button visible, guard wishlist for guests
The guest button was nearly invisible (grey text, fontSize 13 on dark
background). Now uses white70, fontSize 15, TextButton with proper
tap padding. Also guards wishlist toggle on event detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:29:55 +05:30
1c73fb8d9d feat: add guest mode — browse events without login
New file: lib/core/auth/auth_guard.dart
  Static AuthGuard class with isGuest flag and requireLogin() helper
  that shows a login prompt bottom sheet when guests try protected actions.

login_screen.dart / desktop_login_screen.dart:
  Added "Continue as Guest" button below sign-up link.
  Sets AuthGuard.isGuest = true, then navigates to HomeScreen.

api_client.dart:
  _buildAuthBody() and GET auth check no longer throw when token is missing.
  If no token (guest), request proceeds without auth — backend decides.

home_screen.dart:
  Bottom nav guards: tapping Contribute (index 2) or Profile (index 3)
  as guest shows login prompt instead of navigating.

auth_service.dart:
  AuthGuard.setGuest(false) called on successful login AND register
  so guest flag is always cleared when user authenticates.

Guest CAN: browse home, calendar, search, filter, view event details.
Guest CANNOT: contribute, view profile, book events (prompts login).
2026-03-20 22:40:50 +05:30
0c4e62d00e perf: add memCacheWidth/memCacheHeight to all thumbnail images
All CachedNetworkImage instances in list/card contexts now decode at
2x rendered size instead of full resolution. A 3000x2000 event photo
previously decoded to ~24MB in GPU memory even when shown at 96px —
now decodes to <1MB.

Affected screens (16 CachedNetworkImage instances total):
- home_screen.dart: hero (800w), top card (300w), stacked (192w),
  horizontal (440x360), full-width (800x400), search (112x112),
  filter sheet (160x160), type icons (112x112)
- home_desktop_screen.dart: mini (128x128), grid (600x280), horiz (600x296)
- calendar_screen.dart: event card (400x300)
- profile_screen.dart: avatar (size*2), event tile (120x120)

learn_more_screen.dart intentionally unchanged — full-res for detail view.

Estimated memory reduction: ~500MB → ~30MB for a typical home screen.
2026-03-20 22:26:52 +05:30
6d29b95118 feat: redesign hero carousel — overlay, peek, scale, shimmer, FEATURED
UI/UX Pro Max + Flutter Expert audit of the home screen hero section.

viewportFraction 0.88
  Adjacent cards peek 6% on each side — users see there is more content
  to swipe without any instruction. Most impactful single-line UX change.

Overlay card design
  Title and metadata (date + location) now live ON the image behind a
  dark gradient (transparent → black 78%) at the bottom 65% of the card.
  Previously the title was below the image in a split layout that wasted
  space and felt disconnected. Card height increased 300 → 320px.

FEATURED glassmorphism badge
  Top-left corner chip with BackdropFilter blur (sigmaX/Y 10) and a
  white-border container gives each card a premium editorial feel.

Scale animation (AnimatedBuilder per card)
  Active card scales to 1.0, adjacent cards to 0.94. The AnimatedBuilder
  is placed inside itemBuilder so only the visible card rebuilds on each
  scroll tick — not the PageView or any ancestor.

Auto-scroll resets on page change
  onPageChanged now calls _startAutoScroll() which cancels the previous
  timer and starts a fresh 3-second countdown. Users who swipe manually
  always get a full 3 seconds to read before auto-advance continues.

Shimmer loading placeholder (_HeroShimmer)
  New StatefulWidget added below HomeScreen — a LinearGradient scan-line
  animated at 1400ms repeat. Replaces the flat Color(0xFF1A2A4A) box that
  looked broken while images were loading.
2026-03-19 12:03:13 +05:30
d74e637a59 perf: fix scroll lag on profile/contribute, unpin calendar gradient
profile_screen: SingleChildScrollView + Column eagerly built every event
card (all images, shadows, tiles) at once even when off-screen. Replaced
with CustomScrollView + SliverList so only visible tiles are built per
frame. Also switches to BouncingScrollPhysics for natural momentum.

contribute_screen: Each _formCard wrapped in RepaintBoundary so form
cards are isolated render layers — one card's repaint doesn't invalidate
its siblings. Added BouncingScrollPhysics to the form SingleChildScrollView.

calendar_screen: Blue gradient banner was Positioned(top:0) making it
sticky even as the user scrolled. Removed the fixed Positioned layer and
moved the gradient inside the CustomScrollView as the first sliver in a
Stack alongside the calendar card (which keeps its y=110 visual overlap).
Now the entire page — gradient, calendar, events — scrolls as one unit.
2026-03-18 17:16:38 +05:30
0982e4fdee perf: eliminate 60fps setState rebuilds causing scroll lag
Three root causes of the perceived scroll/animation lag:

1. profile_screen.dart — AnimationController listener called setState() on
   every animation frame (60fps × 2s = 120 full-tree rebuilds). The entire
   ProfileScreen with its nested lists and images was rebuilding 60 times per
   second just to update two small widgets (EXP bar + stat counters).
   Fix: remove setState() from listeners entirely; wrap only the EXP bar
   (LayoutBuilder) and stat row (IntrinsicHeight) in AnimatedBuilder so
   only those two leaf widgets re-render per frame.

2. learn_more_screen.dart — PageView.onPageChanged called setState() on
   every swipe, rebuilding the full event detail screen (blurred bg image,
   map, about section, etc.) just to update the 6px dot indicators.
   Fix: int _currentPage → ValueNotifier<int> _pageNotifier; wrap only the
   dot row and the blurred background image in ValueListenableBuilder.

3. search_screen.dart — BackdropFilter(ImageFilter.blur) without a
   RepaintBoundary forces Flutter to read every pixel behind the blur widget
   and composite it every frame. When the user scrolls the underlying list,
   the blur repaints continuously causing frame drops.
   Fix: wrap BackdropFilter in RepaintBoundary to isolate its repaint layer.
2026-03-18 17:00:25 +05:30
9fd5fc3d3b fix: load login background video from local asset instead of network URL
VideoPlayerController.networkUrl(Uri.parse('assets/login-bg.mp4')) silently
fails because 'assets/login-bg.mp4' is not a valid HTTP URL — the video
never initializes and the login screen shows a plain black background.

Fix: switch to VideoPlayerController.asset() and register the file in
pubspec.yaml. The MP4 is gitignored (22 MB) and kept local for builds.
2026-03-18 16:43:40 +05:30
2c109f692c fix: replace Column+Expanded with CustomScrollView on calendar screen
The mobile calendar layout had a split-height bug where the event list
at the bottom was squeezed into whatever pixel crumbs remained after the
calendar card and summary bar consumed their fixed space. On small phones
or 6-row months (~390px calendar), the events area could shrink to under
100px — barely one card, with no way to scroll.

Fix: replace Column + Expanded(ListView) with a CustomScrollView using
slivers so the full page — calendar card, summary bar, and event cards —
scrolls as one unified surface. SliverFillRemaining handles loading and
empty states so they always fill the visible viewport naturally.
2026-03-18 16:39:48 +05:30
8d9bbe888e chore: bump version to 1.5.0+15
versionCode: 15, versionName: 1.5(p)
Includes all performance fixes from previous commits.
2026-03-18 16:31:40 +05:30
002ed3ee98 perf: fix remaining 11 performance issues across 5 screens
Critical — Image.network → CachedNetworkImage:
- home_screen.dart: hero/carousel banner image now cached with placeholder
- profile_screen.dart: avatar and event list tile images now cached
- calendar_screen.dart: event card images now cached with placeholder

High:
- profile_screen.dart: TextEditingControllers in dialogs now properly
  disposed via .then() and after await to prevent memory leaks

Medium:
- search_screen.dart: shrinkWrap:true → ConstrainedBox(maxHeight:320) +
  ClampingScrollPhysics for smooth search result scrolling
- learn_more_screen.dart: MediaQuery.of(context) cached once per method
  instead of being called multiple times on every frame
2026-03-18 16:28:32 +05:30
2aa05366ad perf: fix Android lag, snapping animations & slow image loading
Fix 1: Replace overshooting Cubic(1.95) tab glider curve with
  Curves.easeInOutCubic; reduce duration 450ms → 280ms
Fix 2: Replace marquee jumpTo() with animateTo(linear) for fluid scroll
Fix 3: Replace Image.network with CachedNetworkImage in search results
Fix 4: Replace Image.network with CachedNetworkImage in desktop cards
Fix 5: Wrap IndexedStack children in RepaintBoundary to isolate
  repaints across tabs (Home/Calendar/Contribute/Profile)
Fix 6: Replace setState on PageView.onPageChanged with ValueNotifier
  so only the carousel dots widget rebuilds on swipe
Fix 7: Wrap animated tab glider in RepaintBoundary
Fix 8: Replace shrinkWrap:true ListView with ConstrainedBox(maxHeight)
  to eliminate O(n) layout pass in search results
Fix 9: Increase image cache to 200MB / 500 images in main()
2026-03-18 15:39:42 +05:30
50caad21a5 release: bump version to 1.4(p) (versionCode 14)
- Update versionCode 12 → 14, versionName 1.3(p) → 1.4(p)
- Update pubspec.yaml version to 1.4.0+14
- Add CHANGELOG.md with full version history
- Update README.md: version badge + changelog section
- Desktop Contribute Dashboard rebuilt to match web version
  - Contributor Dashboard title, 3-tab nav (Contribute/Leaderboard/Achievements)
  - Two-column submit form, tier milestone progress bar
  - Desktop leaderboard with podium, filters, rank table (green points)
  - Desktop achievements 3-column badge grid
  - Inline Reward Shop with RP balance
- Gamification feature module (EP, RP, leaderboard, achievements, shop)
- Profile screen redesigned to match web app layout with animations
- Home screen bottom sheet date filter chips
- Updated API endpoints, login/event detail screens, theme colors
- Added Gilroy font suite, responsive layout improvements
2026-03-18 11:10:56 +05:30
65 changed files with 14022 additions and 4044 deletions

View File

@@ -1,12 +0,0 @@
{
"version": "0.0.1",
"autoPort": true,
"configurations": [
{
"name": "flutter-web",
"runtimeExecutable": "bash",
"runtimeArgs": ["/Users/bshtechnologies/Documents/Eventify-frontend/run_web.sh"],
"port": 8080
}
]
}

10
.gitignore vendored
View File

@@ -52,3 +52,13 @@ web/assets/login-bg.mp4
*.keystore
# large binary assets — keep local only, not tracked in git
assets/login-bg.mp4
# Claude Code / MCP / vibe-coding tool artifacts — keep local only
.claude/
.mcp.json
CLAUDE.md
_notes/
.obsidian/
hero_section_improvements.csv
security_audit_report.csv
feature_gap_analysis.csv

View File

@@ -6,6 +6,131 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [2.0.1] - 2026-04-10
Patch release — hotfix for Google Sign-In broken in 2.0.0.
### Fixed
- **Sign in with Google** (`lib/screens/login_screen.dart`): Resolved authentication failure introduced in 2.0.0. Google OAuth flow now completes correctly and exchanges tokens with Django `POST /accounts/google-auth/` as expected.
---
## [2.0.0] - 2026-04-10
Public release milestone. Full backend integration, real image upload pipeline, complete personal profile system, and production Android build infrastructure.
### Added
- **Real image upload pipeline** (`lib/core/api/api_client.dart`): `uploadFile()` method uses `http.MultipartRequest` with explicit MIME type detection from file extension. Supports JPEG, PNG, WebP, MP4, MOV. Files upload to Node.js `/api/v1/upload/file` → OneDrive via Microsoft Graph API, returning a shareable anonymous link.
- **Contribute image upload to OneDrive** (`lib/features/gamification/services/gamification_service.dart`): `submitContribution()` now uploads all selected images before submitting the event form. Uploaded file metadata (including OneDrive share URL) is passed as `media` array in the contribution payload — replacing the broken device-path-as-string approach.
- **Upload endpoint constant** (`lib/core/api/api_endpoints.dart`): `ApiEndpoints.uploadFile` pointing to `$_nodeBase/v1/upload/file`.
- **Full personal info form in Edit Profile sheet** (`lib/screens/profile_screen.dart`):
- First Name, Last Name fields
- Email (read-only, locked from direct edit)
- Phone number field
- District picker with 183-day change cooldown — shows next-change date when locked
- Place, Pincode, State, Country fields
- All fields loaded from SharedPreferences cache and API on open; saved via `PATCH /api/user/update-profile/`
### Changed
- **App version** displayed in Settings → About updated to `2.0(b)` (`lib/screens/settings_screen.dart`).
- **All "(demo)" labels replaced with "(coming soon)"** across the app:
- `settings_screen.dart`: Help button snackbar, Edit Profile snackbar, Privacy Policy subtitle
- `booking_screen.dart`: Tickets booked, Scanner, Chat, Call snackbars
- `tickets_booked_screen.dart`: Scanner, Chat/WhatsApp, Call snackbars
- `calendar_screen.dart`: Notifications snackbar
- **`pubspec.yaml` version bumped** to `2.0.0+20` (version name `2.0.0`, build code `20`).
### Fixed
- **Android build version override** (`android/app/build.gradle.kts`): Removed hardcoded `versionCode = 17` and `versionName = "1.6.1(p)"` — both now read from `flutter.versionCode` / `flutter.versionName` (sourced from `pubspec.yaml`). This was causing Play Store rejections ("version code 17 already used") on every release build.
- **`http_parser` dependency added** (`pubspec.yaml`): Required for explicit `MediaType` MIME typing in `MultipartRequest`. Without it, file uploads defaulted to `application/octet-stream` and were rejected by the Node.js multer middleware.
### Infrastructure
- **Production AAB built and signed** with `upload-keystore-new.jks` — build 20, version name `2.0` — submitted to Google Play Console.
- **`build.gradle.kts` signing config** reads `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` from `gradle.properties` or environment variables (no secrets in source).
---
## [1.6.1] - 2026-04-04
Phase 4 — animation polish and final feature gaps. Flutter app reaches full feature parity with Consumer Web App v1.4.9.
### Added
- **BouncingLoader widget** (`lib/widgets/bouncing_loader.dart`): 3-dot bouncing animation with staggered 200 ms delays using `Curves.bounceOut`. Replaces `CircularProgressIndicator` in home, contribute, and review screens. Accepts `color`, `dotSize`, and `spacing` parameters.
- **DiceBear Notionists avatars on review cards** (REV-001): `CachedNetworkImage` fetches `api.dicebear.com/9.x/notionists/svg?seed={username}`. Falls back to coloured initial letter `CircleAvatar` on error or while loading.
- **Server-side event search** (HOME-007): Search modal now sends `q` param to `EventsByPincodeView`; client-side filter stays for instant `onChanged` feedback while server results load on submit. Cache is bypassed for search queries. Django backend updated with `Q(title__icontains=q) | Q(description__icontains=q)` OR filter.
- **`flutter_staggered_animations: ^1.1.1`** added to pubspec.
### Changed
- **Review list stagger animation** (REV-003): `AnimationLimiter` + `AnimationConfiguration.toStaggeredList` wraps review cards with 375 ms slide-up + fade-in per item.
- **Review submit success spring animation** (REV-004): Checkmark icon now animates with `ScaleTransition` driven by `Curves.elasticOut` (600 ms) instead of a static icon swap.
- **Hero transitions on event cards** (UX-005): `Hero(tag: 'event-hero-{id}')` wraps event images in home screen and matching destination in learn more screen — enabling shared-element transitions.
- **FadeTransition on learn more screen** (UX-005): Screen body fades in with `Curves.easeIn` (350 ms) after event data loads.
- **AnimatedList stagger on leaderboard** (UX-005): `SliverList` entries animate with `AnimationConfiguration.staggeredList` — 375 ms slide-up + fade-in per row.
---
## [1.6.0] - 2026-04-04
Phase 3 — 26 medium-priority gaps. Profile editing, contributor profiles, share cards, booking promo codes, and UX system components.
### Added
- **Eventify ID badge** (AUTH-003): Verified badge displayed on profile and contributor cards for accounts with confirmed identity.
- **DiceBear TierAvatarRing** (`lib/widgets/tier_avatar_ring.dart`): Tier-coloured ring around profile avatars using DiceBear seed — Bronze/Silver/Gold/Platinum/Diamond colours.
- **Profile photo upload to server** (AUTH-006 / PROF-002): `PATCH /api/user/update-profile/` multipart endpoint; photo picker + crop flow integrated.
- **District picker** (PROF-004): 14 Kerala districts selectable from a bottom sheet; stored against user profile.
- **183-day profile cooldown lock** (AUTH-005): Username and display name locked for 183 days after last change; countdown shown in edit form.
- **Kerala pincodes JSON** (`assets/data/kerala_pincodes.json`) (LOC-003): Full offline pincode dataset covering all 14 districts; powers location-aware event discovery without API round trips.
- **Promo code input on booking** (BOOK-003): `POST /bookings/apply-promo/` endpoint; inline validation with success/error state in booking bar.
- **Contributor profile screen** (`lib/screens/contributor_profile_screen.dart`) (CTR-004/005): Public view of any contributor's stats, tier, events submitted, and achievements.
- **Share rank card** (`lib/features/share/share_rank_card.dart`) (SHARE-001/002): Generates a shareable tier/EP card image; `share_plus` used for native share sheet.
- **Share status button on contributor dashboard** (CTR-003): `OutlinedButton.icon` with `Share.share()` near tier/EP display.
- **GlassCard widget** (`lib/widgets/glass_card.dart`) (UX-003): Reusable frosted-glass surface used across gamification and profile screens.
- **EventifyBottomSheet** (`lib/widgets/eventify_bottom_sheet.dart`) (UX-004): Standardised bottom sheet with drag handle, rounded corners, and safe-area inset.
- **Featured events carousel** (HOME-004): Auto-scrolling hero carousel for featured/sponsored events on home screen.
- **Event image gallery** (EVT-001): Full-screen `PageView` carousel inside learn more screen with dot indicator.
### Changed
- **Real gamification data** (GAM-003/004/006): EP/RP transaction history, tier progression, and leaderboard all wired to live Node.js API — mock data removed.
- **Leaderboard card district display** (LDR-003): District badge shown per rank row; district filter pill row added above leaderboard.
- **Achievement badge display + unlock animation** (ACH-002/003): Badges rendered from API; confetti-style animation plays on first unlock.
- **Review responses** (REV-002): Organiser reply thread displayed below each review card.
- **Profile bio and social links** (PROF-001): Edit form includes bio textarea and links for Instagram, Twitter, LinkedIn.
---
## [1.5.1] - 2026-04-04
Phase 2 — 11 high-priority gaps. Authentication hardening, location services, real gamification data hookup, skeleton loading, and booking flow fixes.
### Added
- **Email OTP verification** (AUTH-004): 6-digit OTP sent on registration; verification screen blocks app access until confirmed.
- **Password reset flow** (AUTH-002): Forgot-password → OTP → new password screens; Django `POST /accounts/password-reset/` endpoint integrated.
- **Location permission + haversine distance sorting** (LOC-001/002): `geolocator` requests permission at startup; events sorted by straight-line distance from user's GPS coordinate.
- **Skeleton loading with shimmer** (UX-001): `shimmer: ^3.0.0` added; event feed, leaderboard, and profile screens show shimmer placeholders while data loads.
- **Contributor stats real API** (CTR-001): EP/RP balance and tier fetched from Node.js gamification endpoint on dashboard load.
- **Achievement progress tracking** (ACH-001): Progress bars and completion state fetched from API; local mock removed.
- **Profile stats row** (PROF-003): Likes / Posts / Views counts fetched from user profile API and displayed in profile header.
### Changed
- **Search modal with server pincode detection** (HOME-001/002/003): Search bottom sheet auto-detects user pincode; category filter chips filter from API results.
- **Real tier and EP display** (GAM-002/005): Contributor dashboard shows live tier and EP from Node.js API; tier badge in profile header updated to match.
- **District filter on leaderboard** (LDR-002): Leaderboard district pills populated from API; selecting filters rank table in real time.
- **Booking bar fixes** (EVT-003/BOOK-004): Fixed ticket-count stepper; booking confirmation screen correctly shows booking reference.
---
## [1.5.0] - 2026-04-04
Phase 1 — critical gaps. Live backend integration replacing all mock/stub data, payment checkout, and OAuth.
### Added
- **Gamification API integration**: EP, RP, leaderboard, and achievements wired to live Node.js endpoints at `app.eventifyplus.com/api/v1/` — all mock `GamificationService` data replaced.
- **Razorpay checkout** (BOOK-001): Native Razorpay SDK integrated; `POST /bookings/create/` → order creation → Razorpay payment sheet → `POST /bookings/verify/` webhook.
- **Google OAuth login** (AUTH-001): `google_sign_in` flow; tokens exchanged with Django `POST /accounts/google-auth/` endpoint.
- **Notification panel** (NOTIF-002/003/004): `DraggableScrollableSheet` notification drawer with 4 colour-coded notification types (booking, event, system, promo); mark-individual-read and mark-all-read actions.
---
## [1.4.0] - 2026-03-18
### Added

Binary file not shown.

View File

@@ -22,8 +22,8 @@ android {
applicationId = "com.sicherhaven.eventify"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = 17
versionName = "1.6.1(p)"
versionCode = flutter.versionCode
versionName = flutter.versionName
}
// ---------- SIGNING CONFIG ----------

View File

@@ -26,3 +26,62 @@
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task
# Razorpay
-keepattributes *Annotation*,Signature,*Annotation*
-dontwarn com.razorpay.**
-keep class com.razorpay.** { *; }
-optimizations !method/inlining/
-keepclasseswithmembers class * {
public void onPayment*(...);
}
-keep class proguard.annotation.Keep
-keep class proguard.annotation.KeepClassMembers
-keep @proguard.annotation.Keep class * { *; }
-keep @proguard.annotation.KeepClassMembers class * {
<fields>;
<methods>;
}
# Google Sign-In / Play Services
-keep class com.google.android.gms.** { *; }
-keep interface com.google.android.gms.** { *; }
-dontwarn com.google.android.gms.**
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
# Geolocator / Geocoding
-keep class com.baseflow.** { *; }
-dontwarn com.baseflow.**
# url_launcher, share_plus, image_picker, path_provider, etc.
-keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.plugins.**
# OkHttp (used by many network libs)
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Parcelable classes
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
# Keep Serializable classes
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}

View File

@@ -18,6 +18,18 @@
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_GOOGLE_MAPS_API_KEY"/>
<!-- Splash video plays first, then launches MainActivity -->
<activity
android:name=".SplashActivity"
android:exported="true"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -31,11 +43,6 @@
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below. Used by Flutter tool. -->

View File

@@ -0,0 +1,80 @@
package com.sicherhaven.eventify
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowInsetsController
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.VideoView
class SplashActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
// White background matches splash logo/video content
val container = FrameLayout(this)
container.setBackgroundColor(Color.WHITE)
setContentView(container, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
// Edge-to-edge: hide both status bar and navigation bar (after setContentView so DecorView exists)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.insetsController?.let {
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
}
val videoView = VideoView(this)
container.addView(videoView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
))
val uri = Uri.parse("android.resource://$packageName/${R.raw.splash_video}")
videoView.setVideoURI(uri)
videoView.setOnPreparedListener { mp ->
mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)
mp.start()
}
videoView.setOnCompletionListener {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
videoView.setOnErrorListener { _, _, _ ->
startActivity(Intent(this, MainActivity::class.java))
finish()
true
}
videoView.requestFocus()
}
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -47,5 +47,18 @@
<true/>
<key>UIStatusBarHidden</key>
<false/>
<key>GIDClientID</key>
<string>639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd.apps.googleusercontent.com</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,94 @@
// lib/core/analytics/posthog_service.dart
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
/// Lightweight PostHog analytics client using the HTTP API.
/// Works with Dart 2.x (no posthog_flutter SDK needed).
class PostHogService {
static const String _apiKey = 'phc_xXxn0COAwWRj3AU7fspsTuesCIK0aBGXb3zaIIJRgZA';
static const String _host = 'https://eu.i.posthog.com';
static const String _distinctIdKey = 'posthog_distinct_id';
static PostHogService? _instance;
String? _distinctId;
PostHogService._();
static PostHogService get instance {
_instance ??= PostHogService._();
return _instance!;
}
/// Initialize and load or generate a distinct ID.
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_distinctId = prefs.getString(_distinctIdKey);
if (_distinctId == null) {
_distinctId = DateTime.now().millisecondsSinceEpoch.toRadixString(36) +
UniqueKey().toString().hashCode.toRadixString(36);
await prefs.setString(_distinctIdKey, _distinctId!);
}
}
/// Identify a user (call after login).
void identify(String userId, {Map<String, dynamic>? properties}) {
_distinctId = userId;
SharedPreferences.getInstance().then((prefs) {
prefs.setString(_distinctIdKey, userId);
});
_send('identify', {
'distinct_id': userId,
if (properties != null) '\$set': properties,
});
}
/// Capture a custom event.
void capture(String event, {Map<String, dynamic>? properties}) {
_send('capture', {
'event': event,
'distinct_id': _distinctId ?? 'anonymous',
'properties': {
...?properties,
'\$lib': 'flutter',
'\$lib_version': '1.0.0',
},
});
}
/// Capture a screen view.
void screen(String screenName, {Map<String, dynamic>? properties}) {
capture('\$screen', properties: {
'\$screen_name': screenName,
...?properties,
});
}
/// Reset identity (call on logout).
void reset() {
_distinctId = null;
SharedPreferences.getInstance().then((prefs) {
prefs.remove(_distinctIdKey);
});
}
/// Send event to PostHog API (fire-and-forget).
void _send(String endpoint, Map<String, dynamic> body) {
final payload = {
'api_key': _apiKey,
'timestamp': DateTime.now().toUtc().toIso8601String(),
...body,
};
// Fire and forget — don't block the UI
http.post(
Uri.parse('$_host/$endpoint/'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(payload),
).catchError((e) {
if (kDebugMode) debugPrint('PostHog error: $e');
});
}
}

View File

@@ -1,13 +1,17 @@
// lib/core/api/api_client.dart
import 'dart:async';
import 'dart:convert';
import 'dart:io' show SocketException;
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import '../storage/token_storage.dart';
class ApiClient {
static const Duration _timeout = Duration(seconds: 30);
static const Duration _timeout = Duration(seconds: 25);
static const Duration _retryDelay = Duration(milliseconds: 600);
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
static const bool _developmentMode = true;
static const bool _developmentMode = false;
/// POST request
///
@@ -27,13 +31,7 @@ class ApiClient {
late http.Response response;
try {
response = await http
.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(finalBody),
)
.timeout(_timeout);
response = await _postWithRetry(url, headers, finalBody);
} catch (e) {
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
@@ -57,6 +55,39 @@ class ApiClient {
'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},
],
};
}
}
@@ -66,6 +97,82 @@ class ApiClient {
return _handleResponse(url, response, finalBody);
}
/// POST with one retry on transient network errors.
/// Retries on SocketException / TimeoutException only.
Future<http.Response> _postWithRetry(
String url,
Map<String, String> headers,
Map<String, dynamic> body,
) async {
try {
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
} on SocketException {
if (kDebugMode) debugPrint('ApiClient.post retry after SocketException');
await Future.delayed(_retryDelay);
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
} on TimeoutException {
if (kDebugMode) debugPrint('ApiClient.post retry after TimeoutException');
await Future.delayed(_retryDelay);
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
}
}
/// 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));
const _mimeMap = <String, List<String>>{
'jpg': ['image', 'jpeg'],
'jpeg': ['image', 'jpeg'],
'png': ['image', 'png'],
'webp': ['image', 'webp'],
'mp4': ['video', 'mp4'],
'mov': ['video', 'quicktime'],
};
final ext = filePath.split('.').last.toLowerCase();
final parts = _mimeMap[ext] ?? ['image', 'jpeg'];
request.files.add(await http.MultipartFile.fromPath(
'file',
filePath,
contentType: MediaType(parts[0], parts[1]),
));
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.
@@ -76,21 +183,24 @@ class ApiClient {
bool requiresAuth = true,
}) async {
// build final query params including auth if needed
final Map<String, dynamic> finalParams = {};
final originalUri = Uri.parse(url);
final queryParams = <String, String>{...originalUri.queryParameters};
if (requiresAuth) {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (token != null && username != null) {
finalParams['token'] = token;
finalParams['username'] = username;
queryParams['token'] = token;
queryParams['username'] = username;
}
// Guest mode: proceed without token — let backend decide
}
if (params != null) finalParams.addAll(params);
if (params != null) {
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
}
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
final uri = originalUri.replace(queryParameters: queryParams);
late http.Response response;
try {
@@ -100,9 +210,156 @@ class ApiClient {
throw Exception('Network error: $e');
}
return _handleResponse(url, response, finalParams);
return _handleResponse(url, response, queryParams);
}
// ---------------------------------------------------------------------------
// 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': '680001',
'place': 'Thekkinkadu Maidanam',
'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': 'Maidanam Grounds',
'event_status': 'active',
'latitude': 10.5276,
'longitude': 76.2144,
'location_name': 'Thrissur',
'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 = {};

View File

@@ -2,14 +2,20 @@
class ApiEndpoints {
// Change this to your desired backend base URL (local or UAT)
// For local Django dev use: "http://127.0.0.1:8000/api"
// For UAT: "https://uat.eventifyplus.com/api"
static const String baseUrl = "https://uat.eventifyplus.com/api";
// em.eventifyplus.com / uat.eventifyplus.com DNS → K8s, broken TLS. backend.eventifyplus.com → EC2 174.129.72.160, valid cert.
static const String baseUrl = "https://backend.eventifyplus.com/api";
/// Base URL for media files (images, icons uploaded via Django admin).
/// Relative paths like `/media/...` are resolved against this.
static const String mediaBaseUrl = "https://backend.eventifyplus.com";
// Auth
static const String register = "$baseUrl/user/register/";
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/";
static const String forgotPassword = "$baseUrl/user/forgot-password/";
// Events
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
@@ -18,17 +24,46 @@ 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/";
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
// Gamification / Contributor Module (TechDocs v2)
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
static const String leaderboard = "$baseUrl/v1/leaderboard/";
static const String shopItems = "$baseUrl/v1/shop/items/";
static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
// Reviews (served by Node.js backend via app.eventifyplus.com)
static const String _reviewBase = "https://app.eventifyplus.com/api/reviews";
static const String reviewSubmit = "$_reviewBase/submit";
static const String reviewList = "$_reviewBase/list";
static const String reviewHelpful = "$_reviewBase/helpful";
static const String reviewFlag = "$_reviewBase/flag";
// 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";
static const String shopItems = "$_nodeBase/v1/shop/items";
static const String shopRedeem = "$_nodeBase/v1/shop/redeem";
static const String contributeSubmit = "$_nodeBase/v1/gamification/submit-event";
static const String gradeContribution = "$_nodeBase/v1/admin/contributions/"; // append {id}/grade/
// Bookings
static const String ticketMetaList = "$baseUrl/bookings/ticket-meta/list/";
static const String cartAdd = "$baseUrl/bookings/cart/add/";
static const String checkout = "$baseUrl/bookings/checkout/";
static const String checkIn = "$baseUrl/bookings/check-in/";
// Auth - Google OAuth
static const String googleLogin = "$baseUrl/user/google-login/";
// Notifications
static const String notificationList = "$baseUrl/notifications/list/";
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
static const String notificationCount = "$baseUrl/notifications/count";
}

View File

@@ -0,0 +1,73 @@
// lib/core/utils/error_utils.dart
/// Converts raw exceptions into user-friendly messages.
/// Strips technical details (hostnames, ports, stack traces, exception chains)
/// and returns a clean message safe to display in the UI.
String userFriendlyError(Object e) {
final raw = e.toString();
// Network / connectivity issues
if (raw.contains('SocketException') ||
raw.contains('Connection refused') ||
raw.contains('Connection reset') ||
raw.contains('Network is unreachable') ||
raw.contains('No address associated') ||
raw.contains('Failed to fetch') ||
raw.contains('HandshakeException') ||
raw.contains('ClientException')) {
return 'Unable to connect. Please check your internet connection and try again.';
}
// Timeout
if (raw.contains('TimeoutException') || raw.contains('timed out')) {
return 'The request took too long. Please try again.';
}
// Rate limited
if (raw.contains('status 429') || raw.contains('throttled') || raw.contains('Too Many Requests')) {
return 'Too many requests. Please wait a moment and try again.';
}
// Auth expired / forbidden
if (raw.contains('status 401') || raw.contains('Unauthorized')) {
return 'Session expired. Please log in again.';
}
if (raw.contains('status 403') || raw.contains('Forbidden')) {
return 'You do not have permission to perform this action.';
}
// Server error
if (RegExp(r'status 5\d\d').hasMatch(raw)) {
return 'Something went wrong on our end. Please try again later.';
}
// Not found
if (raw.contains('status 404') || raw.contains('Not Found')) {
return 'The requested resource was not found.';
}
// Strip Exception wrappers and nested chains for validation messages
var cleaned = raw
.replaceAll(RegExp(r'Exception:\s*'), '')
.replaceAll(RegExp(r'Failed to \w+ \w+:\s*'), '')
.replaceAll(RegExp(r'Network error:\s*'), '')
.replaceAll(RegExp(r'Request failed \(status \d+\)\s*'), '')
.trim();
// If the cleaned message is empty or still looks technical, use a generic fallback
if (cleaned.isEmpty ||
cleaned.contains('errno') ||
cleaned.contains('address =') ||
cleaned.contains('port =') ||
cleaned.startsWith('{') ||
cleaned.startsWith('[')) {
return 'Something went wrong. Please try again.';
}
// Capitalize first letter
if (cleaned.isNotEmpty) {
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
}
return cleaned;
}

View File

@@ -59,6 +59,19 @@ class AuthProvider extends ChangeNotifier {
}
}
/// Google OAuth login.
Future<void> googleLogin() async {
_loading = true;
notifyListeners();
try {
final user = await _authService.googleLogin();
_user = user;
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> logout() async {
await _authService.logout();
_user = null;

View File

@@ -1,15 +1,24 @@
// lib/features/auth/services/auth_service.dart
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:google_sign_in/google_sign_in.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/auth/auth_guard.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/analytics/posthog_service.dart';
import '../models/user_model.dart';
class AuthService {
final ApiClient _api = ApiClient();
/// Google OAuth 2.0 Web Client ID from Google Cloud Console.
/// Must match the `GOOGLE_CLIENT_ID` env var set on the Django backend
/// so the server can verify the `id_token` audience.
/// Source: Google Cloud Console → APIs & Services → Credentials → Web application.
static const String _googleWebClientId =
'639347358523-mtkm3i8vssuhsun80rp2llt09eou0p8g.apps.googleusercontent.com';
/// LOGIN → returns UserModel
Future<UserModel> login(String username, String password) async {
try {
@@ -58,6 +67,24 @@ 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',
});
PostHogService.instance.capture('user_logged_in');
return UserModel.fromJson(res);
} catch (e) {
if (kDebugMode) debugPrint('AuthService.login error: $e');
@@ -70,15 +97,20 @@ class AuthService {
required String email,
required String phoneNumber,
required String password,
String? district,
}) async {
try {
final body = <String, dynamic>{
"email": email,
"phone_number": phoneNumber,
"password": password,
};
if (district != null && district.isNotEmpty) {
body["district"] = district;
}
final res = await _api.post(
ApiEndpoints.register,
body: {
"email": email,
"phone_number": phoneNumber,
"password": password,
},
body: body,
requiresAuth: false,
);
@@ -130,6 +162,79 @@ class AuthService {
}
}
/// GOOGLE OAUTH LOGIN → returns UserModel
Future<UserModel> googleLogin() async {
try {
final googleSignIn = GoogleSignIn(
scopes: const ['email', 'profile'],
serverClientId: _googleWebClientId,
);
final account = await googleSignIn.signIn();
if (account == null) throw Exception('Google sign-in cancelled');
final auth = await account.authentication;
final idToken = auth.idToken;
if (idToken == null) throw Exception('Failed to get Google ID token');
final res = await _api.post(
ApiEndpoints.googleLogin,
body: {'id_token': idToken},
requiresAuth: false,
);
final token = res['token'];
if (token == null) throw Exception('Token missing from response');
final serverEmail = (res['email'] as String?) ?? account.email;
final displayName = (res['username'] as String?) ?? account.displayName ?? serverEmail;
AuthGuard.setGuest(false);
await TokenStorage.saveToken(token.toString(), serverEmail);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('current_email', serverEmail);
await prefs.setString('email', serverEmail);
final perKey = 'display_name_$serverEmail';
if ((prefs.getString(perKey) ?? '').isEmpty) {
await prefs.setString(perKey, displayName);
}
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',
});
PostHogService.instance.capture('user_logged_in');
return UserModel.fromJson(res);
} catch (e) {
if (kDebugMode) debugPrint('AuthService.googleLogin error: $e');
rethrow;
}
}
/// FORGOT PASSWORD → backend sends reset instructions by email.
/// Frontend never leaks whether the email is registered — same UX on success and 404.
Future<void> forgotPassword(String email) async {
await _api.post(
ApiEndpoints.forgotPassword,
body: {'email': email},
requiresAuth: false,
);
}
/// Logout clear auth token and current_email (keep per-account display_name entries so they persist)
Future<void> logout() async {
try {
@@ -141,6 +246,8 @@ class AuthService {
// Also remove canonical 'email' pointing to current user
await prefs.remove('email');
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
PostHogService.instance.capture('user_logged_out');
PostHogService.instance.reset();
} catch (e) {
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
}

View File

@@ -0,0 +1,87 @@
// lib/features/booking/models/booking_models.dart
class TicketMetaModel {
final int id;
final int eventId;
final String ticketType;
final double price;
final int availableQuantity;
final String? description;
const TicketMetaModel({
required this.id,
required this.eventId,
required this.ticketType,
required this.price,
this.availableQuantity = 0,
this.description,
});
factory TicketMetaModel.fromJson(Map<String, dynamic> json) {
return TicketMetaModel(
id: (json['id'] as num?)?.toInt() ?? 0,
eventId: (json['event_id'] as num?)?.toInt() ?? (json['event'] as num?)?.toInt() ?? 0,
ticketType: json['ticket_type'] as String? ?? json['name'] as String? ?? '',
price: (json['price'] as num?)?.toDouble() ?? 0.0,
availableQuantity: (json['available_quantity'] as num?)?.toInt() ?? 0,
description: json['description'] as String?,
);
}
}
class CartItemModel {
final TicketMetaModel ticket;
int quantity;
CartItemModel({required this.ticket, this.quantity = 1});
double get subtotal => ticket.price * quantity;
}
class ShippingDetails {
final String name;
final String email;
final String phone;
final String? address;
final String? city;
final String? state;
final String? zipCode;
const ShippingDetails({
required this.name,
required this.email,
required this.phone,
this.address,
this.city,
this.state,
this.zipCode,
});
Map<String, dynamic> toJson() => {
'name': name,
'email': email,
'phone': phone,
if (address != null) 'address': address,
if (city != null) 'city': city,
if (state != null) 'state': state,
if (zipCode != null) 'zip_code': zipCode,
};
}
class OrderSummary {
final List<CartItemModel> items;
final double subtotal;
final double discount;
final double tax;
final double total;
final String? couponCode;
const OrderSummary({
required this.items,
required this.subtotal,
this.discount = 0,
this.tax = 0,
required this.total,
this.couponCode,
});
}

View File

@@ -0,0 +1,216 @@
// lib/features/booking/providers/checkout_provider.dart
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/utils/error_utils.dart';
import '../models/booking_models.dart';
import '../services/booking_service.dart';
enum CheckoutStep { tickets, details, payment, confirmation }
class CheckoutProvider extends ChangeNotifier {
final BookingService _service = BookingService();
// Event being booked
int? eventId;
String eventName = '';
// Step tracking
CheckoutStep currentStep = CheckoutStep.tickets;
// Ticket selection
List<TicketMetaModel> availableTickets = [];
List<CartItemModel> cart = [];
// Shipping
ShippingDetails? shippingDetails;
// Coupon / promo
String? couponCode;
double discountAmount = 0.0;
String? promoMessage;
bool promoApplied = false;
// Status
bool loading = false;
String? error;
String? paymentId;
/// Initialize checkout for an event.
Future<void> initForEvent(int eventId, String eventName) async {
this.eventId = eventId;
this.eventName = eventName;
currentStep = CheckoutStep.tickets;
cart = [];
shippingDetails = null;
couponCode = null;
discountAmount = 0.0;
promoMessage = null;
promoApplied = false;
paymentId = null;
error = null;
loading = true;
notifyListeners();
try {
availableTickets = await _service.getTicketMeta(eventId);
} catch (e) {
error = userFriendlyError(e);
} finally {
loading = false;
notifyListeners();
}
}
/// Add or update cart item.
void setTicketQuantity(TicketMetaModel ticket, int qty) {
cart.removeWhere((c) => c.ticket.id == ticket.id);
if (qty > 0) {
cart.add(CartItemModel(ticket: ticket, quantity: qty));
}
notifyListeners();
}
double get subtotal => cart.fold(0, (sum, item) => sum + item.subtotal);
double get total => subtotal - discountAmount;
bool get hasItems => cart.isNotEmpty;
/// Move to next step.
void nextStep() {
if (currentStep == CheckoutStep.tickets && hasItems) {
currentStep = CheckoutStep.details;
} else if (currentStep == CheckoutStep.details && shippingDetails != null) {
currentStep = CheckoutStep.payment;
}
notifyListeners();
}
/// Move to previous step.
void previousStep() {
if (currentStep == CheckoutStep.payment) {
currentStep = CheckoutStep.details;
} else if (currentStep == CheckoutStep.details) {
currentStep = CheckoutStep.tickets;
}
notifyListeners();
}
/// Set shipping details from form.
void setShipping(ShippingDetails details) {
shippingDetails = details;
notifyListeners();
}
/// Apply a promo code against the backend.
Future<bool> applyPromo(String code) async {
if (code.trim().isEmpty) return false;
loading = true;
error = null;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
final response = await http.post(
Uri.parse('${ApiEndpoints.baseUrl}/bookings/apply-promo/'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode({'code': code.trim(), 'event_id': eventId}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (data['valid'] == true) {
discountAmount = (data['discount_amount'] as num?)?.toDouble() ?? 0.0;
couponCode = code.trim();
promoMessage = data['message'] as String? ?? 'Promo applied!';
promoApplied = true;
notifyListeners();
return true;
} else {
promoMessage = data['message'] as String? ?? 'Invalid promo code';
promoApplied = false;
discountAmount = 0.0;
couponCode = null;
notifyListeners();
return false;
}
} else {
promoMessage = 'Could not apply promo code';
return false;
}
} catch (e) {
promoMessage = 'Could not apply promo code';
return false;
} finally {
loading = false;
notifyListeners();
}
}
/// Remove applied promo code.
void resetPromo() {
discountAmount = 0.0;
couponCode = null;
promoMessage = null;
promoApplied = false;
notifyListeners();
}
/// Process checkout on backend.
Future<Map<String, dynamic>> processCheckout() async {
loading = true;
error = null;
notifyListeners();
try {
final tickets = cart.map((c) => {
'ticket_meta_id': c.ticket.id,
'quantity': c.quantity,
}).toList();
final res = await _service.processCheckout(
eventId: eventId!,
tickets: tickets,
shippingDetails: shippingDetails?.toJson() ?? {},
couponCode: couponCode,
);
return res;
} catch (e) {
error = userFriendlyError(e);
rethrow;
} finally {
loading = false;
notifyListeners();
}
}
/// Mark payment as complete.
void markPaymentSuccess(String id) {
paymentId = id;
currentStep = CheckoutStep.confirmation;
notifyListeners();
}
/// Reset checkout state.
void reset() {
eventId = null;
eventName = '';
currentStep = CheckoutStep.tickets;
availableTickets = [];
cart = [];
shippingDetails = null;
couponCode = null;
discountAmount = 0.0;
promoMessage = null;
promoApplied = false;
paymentId = null;
error = null;
loading = false;
notifyListeners();
}
}

View File

@@ -0,0 +1,53 @@
// lib/features/booking/services/booking_service.dart
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../models/booking_models.dart';
class BookingService {
final ApiClient _api = ApiClient();
/// Fetch available ticket types for an event.
Future<List<TicketMetaModel>> getTicketMeta(int eventId) async {
final res = await _api.post(
ApiEndpoints.ticketMetaList,
body: {'event_id': eventId},
);
final rawList = res['ticket_metas'] ?? res['tickets'] ?? res['data'] ?? [];
if (rawList is List) {
return rawList
.map((e) => TicketMetaModel.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
return [];
}
/// Add item to cart.
Future<Map<String, dynamic>> addToCart({
required int ticketMetaId,
required int quantity,
}) async {
return await _api.post(
ApiEndpoints.cartAdd,
body: {'ticket_meta_id': ticketMetaId, 'quantity': quantity},
);
}
/// Process checkout — creates booking + returns order ID for payment.
Future<Map<String, dynamic>> processCheckout({
required int eventId,
required List<Map<String, dynamic>> tickets,
required Map<String, dynamic> shippingDetails,
String? couponCode,
}) async {
return await _api.post(
ApiEndpoints.checkout,
body: {
'event_id': eventId,
'tickets': tickets,
'shipping': shippingDetails,
if (couponCode != null) 'coupon_code': couponCode,
},
);
}
}

View File

@@ -0,0 +1,67 @@
// lib/features/booking/services/payment_service.dart
import 'package:razorpay_flutter/razorpay_flutter.dart';
import 'package:flutter/foundation.dart';
typedef PaymentSuccessCallback = void Function(PaymentSuccessResponse response);
typedef PaymentErrorCallback = void Function(PaymentFailureResponse response);
typedef ExternalWalletCallback = void Function(ExternalWalletResponse response);
class PaymentService {
late Razorpay _razorpay;
// Razorpay test key — matches web app
static const String _testKey = 'rzp_test_S49PVZmqAVoWSH';
PaymentSuccessCallback? onSuccess;
PaymentErrorCallback? onError;
ExternalWalletCallback? onExternalWallet;
void initialize({
required PaymentSuccessCallback onSuccess,
required PaymentErrorCallback onError,
ExternalWalletCallback? onExternalWallet,
}) {
_razorpay = Razorpay();
this.onSuccess = onSuccess;
this.onError = onError;
this.onExternalWallet = onExternalWallet;
_razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handleSuccess);
_razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _handleError);
_razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet);
}
void openPayment({
required double amount,
required String email,
required String phone,
required String eventName,
String? orderId,
}) {
final options = <String, dynamic>{
'key': _testKey,
'amount': (amount * 100).toInt(), // paise
'currency': 'INR',
'name': 'Eventify',
'description': 'Ticket: $eventName',
'prefill': {
'email': email,
'contact': phone,
},
'theme': {'color': '#0B63D6'},
};
if (orderId != null) options['order_id'] = orderId;
if (kDebugMode) debugPrint('PaymentService: opening Razorpay with amount=${amount * 100} paise');
_razorpay.open(options);
}
void _handleSuccess(PaymentSuccessResponse res) => onSuccess?.call(res);
void _handleError(PaymentFailureResponse res) => onError?.call(res);
void _handleExternalWallet(ExternalWalletResponse res) => onExternalWallet?.call(res);
void dispose() {
_razorpay.clear();
}
}

View File

@@ -1,4 +1,6 @@
// lib/features/events/models/event_models.dart
import '../../../core/api/api_endpoints.dart';
class EventTypeModel {
final int id;
final String name;
@@ -6,11 +8,18 @@ class EventTypeModel {
EventTypeModel({required this.id, required this.name, this.iconUrl});
/// Resolve a relative media path (e.g. `/media/...`) to a full URL.
static String? _resolveMediaUrl(String? raw) {
if (raw == null || raw.isEmpty) return null;
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
return '${ApiEndpoints.mediaBaseUrl}$raw';
}
factory EventTypeModel.fromJson(Map<String, dynamic> j) {
return EventTypeModel(
id: j['id'] as int,
name: (j['event_type'] ?? j['name'] ?? '') as String,
iconUrl: (j['event_type_icon'] ?? j['icon_url']) as String?,
iconUrl: _resolveMediaUrl((j['event_type_icon'] ?? j['icon_url']) as String?),
);
}
}
@@ -24,7 +33,7 @@ class EventImageModel {
factory EventImageModel.fromJson(Map<String, dynamic> j) {
return EventImageModel(
isPrimary: j['is_primary'] == true,
image: (j['image'] ?? '') as String,
image: EventTypeModel._resolveMediaUrl(j['image'] as String?) ?? '',
);
}
}
@@ -59,6 +68,19 @@ class EventModel {
// Structured important info list [{title, value}, ...]
final List<Map<String, String>> importantInfo;
// Review stats (populated when backend includes them)
final double? averageRating;
final int? reviewCount;
// Contributor fields (EVT-001)
final String? contributorId;
final String? contributorName;
final String? contributorTier;
// Curation flags
final bool isFeatured;
final bool isTopEvent;
EventModel({
required this.id,
required this.name,
@@ -82,6 +104,13 @@ class EventModel {
this.longitude,
this.locationName,
this.importantInfo = const [],
this.averageRating,
this.reviewCount,
this.contributorId,
this.contributorName,
this.contributorTier,
this.isFeatured = false,
this.isTopEvent = false,
});
/// Safely parse a double from backend (may arrive as String or num)
@@ -129,7 +158,7 @@ class EventModel {
place: (j['place'] ?? j['venue_name']) as String?,
isBookable: j['is_bookable'] == null ? true : (j['is_bookable'] == true || j['is_bookable'].toString().toLowerCase() == 'true'),
eventTypeId: j['event_type'] is int ? j['event_type'] as int : (j['event_type'] != null ? int.tryParse(j['event_type'].toString()) : null),
thumbImg: j['thumb_img'] as String?,
thumbImg: EventTypeModel._resolveMediaUrl(j['thumb_img'] as String?),
images: imgs,
importantInformation: j['important_information'] as String?,
venueName: j['venue_name'] as String?,
@@ -139,6 +168,13 @@ class EventModel {
longitude: _parseDouble(j['longitude']),
locationName: j['location_name'] as String?,
importantInfo: _parseImportantInfo(j['important_info']),
averageRating: (j['average_rating'] as num?)?.toDouble(),
reviewCount: (j['review_count'] as num?)?.toInt(),
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

@@ -1,5 +1,4 @@
// lib/features/events/services/events_service.dart
import 'package:intl/intl.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../models/event_models.dart';
@@ -7,27 +6,119 @@ import '../models/event_models.dart';
class EventsService {
final ApiClient _api = ApiClient();
// ---------------------------------------------------------------------------
// In-memory caches with TTL
// ---------------------------------------------------------------------------
static List<EventTypeModel>? _cachedTypes;
static DateTime? _typesCacheTime;
static const _typesCacheTTL = Duration(minutes: 30);
static List<EventModel>? _cachedAllEvents;
static DateTime? _eventsCacheTime;
static const _eventsCacheTTL = Duration(minutes: 5);
/// Get event types (POST to /events/type-list/)
/// Cached for 30 minutes since event types rarely change.
Future<List<EventTypeModel>> getEventTypes() async {
if (_cachedTypes != null &&
_typesCacheTime != null &&
DateTime.now().difference(_typesCacheTime!) < _typesCacheTTL) {
return _cachedTypes!;
}
final res = await _api.post(ApiEndpoints.eventTypes, requiresAuth: false);
final list = <EventTypeModel>[];
final data = res['event_types'] ?? res['event_types'] ?? res;
final data = res['event_types'] ?? res;
if (data is List) {
for (final e in data) {
if (e is Map<String, dynamic>) list.add(EventTypeModel.fromJson(e));
}
} else if (res['event_types'] is List) {
for (final e in res['event_types']) {
list.add(EventTypeModel.fromJson(Map<String, dynamic>.from(e)));
}
_cachedTypes = list;
_typesCacheTime = DateTime.now();
return list;
}
/// Get events filtered by pincode with pagination.
/// [page] starts at 1. [pageSize] defaults to 50.
/// Returns a list of events for the requested page.
Future<List<EventModel>> getEventsByPincode(String pincode, {int page = 1, int pageSize = 50, int perType = 5, String q = ''}) async {
// Use cache for 'all' pincode queries (first page only, no active search)
if (pincode == 'all' &&
page == 1 &&
q.isEmpty &&
_cachedAllEvents != null &&
_eventsCacheTime != null &&
DateTime.now().difference(_eventsCacheTime!) < _eventsCacheTTL) {
return _cachedAllEvents!;
}
final Map<String, dynamic> body = {'pincode': pincode, 'page': page, 'page_size': pageSize};
// Diverse mode: fetch a few events per type so all categories are represented
if (perType > 0 && page == 1) body['per_type'] = perType;
// Server-side search filter
if (q.isNotEmpty) body['q'] = q;
final res = await _api.post(
ApiEndpoints.eventsByPincode,
body: body,
requiresAuth: false,
);
final list = <EventModel>[];
final events = res['events'] ?? res['data'] ?? [];
if (events is List) {
for (final e in events) {
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
}
}
if (pincode == 'all' && page == 1) {
_cachedAllEvents = list;
_eventsCacheTime = DateTime.now();
}
return list;
}
/// Get events filtered by pincode (POST to /events/pincode-events/)
/// Use pincode='all' to fetch all events.
Future<List<EventModel>> getEventsByPincode(String pincode) async {
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode}, requiresAuth: false);
/// Event details — requiresAuth: false so guests can fetch full details
Future<EventModel> getEventDetails(int eventId) async {
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
return EventModel.fromJson(Map<String, dynamic>.from(res));
}
/// Related events by event_type_id (EVT-002).
/// Fetches events with the same category, silently returns [] on failure.
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
try {
final res = await _api.post(
ApiEndpoints.eventsByCategory,
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
requiresAuth: false,
);
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
if (results is List) {
return results
.whereType<Map<String, dynamic>>()
.map((e) => EventModel.fromJson(e))
.toList();
}
} catch (_) {
// silently fail — related events are non-critical
}
return [];
}
/// Get events by GPS coordinates using haversine distance filtering.
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
final body = {
'latitude': lat,
'longitude': lng,
'radius_km': radiusKm,
'page': 1,
'page_size': 50,
'per_type': 5,
};
final res = await _api.post(ApiEndpoints.eventsByPincode, body: body, requiresAuth: false);
final list = <EventModel>[];
final events = res['events'] ?? res['data'] ?? [];
if (events is List) {
@@ -38,30 +129,48 @@ class EventsService {
return list;
}
/// Event details
Future<EventModel> getEventDetails(int eventId) async {
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
return EventModel.fromJson(Map<String, dynamic>.from(res));
/// 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/)
/// Accepts month string and year int.
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
// expected keys: dates, total_number_of_events, date_events
return res;
}
/// Convenience: get events for a specific date (YYYY-MM-DD)
/// Convenience: get events for a specific date (YYYY-MM-DD).
/// Uses the cached events list when available to avoid redundant API calls.
Future<List<EventModel>> getEventsForDate(String date) async {
// Simplest approach: hit pincode-events with filter or hit events-by-month-year and then
// query event-details for events of that date. Assuming backend doesn't provide direct endpoint,
// we'll call eventsByPincode('all') and filter locally by date — acceptable for demo/small datasets.
final all = await getEventsByPincode('all');
return all.where((e) {
try {
return e.startDate == date || e.endDate == date || (DateTime.parse(e.startDate).isBefore(DateTime.parse(date)) && DateTime.parse(e.endDate).isAfter(DateTime.parse(date)));
return e.startDate == date ||
e.endDate == date ||
(DateTime.parse(e.startDate).isBefore(DateTime.parse(date)) &&
DateTime.parse(e.endDate).isAfter(DateTime.parse(date)));
} catch (_) {
return false;
}

View File

@@ -1,6 +1,8 @@
// lib/features/gamification/models/gamification_models.dart
// Data models matching TechDocs v2 DB schema for the Contributor Module.
import 'package:flutter/foundation.dart';
// ---------------------------------------------------------------------------
// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
// ---------------------------------------------------------------------------
@@ -68,13 +70,21 @@ int tierStartEp(ContributorTier tier) {
// ---------------------------------------------------------------------------
class UserGamificationProfile {
final String userId;
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
final int currentEp; // Liquid EP accumulated this month.
final int currentRp; // Spendable Reward Points.
final String username;
final String? avatarUrl;
final String? district;
final String? eventifyId;
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
final int currentEp; // Liquid EP accumulated this month.
final int currentRp; // Spendable Reward Points.
final ContributorTier tier;
const UserGamificationProfile({
required this.userId,
required this.username,
this.avatarUrl,
this.district,
this.eventifyId,
required this.lifetimeEp,
required this.currentEp,
required this.currentRp,
@@ -82,53 +92,181 @@ class UserGamificationProfile {
});
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
final ep = (json['lifetime_ep'] as int?) ?? 0;
debugPrint('Mapping UserGamificationProfile from JSON: $json');
final ep = (json['lifetime_ep'] as int?) ?? (json['points'] as int?) ?? (json['total_points'] as int?) ?? 0;
return UserGamificationProfile(
userId: json['user_id'] as String? ?? '',
userId: (json['user_id'] ?? json['email'] ?? json['userId'] ?? '').toString(),
username: (json['username'] ?? json['name'] ?? json['full_name'] ?? json['display_name'] ?? '').toString(),
avatarUrl: json['profile_image'] as String? ?? json['avatar_url'] as String? ?? json['profile_pic'] as String?,
district: json['district'] as String? ?? json['location'] as String?,
eventifyId: (json['eventify_id'] ?? json['eventifyId'] ?? json['id'] ?? '').toString(),
lifetimeEp: ep,
currentEp: (json['current_ep'] as int?) ?? 0,
currentRp: (json['current_rp'] as int?) ?? 0,
currentEp: (json['current_ep'] as int?) ?? (json['monthly_points'] as int?) ?? (json['points_this_month'] as int?) ?? 0,
currentRp: (json['current_rp'] as int?) ?? (json['reward_points'] as int?) ?? (json['rp'] as int?) ?? 0,
tier: tierFromEp(ep),
);
}
}
// ---------------------------------------------------------------------------
// LeaderboardEntry
// LeaderboardEntry — maps from Node.js API response fields
// ---------------------------------------------------------------------------
class LeaderboardEntry {
final int rank;
final String username;
final String? avatarUrl;
final int lifetimeEp;
final int monthlyPoints;
final ContributorTier tier;
final int eventsCount;
final bool isCurrentUser;
final String? district;
const LeaderboardEntry({
required this.rank,
required this.username,
this.avatarUrl,
required this.lifetimeEp,
this.monthlyPoints = 0,
required this.tier,
required this.eventsCount,
this.isCurrentUser = false,
this.district,
});
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
final ep = (json['lifetime_ep'] as int?) ?? 0;
// Node.js API returns 'points' for lifetime EP and 'name' for username
final ep = (json['points'] as num?)?.toInt() ?? (json['lifetime_ep'] as num?)?.toInt() ?? 0;
final tierStr = json['level'] as String? ?? json['tier'] as String?;
return LeaderboardEntry(
rank: (json['rank'] as int?) ?? 0,
username: json['username'] as String? ?? '',
rank: (json['rank'] as num?)?.toInt() ?? 0,
username: json['name'] as String? ?? json['username'] as String? ?? '',
avatarUrl: json['avatar_url'] as String?,
lifetimeEp: ep,
tier: tierFromEp(ep),
eventsCount: (json['events_count'] as int?) ?? 0,
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
tier: tierStr != null ? _tierFromString(tierStr) : tierFromEp(ep),
eventsCount: (json['eventsAdded'] as num?)?.toInt() ?? (json['events_count'] as num?)?.toInt() ?? 0,
isCurrentUser: (json['is_current_user'] as bool?) ?? false,
district: json['district'] as String?,
);
}
}
/// Parse tier string from API (e.g. "Gold") to enum.
ContributorTier _tierFromString(String s) {
switch (s.toLowerCase()) {
case 'diamond': return ContributorTier.DIAMOND;
case 'platinum': return ContributorTier.PLATINUM;
case 'gold': return ContributorTier.GOLD;
case 'silver': return ContributorTier.SILVER;
default: return ContributorTier.BRONZE;
}
}
// ---------------------------------------------------------------------------
// CurrentUserStats — from leaderboard API's currentUser field
// ---------------------------------------------------------------------------
class CurrentUserStats {
final int rank;
final int points;
final int monthlyPoints;
final String level;
final int rewardCycleDays;
final int eventsAdded;
final String? district;
const CurrentUserStats({
required this.rank,
required this.points,
this.monthlyPoints = 0,
required this.level,
this.rewardCycleDays = 0,
this.eventsAdded = 0,
this.district,
});
factory CurrentUserStats.fromJson(Map<String, dynamic> json) {
return CurrentUserStats(
rank: (json['rank'] as num?)?.toInt() ?? 0,
points: (json['points'] as num?)?.toInt() ?? 0,
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
level: json['level'] as String? ?? 'Bronze',
rewardCycleDays: (json['rewardCycleDays'] as num?)?.toInt() ?? 0,
eventsAdded: (json['eventsAdded'] as num?)?.toInt() ?? 0,
district: json['district'] as String?,
);
}
}
// ---------------------------------------------------------------------------
// LeaderboardResponse — wraps the full leaderboard API response
// ---------------------------------------------------------------------------
class LeaderboardResponse {
final List<LeaderboardEntry> entries;
final CurrentUserStats? currentUser;
final int totalParticipants;
const LeaderboardResponse({
required this.entries,
this.currentUser,
this.totalParticipants = 0,
});
}
// ---------------------------------------------------------------------------
// SubmissionModel — event submissions from dashboard API
// ---------------------------------------------------------------------------
class SubmissionModel {
final String id;
final String eventName;
final String category;
final String status; // PENDING, APPROVED, REJECTED
final String? district;
final int epAwarded;
final DateTime createdAt;
final List<String> images;
const SubmissionModel({
required this.id,
required this.eventName,
this.category = '',
required this.status,
this.district,
this.epAwarded = 0,
required this.createdAt,
this.images = const [],
});
factory SubmissionModel.fromJson(Map<String, dynamic> json) {
final rawImages = json['images'] as List? ?? [];
return SubmissionModel(
id: (json['id'] ?? json['submission_id'] ?? '').toString(),
eventName: json['event_name'] as String? ?? '',
category: json['category'] as String? ?? '',
status: json['status'] as String? ?? 'PENDING',
district: json['district'] as String?,
epAwarded: (json['total_ep_awarded'] as num?)?.toInt() ?? (json['ep_awarded'] as num?)?.toInt() ?? 0,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
images: rawImages.map((e) => e.toString()).toList(),
);
}
}
// ---------------------------------------------------------------------------
// DashboardResponse — wraps the full dashboard API response
// ---------------------------------------------------------------------------
class DashboardResponse {
final UserGamificationProfile profile;
final List<SubmissionModel> submissions;
final List<AchievementBadge> achievements;
const DashboardResponse({
required this.profile,
this.submissions = const [],
this.achievements = const [],
});
}
// ---------------------------------------------------------------------------
// ShopItem — mirrors `RedeemShopItem` table
// ---------------------------------------------------------------------------
@@ -209,4 +347,15 @@ class AchievementBadge {
required this.isUnlocked,
required this.progress,
});
factory AchievementBadge.fromJson(Map<String, dynamic> json) {
return AchievementBadge(
id: (json['id'] ?? json['badge_id'] ?? '').toString(),
title: (json['title'] ?? json['name'] ?? '').toString(),
description: (json['description'] ?? '').toString(),
iconName: (json['icon_name'] ?? json['icon'] ?? 'star').toString(),
isUnlocked: json['is_unlocked'] == true || json['unlocked'] == true,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
);
}
}

View File

@@ -1,8 +1,11 @@
// lib/features/gamification/providers/gamification_provider.dart
import 'package:flutter/foundation.dart';
import '../../../core/utils/error_utils.dart';
import '../models/gamification_models.dart';
import '../services/gamification_service.dart';
import '../../events/services/events_service.dart';
import '../../events/models/event_models.dart';
class GamificationProvider extends ChangeNotifier {
final GamificationService _service = GamificationService();
@@ -11,54 +14,148 @@ class GamificationProvider extends ChangeNotifier {
UserGamificationProfile? profile;
List<LeaderboardEntry> leaderboard = [];
List<ShopItem> shopItems = [];
List<AchievementBadge> achievements = [];
List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
List<SubmissionModel> submissions = [];
CurrentUserStats? currentUserStats;
int totalParticipants = 0;
List<String> eventCategories = [];
// Leaderboard filters — matches web version
String leaderboardDistrict = 'Overall Kerala';
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
bool isLoading = false;
bool isLeaderboardLoading = false;
String? error;
// TTL guard — prevents redundant API calls from multiple screens
DateTime? _lastLoadTime;
static const _loadTtl = Duration(minutes: 2);
// ---------------------------------------------------------------------------
// Load everything at once (called when ContributeScreen is mounted)
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
// ---------------------------------------------------------------------------
Future<void> loadAll() async {
Future<void> loadAll({bool force = false}) async {
debugPrint('GamificationProvider.loadAll(force: $force) called');
// Skip if recently loaded (within 2 minutes) unless forced or profile is null
if (!force && profile != null && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
debugPrint('GamificationProvider.loadAll skipped due to TTL');
return;
}
isLoading = true;
error = null;
notifyListeners();
try {
debugPrint('GamificationProvider: Requesting dashboard, leaderboard, etc...');
final results = await Future.wait([
_service.getProfile(),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
_service.getShopItems(),
_service.getAchievements(),
_service.getDashboard().catchError((e) {
debugPrint('Dashboard error: $e');
return const DashboardResponse(
profile: UserGamificationProfile(
userId: '',
username: '',
lifetimeEp: 0,
currentEp: 0,
currentRp: 0,
tier: ContributorTier.BRONZE,
),
);
}),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod).catchError((e) {
debugPrint('Leaderboard error: $e');
return const LeaderboardResponse(entries: []);
}),
_service.getShopItems().catchError((e) {
debugPrint('Shop error: $e');
return <ShopItem>[];
}),
_service.getAchievements().catchError((e) {
debugPrint('Achievements error: $e');
return <AchievementBadge>[];
}),
EventsService().getEventTypes().catchError((e) {
debugPrint('EventTypes error: $e');
return <EventTypeModel>[];
}),
]);
profile = results[0] as UserGamificationProfile;
leaderboard = results[1] as List<LeaderboardEntry>;
shopItems = results[2] as List<ShopItem>;
achievements = results[3] as List<AchievementBadge>;
final dashboard = results[0] as DashboardResponse;
profile = dashboard.profile;
submissions = dashboard.submissions;
final lbResponse = results[1] as LeaderboardResponse;
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
currentUserStats = lbResponse.currentUser;
totalParticipants = lbResponse.totalParticipants;
shopItems = results[2] as List<ShopItem>;
// Prefer achievements from dashboard API; fall back to fetched or existing defaults
final dashAchievements = dashboard.achievements;
final fetchedAchievements = results[3] as List<AchievementBadge>;
if (dashAchievements.isNotEmpty) {
achievements = dashAchievements;
} else if (fetchedAchievements.isNotEmpty) {
achievements = fetchedAchievements;
}
final eventTypes = results[4] as List<EventTypeModel>;
if (eventTypes.isNotEmpty) {
eventCategories = eventTypes.map((e) => e.name).toList();
}
// Otherwise, keep current defaults
_lastLoadTime = DateTime.now();
} catch (e) {
error = e.toString();
error = userFriendlyError(e);
} finally {
isLoading = false;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Load leaderboard independently (decoupled from loadAll)
// ---------------------------------------------------------------------------
Future<void> loadLeaderboard() async {
isLeaderboardLoading = true;
notifyListeners();
try {
final response = await _service.getLeaderboard(
district: leaderboardDistrict,
timePeriod: leaderboardTimePeriod,
);
leaderboard = response.entries;
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Change district filter
// ---------------------------------------------------------------------------
Future<void> setDistrict(String district) async {
if (leaderboardDistrict == district) return;
leaderboardDistrict = district;
isLeaderboardLoading = true;
notifyListeners();
try {
leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = e.toString();
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
}
notifyListeners();
}
@@ -69,11 +166,17 @@ class GamificationProvider extends ChangeNotifier {
Future<void> setTimePeriod(String period) async {
if (leaderboardTimePeriod == period) return;
leaderboardTimePeriod = period;
isLeaderboardLoading = true;
notifyListeners();
try {
leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = e.toString();
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
}
notifyListeners();
}
@@ -88,6 +191,10 @@ class GamificationProvider extends ChangeNotifier {
if (profile != null) {
profile = UserGamificationProfile(
userId: profile!.userId,
username: profile!.username,
avatarUrl: profile!.avatarUrl,
district: profile!.district,
eventifyId: profile!.eventifyId,
lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp,
currentRp: profile!.currentRp - item.rpCost,
@@ -104,6 +211,10 @@ class GamificationProvider extends ChangeNotifier {
if (profile != null) {
profile = UserGamificationProfile(
userId: profile!.userId,
username: profile!.username,
avatarUrl: profile!.avatarUrl,
district: profile!.district,
eventifyId: profile!.eventifyId,
lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp,
currentRp: profile!.currentRp + item.rpCost,
@@ -121,4 +232,41 @@ class GamificationProvider extends ChangeNotifier {
Future<void> submitContribution(Map<String, dynamic> data) async {
await _service.submitContribution(data);
}
// ---------------------------------------------------------------------------
// Helper: Filter by district and re-rank results locally.
// This is a fallback in case the backend returns a global list for a district-specific query.
// ---------------------------------------------------------------------------
List<LeaderboardEntry> _filterAndReRank(List<LeaderboardEntry> entries, String district, String period) {
if (entries.isEmpty) return [];
List<LeaderboardEntry> result = entries;
if (district != 'Overall Kerala') {
// Case-insensitive filtering to be robust
result = entries.where((e) => e.district?.toLowerCase() == district.toLowerCase()).toList();
}
// Sort based on period
if (period == 'this_month') {
result.sort((a, b) => b.monthlyPoints.compareTo(a.monthlyPoints));
} else {
result.sort((a, b) => b.lifetimeEp.compareTo(a.lifetimeEp));
}
// Assign new ranks based on local sort order
return List.generate(result.length, (i) {
final e = result[i];
return LeaderboardEntry(
rank: i + 1,
username: e.username,
avatarUrl: e.avatarUrl,
lifetimeEp: e.lifetimeEp,
monthlyPoints: e.monthlyPoints,
tier: e.tier,
eventsCount: e.eventsCount,
isCurrentUser: e.isCurrentUser,
district: e.district,
);
});
}
}

View File

@@ -1,180 +1,208 @@
// lib/features/gamification/services/gamification_service.dart
//
// Stub service using the real API contract from TechDocs v2.
// All methods currently return mock data.
// TODO: replace each mock block with a real ApiClient call once
// the backend endpoints are live on uat.eventifyplus.com.
// Real API service for the Contributor / Gamification module.
// Calls the Node.js gamification server at app.eventifyplus.com.
import 'dart:math';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/storage/token_storage.dart';
import '../models/gamification_models.dart';
class GamificationService {
final ApiClient _api = ApiClient();
/// Helper: get current user's email for API calls.
Future<String> _getUserEmail() async {
final email = await TokenStorage.getUsername();
return email ?? '';
}
// ---------------------------------------------------------------------------
// User Gamification Profile
// TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
// Dashboard (profile + submissions)
// GET /v1/gamification/dashboard?user_id={email}
// ---------------------------------------------------------------------------
Future<UserGamificationProfile> getProfile() async {
await Future.delayed(const Duration(milliseconds: 400));
return const UserGamificationProfile(
userId: 'mock-user-001',
lifetimeEp: 320,
currentEp: 70,
currentRp: 45,
tier: ContributorTier.SILVER,
Future<DashboardResponse> getDashboard() async {
final email = await _getUserEmail();
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(email)}';
final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
final rawSubs = res['submissions'] as List? ?? [];
final rawAchievements = res['achievements'] as List? ?? [];
final submissions = rawSubs
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
.toList();
final achievements = rawAchievements
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
.toList();
return DashboardResponse(
profile: UserGamificationProfile.fromJson(profileJson),
submissions: submissions,
achievements: achievements,
);
}
// ---------------------------------------------------------------------------
// Public contributor profile (any user by userId / email)
// GET /v1/gamification/dashboard?user_id={userId}
// ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboardForUser(String userId) async {
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(userId)}';
final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
final rawSubs = res['submissions'] as List? ?? [];
final rawAchievements = res['achievements'] as List? ?? [];
final submissions = rawSubs
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
.toList();
final achievements = rawAchievements
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
.toList();
return DashboardResponse(
profile: UserGamificationProfile.fromJson(profileJson),
submissions: submissions,
achievements: achievements,
);
}
/// Convenience — returns just the profile (backward-compatible with provider).
Future<UserGamificationProfile> getProfile() async {
final dashboard = await getDashboard();
return dashboard.profile;
}
// ---------------------------------------------------------------------------
// Leaderboard
// district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
// timePeriod: 'all_time' | 'this_month'
// TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
// GET /v1/gamification/leaderboard?period=all|month&district=X&user_id=Y&limit=50
// ---------------------------------------------------------------------------
Future<List<LeaderboardEntry>> getLeaderboard({
Future<LeaderboardResponse> getLeaderboard({
required String district,
required String timePeriod,
}) async {
await Future.delayed(const Duration(milliseconds: 500));
final email = await _getUserEmail();
// Realistic mock names per district
final names = [
'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
'Kristin Watson', 'Guy Hawkins',
];
// Map Flutter filter values to API params
final period = timePeriod == 'this_month' ? 'month' : 'all';
final rng = Random(district.hashCode ^ timePeriod.hashCode);
final baseEp = timePeriod == 'this_month' ? 800 : 4500;
final params = <String, String>{
'period': period,
'user_id': email,
'limit': '50',
};
if (district != 'Overall Kerala') {
params['district'] = district;
}
final entries = List.generate(10, (i) {
final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
return LeaderboardEntry(
rank: i + 1,
username: names[i],
lifetimeEp: ep,
tier: tierFromEp(ep),
eventsCount: 149 - i * 12,
isCurrentUser: i == 7, // mock: current user is rank 8
final query = Uri(queryParameters: params).query;
final url = '${ApiEndpoints.leaderboard}?$query';
final res = await _api.get(url, requiresAuth: false);
final rawList = res['leaderboard'] as List? ?? [];
final entries = rawList
.map((e) => LeaderboardEntry.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
CurrentUserStats? currentUser;
if (res['currentUser'] != null && res['currentUser'] is Map) {
currentUser = CurrentUserStats.fromJson(
Map<String, dynamic>.from(res['currentUser'] as Map),
);
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Redeem Shop Items
// TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
// ---------------------------------------------------------------------------
Future<List<ShopItem>> getShopItems() async {
await Future.delayed(const Duration(milliseconds: 400));
return const [
ShopItem(
id: 'item-001',
name: 'Amazon ₹500 Voucher',
description: 'Redeem for any purchase on Amazon India.',
rpCost: 50,
stockQuantity: 20,
),
ShopItem(
id: 'item-002',
name: 'Swiggy ₹200 Voucher',
description: 'Free food delivery credit on Swiggy.',
rpCost: 20,
stockQuantity: 35,
),
ShopItem(
id: 'item-003',
name: 'Eventify Pro — 1 Month',
description: 'Premium access to Eventify.Plus features.',
rpCost: 30,
stockQuantity: 100,
),
ShopItem(
id: 'item-004',
name: 'Zomato ₹150 Voucher',
description: 'Discount on your next Zomato order.',
rpCost: 15,
stockQuantity: 50,
),
ShopItem(
id: 'item-005',
name: 'BookMyShow ₹300 Voucher',
description: 'Movie & event ticket credit on BookMyShow.',
rpCost: 30,
stockQuantity: 15,
),
ShopItem(
id: 'item-006',
name: 'Exclusive Badge',
description: 'Rare "Pioneer" badge for your profile.',
rpCost: 5,
stockQuantity: 0, // out of stock
),
];
}
// ---------------------------------------------------------------------------
// Redeem an item
// TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
// ---------------------------------------------------------------------------
Future<RedemptionRecord> redeemItem(String itemId) async {
await Future.delayed(const Duration(milliseconds: 600));
// Generate a fake voucher code
final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
return RedemptionRecord(
id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
itemId: itemId,
rpSpent: 0, // provider will look up cost
voucherCode: code,
timestamp: DateTime.now(),
return LeaderboardResponse(
entries: entries,
currentUser: currentUser,
totalParticipants: (res['totalParticipants'] as num?)?.toInt() ?? entries.length,
);
}
// ---------------------------------------------------------------------------
// Submit Contribution
// TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
// Shop Items
// GET /v1/shop/items
// ---------------------------------------------------------------------------
Future<void> submitContribution(Map<String, dynamic> data) async {
await Future.delayed(const Duration(milliseconds: 800));
// Mock always succeeds
Future<List<ShopItem>> getShopItems() async {
final res = await _api.get(ApiEndpoints.shopItems, requiresAuth: false);
final rawItems = res['items'] as List? ?? [];
return rawItems
.map((e) => ShopItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
// ---------------------------------------------------------------------------
// Achievements
// Redeem Item
// POST /v1/shop/redeem body: { user_id, item_id }
// ---------------------------------------------------------------------------
Future<RedemptionRecord> redeemItem(String itemId) async {
final email = await _getUserEmail();
final res = await _api.post(
ApiEndpoints.shopRedeem,
body: {'user_id': email, 'item_id': itemId},
requiresAuth: false,
);
final voucher = res['voucher'] as Map<String, dynamic>? ?? res;
return RedemptionRecord.fromJson(Map<String, dynamic>.from(voucher));
}
// ---------------------------------------------------------------------------
// Submit Contribution
// 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();
// 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,
requiresAuth: false,
);
}
// ---------------------------------------------------------------------------
// Achievements — sourced from dashboard API `achievements` array.
// Falls back to default badges if API doesn't return achievements yet.
// ---------------------------------------------------------------------------
Future<List<AchievementBadge>> getAchievements() async {
await Future.delayed(const Duration(milliseconds: 300));
return const [
AchievementBadge(
id: 'badge-01', title: 'First Submission',
description: 'Submitted your first event.',
iconName: 'edit', isUnlocked: true, progress: 1.0,
),
AchievementBadge(
id: 'badge-02', title: 'Silver Streak',
description: 'Reached Silver tier.',
iconName: 'star', isUnlocked: true, progress: 1.0,
),
AchievementBadge(
id: 'badge-03', title: 'Gold Rush',
description: 'Reach Gold tier (500 EP).',
iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
),
AchievementBadge(
id: 'badge-04', title: 'Top 10',
description: 'Appear in the district leaderboard top 10.',
iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
),
AchievementBadge(
id: 'badge-05', title: 'Image Pro',
description: 'Submit 10 events with 3+ images.',
iconName: 'photo_library', isUnlocked: false, progress: 0.3,
),
AchievementBadge(
id: 'badge-06', title: 'Pioneer',
description: 'One of the first 100 contributors.',
iconName: 'verified', isUnlocked: true, progress: 1.0,
),
];
try {
final dashboard = await getDashboard();
if (dashboard.achievements.isNotEmpty) return dashboard.achievements;
} catch (_) {
// Fall through to defaults
}
return defaultBadges;
}
static const defaultBadges = [
AchievementBadge(id: 'badge-01', title: 'Newcomer', description: 'First Event Posted', iconName: 'star', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-02', title: 'Contributor', description: '10th Event Posted within a month', iconName: 'crown', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-03', title: 'On Fire!', description: '3 Day Streak of logging in', iconName: 'fire', isUnlocked: false, progress: 0.67),
AchievementBadge(id: 'badge-04', title: 'Verified', description: 'Identity Verified successfully', iconName: 'verified', isUnlocked: true, progress: 1.0),
AchievementBadge(id: 'badge-05', title: 'Quality', description: '5 Star Event Rating received', iconName: 'star', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-06', title: 'Community', description: 'Referred 5 Friends to the platform', iconName: 'community', isUnlocked: false, progress: 0.4),
AchievementBadge(id: 'badge-07', title: 'Expert', description: 'Level 10 Reached in 3 months', iconName: 'expert', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-08', title: 'Precision', description: '100% Data Accuracy on all events', iconName: 'precision', isUnlocked: false, progress: 0.0),
];
}

View File

@@ -0,0 +1,33 @@
// lib/features/notifications/models/notification_model.dart
class NotificationModel {
final int id;
final String title;
final String message;
final String type; // event, promo, system, booking
bool isRead;
final DateTime createdAt;
final String? actionUrl;
NotificationModel({
required this.id,
required this.title,
required this.message,
this.type = 'system',
this.isRead = false,
required this.createdAt,
this.actionUrl,
});
factory NotificationModel.fromJson(Map<String, dynamic> json) {
return NotificationModel(
id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String? ?? '',
message: json['message'] as String? ?? '',
type: json['notification_type'] as String? ?? json['type'] as String? ?? 'system',
isRead: (json['is_read'] as bool?) ?? false,
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
actionUrl: json['action_url'] as String?,
);
}
}

View File

@@ -0,0 +1,72 @@
// lib/features/notifications/providers/notification_provider.dart
import 'package:flutter/foundation.dart';
import '../../../core/utils/error_utils.dart';
import '../models/notification_model.dart';
import '../services/notification_service.dart';
class NotificationProvider extends ChangeNotifier {
final NotificationService _service = NotificationService();
List<NotificationModel> notifications = [];
int unreadCount = 0;
bool loading = false;
String? error;
/// Load full notification list.
Future<void> loadNotifications() async {
loading = true;
error = null;
notifyListeners();
try {
notifications = await _service.getNotifications();
unreadCount = notifications.where((n) => !n.isRead).length;
} catch (e) {
error = userFriendlyError(e);
} finally {
loading = false;
notifyListeners();
}
}
/// Lightweight count refresh (no full list fetch).
Future<void> refreshUnreadCount() async {
try {
unreadCount = await _service.getUnreadCount();
notifyListeners();
} catch (_) {
// Silently fail — badge just won't update
}
}
/// Mark single notification as read.
Future<void> markAsRead(int id) async {
try {
await _service.markAsRead(notificationId: id);
final idx = notifications.indexWhere((n) => n.id == id);
if (idx >= 0) {
notifications[idx].isRead = true;
unreadCount = notifications.where((n) => !n.isRead).length;
notifyListeners();
}
} catch (e) {
error = userFriendlyError(e);
notifyListeners();
}
}
/// Mark all as read.
Future<void> markAllAsRead() async {
try {
await _service.markAsRead(); // null = mark all
for (final n in notifications) {
n.isRead = true;
}
unreadCount = 0;
notifyListeners();
} catch (e) {
error = userFriendlyError(e);
notifyListeners();
}
}
}

View File

@@ -0,0 +1,41 @@
// lib/features/notifications/services/notification_service.dart
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../models/notification_model.dart';
class NotificationService {
final ApiClient _api = ApiClient();
/// Fetch notifications for current user (paginated).
Future<List<NotificationModel>> getNotifications({int page = 1, int pageSize = 20}) async {
final res = await _api.post(
ApiEndpoints.notificationList,
body: {'page': page, 'page_size': pageSize},
);
final rawList = res['notifications'] ?? res['data'] ?? [];
if (rawList is List) {
return rawList
.map((e) => NotificationModel.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
return [];
}
/// Mark a single notification as read, or all if [notificationId] is null.
Future<void> markAsRead({int? notificationId}) async {
final body = <String, dynamic>{};
if (notificationId != null) {
body['notification_id'] = notificationId;
} else {
body['mark_all'] = true;
}
await _api.post(ApiEndpoints.notificationMarkRead, body: body);
}
/// Get unread notification count (lightweight).
Future<int> getUnreadCount() async {
final res = await _api.post(ApiEndpoints.notificationCount);
return (res['unread_count'] as num?)?.toInt() ?? 0;
}
}

View File

@@ -0,0 +1,61 @@
// lib/features/notifications/widgets/notification_bell.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/notification_provider.dart';
import 'notification_panel.dart';
class NotificationBell extends StatelessWidget {
const NotificationBell({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<NotificationProvider>(
builder: (context, provider, _) {
return GestureDetector(
onTap: () => _showPanel(context),
child: Stack(
clipBehavior: Clip.none,
children: [
const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(Icons.notifications_outlined, size: 26, color: Colors.black87),
),
if (provider.unreadCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
provider.unreadCount > 99 ? '99+' : '${provider.unreadCount}',
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
),
),
],
),
);
},
);
}
void _showPanel(BuildContext context) {
context.read<NotificationProvider>().loadNotifications();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => ChangeNotifierProvider.value(
value: context.read<NotificationProvider>(),
child: const NotificationPanel(),
),
);
}
}

View File

@@ -0,0 +1,101 @@
// lib/features/notifications/widgets/notification_panel.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/notification_provider.dart';
import 'notification_tile.dart';
class NotificationPanel extends StatelessWidget {
const NotificationPanel({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
minChildSize: 0.35,
maxChildSize: 0.95,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
children: [
// Handle bar
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 48,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(3),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Notifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
Consumer<NotificationProvider>(
builder: (_, provider, __) {
if (provider.unreadCount == 0) return const SizedBox.shrink();
return TextButton(
onPressed: provider.markAllAsRead,
child: const Text('Mark all read', style: TextStyle(fontSize: 13)),
);
},
),
],
),
),
const Divider(height: 1),
// List
Expanded(
child: Consumer<NotificationProvider>(
builder: (_, provider, __) {
if (provider.loading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.notifications.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_none, size: 56, color: Colors.grey.shade300),
const SizedBox(height: 12),
Text('No notifications yet', style: TextStyle(color: Colors.grey.shade500, fontSize: 15)),
],
),
);
}
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: provider.notifications.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
itemBuilder: (ctx, idx) {
final notif = provider.notifications[idx];
return NotificationTile(
notification: notif,
onTap: () {
if (!notif.isRead) provider.markAsRead(notif.id);
},
);
},
);
},
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,94 @@
// lib/features/notifications/widgets/notification_tile.dart
import 'package:flutter/material.dart';
import '../models/notification_model.dart';
class NotificationTile extends StatelessWidget {
final NotificationModel notification;
final VoidCallback? onTap;
const NotificationTile({Key? key, required this.notification, this.onTap}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
color: notification.isRead ? Colors.transparent : const Color(0xFFF0F4FF),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildIcon(),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.title,
style: TextStyle(
fontWeight: notification.isRead ? FontWeight.w400 : FontWeight.w600,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
notification.message,
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
_timeAgo(notification.createdAt),
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
),
],
),
),
],
),
),
);
}
Widget _buildIcon() {
final config = _typeConfig(notification.type);
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: config.color.withOpacity(0.15),
shape: BoxShape.circle,
),
child: Icon(config.icon, color: config.color, size: 20),
);
}
static _TypeConfig _typeConfig(String type) {
switch (type) {
case 'event': return _TypeConfig(Colors.blue, Icons.event);
case 'promo': return _TypeConfig(Colors.green, Icons.local_offer);
case 'booking': return _TypeConfig(Colors.orange, Icons.confirmation_number);
default: return _TypeConfig(Colors.grey, Icons.info_outline);
}
}
static String _timeAgo(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
return '${dt.day}/${dt.month}/${dt.year}';
}
}
class _TypeConfig {
final Color color;
final IconData icon;
const _TypeConfig(this.color, this.icon);
}

View File

@@ -0,0 +1,113 @@
// lib/features/reviews/models/review_models.dart
class ReviewModel {
final int id;
final int eventId;
final String username;
final int rating;
final String? comment;
final String status;
final DateTime createdAt;
final DateTime updatedAt;
final bool isVerified;
final int helpfulCount;
final int flagCount;
final bool userMarkedHelpful;
final bool userFlagged;
ReviewModel({
required this.id,
required this.eventId,
required this.username,
required this.rating,
this.comment,
this.status = 'PUBLISHED',
required this.createdAt,
required this.updatedAt,
this.isVerified = false,
this.helpfulCount = 0,
this.flagCount = 0,
this.userMarkedHelpful = false,
this.userFlagged = false,
});
factory ReviewModel.fromJson(Map<String, dynamic> j, {Map<String, bool>? interactions}) {
return ReviewModel(
id: j['id'] as int,
eventId: j['event_id'] as int,
username: (j['username'] ?? j['display_name'] ?? 'Anonymous') as String,
rating: j['rating'] as int,
comment: j['comment'] as String?,
status: (j['status'] ?? 'PUBLISHED') as String,
createdAt: DateTime.tryParse(j['created_at'] ?? '') ?? DateTime.now(),
updatedAt: DateTime.tryParse(j['updated_at'] ?? '') ?? DateTime.now(),
isVerified: j['is_verified'] == true,
helpfulCount: (j['helpful_count'] ?? 0) as int,
flagCount: (j['flag_count'] ?? 0) as int,
userMarkedHelpful: interactions?['helpful'] ?? false,
userFlagged: interactions?['flag'] ?? false,
);
}
ReviewModel copyWith({int? helpfulCount, bool? userMarkedHelpful, bool? userFlagged}) {
return ReviewModel(
id: id, eventId: eventId, username: username, rating: rating,
comment: comment, status: status, createdAt: createdAt, updatedAt: updatedAt,
isVerified: isVerified,
helpfulCount: helpfulCount ?? this.helpfulCount,
flagCount: flagCount,
userMarkedHelpful: userMarkedHelpful ?? this.userMarkedHelpful,
userFlagged: userFlagged ?? this.userFlagged,
);
}
}
class ReviewStatsModel {
final double averageRating;
final int reviewCount;
final Map<int, int> distribution;
ReviewStatsModel({
required this.averageRating,
required this.reviewCount,
required this.distribution,
});
factory ReviewStatsModel.fromJson(Map<String, dynamic> j) {
final dist = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
final rawDist = j['distribution'];
if (rawDist is Map) {
rawDist.forEach((k, v) {
final key = int.tryParse(k.toString());
if (key != null && key >= 1 && key <= 5) dist[key] = (v as num).toInt();
});
} else if (rawDist is List) {
for (int i = 0; i < rawDist.length && i < 5; i++) {
dist[i + 1] = (rawDist[i] as num).toInt();
}
}
return ReviewStatsModel(
averageRating: (j['average_rating'] as num?)?.toDouble() ?? 0.0,
reviewCount: (j['review_count'] as num?)?.toInt() ?? 0,
distribution: dist,
);
}
}
class ReviewListResponse {
final List<ReviewModel> reviews;
final ReviewStatsModel stats;
final ReviewModel? userReview;
final int total;
final int page;
final int pageSize;
ReviewListResponse({
required this.reviews,
required this.stats,
this.userReview,
required this.total,
required this.page,
required this.pageSize,
});
}

View File

@@ -0,0 +1,104 @@
// lib/features/reviews/services/review_service.dart
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../models/review_models.dart';
class ReviewService {
final ApiClient _api = ApiClient();
/// Fetch paginated reviews + stats for an event.
Future<ReviewListResponse> getReviews(int eventId, {int page = 1, int pageSize = 10}) async {
try {
final res = await _api.post(
ApiEndpoints.reviewList,
body: {'event_id': eventId, 'page': page, 'page_size': pageSize},
requiresAuth: true,
);
// Parse interactions map: { "review_id": { "helpful": bool, "flag": bool } }
final rawInteractions = res['interactions'] as Map<String, dynamic>? ?? {};
final interactionsMap = <int, Map<String, bool>>{};
rawInteractions.forEach((key, value) {
final id = int.tryParse(key);
if (id != null && value is Map) {
interactionsMap[id] = {
'helpful': value['helpful'] == true,
'flag': value['flag'] == true,
};
}
});
// Parse reviews
final rawReviews = res['reviews'] as List? ?? [];
final reviews = rawReviews.map((r) {
final review = Map<String, dynamic>.from(r as Map);
return ReviewModel.fromJson(review, interactions: interactionsMap[review['id']]);
}).toList();
// Parse stats
final stats = ReviewStatsModel.fromJson(res);
// Parse user's own review
ReviewModel? userReview;
if (res['user_review'] != null && res['user_review'] is Map) {
final ur = Map<String, dynamic>.from(res['user_review'] as Map);
userReview = ReviewModel.fromJson(ur, interactions: interactionsMap[ur['id']]);
}
return ReviewListResponse(
reviews: reviews,
stats: stats,
userReview: userReview,
total: (res['total'] as num?)?.toInt() ?? reviews.length,
page: (res['page'] as num?)?.toInt() ?? page,
pageSize: (res['page_size'] as num?)?.toInt() ?? pageSize,
);
} catch (_) {
rethrow;
}
}
/// Submit or update a review.
Future<void> submitReview(int eventId, int rating, String? comment) async {
try {
await _api.post(
ApiEndpoints.reviewSubmit,
body: {
'event_id': eventId,
'rating': rating,
if (comment != null && comment.trim().isNotEmpty) 'comment': comment.trim(),
},
requiresAuth: true,
);
} catch (_) {
rethrow;
}
}
/// Toggle helpful vote on a review. Returns new helpful count.
Future<int> markHelpful(int reviewId) async {
try {
final res = await _api.post(
ApiEndpoints.reviewHelpful,
body: {'review_id': reviewId},
requiresAuth: true,
);
return (res['helpful_count'] as num?)?.toInt() ?? 0;
} catch (_) {
rethrow;
}
}
/// Flag a review for moderation.
Future<void> flagReview(int reviewId) async {
try {
await _api.post(
ApiEndpoints.reviewFlag,
body: {'review_id': reviewId},
requiresAuth: true,
);
} catch (_) {
rethrow;
}
}
}

View File

@@ -0,0 +1,241 @@
// lib/features/reviews/widgets/review_card.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/review_models.dart';
import 'star_display.dart';
class ReviewCard extends StatefulWidget {
final ReviewModel review;
final String? currentUsername;
final Future<int> Function(int reviewId) onHelpful;
final Future<void> Function(int reviewId) onFlag;
const ReviewCard({
Key? key,
required this.review,
this.currentUsername,
required this.onHelpful,
required this.onFlag,
}) : super(key: key);
@override
State<ReviewCard> createState() => _ReviewCardState();
}
class _ReviewCardState extends State<ReviewCard> {
late ReviewModel _review;
bool _expanded = false;
@override
void initState() {
super.initState();
_review = widget.review;
}
@override
void didUpdateWidget(covariant ReviewCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.review.id != widget.review.id) _review = widget.review;
}
bool get _isOwnReview =>
widget.currentUsername != null &&
widget.currentUsername!.isNotEmpty &&
_review.username == widget.currentUsername;
String _timeAgo(DateTime dt) {
final diff = DateTime.now().difference(dt);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
if (diff.inDays < 30) return '${diff.inDays}d ago';
if (diff.inDays < 365) return '${(diff.inDays / 30).floor()}mo ago';
return '${(diff.inDays / 365).floor()}y ago';
}
Color _avatarColor(String name) {
final colors = [
const Color(0xFF0F45CF), const Color(0xFF7C3AED), const Color(0xFFEC4899),
const Color(0xFFF59E0B), const Color(0xFF10B981), const Color(0xFFEF4444),
const Color(0xFF06B6D4), const Color(0xFF8B5CF6),
];
return colors[name.hashCode.abs() % colors.length];
}
Future<void> _handleHelpful() async {
if (_isOwnReview) return;
try {
final newCount = await widget.onHelpful(_review.id);
if (mounted) {
setState(() {
_review = _review.copyWith(
helpfulCount: newCount,
userMarkedHelpful: !_review.userMarkedHelpful,
);
});
}
} catch (_) {}
}
Future<void> _handleFlag() async {
if (_isOwnReview || _review.userFlagged) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Report Review'),
content: const Text('Are you sure you want to report this review as inappropriate?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Report', style: TextStyle(color: Color(0xFFEF4444))),
),
],
),
);
if (confirmed != true) return;
try {
await widget.onFlag(_review.id);
if (mounted) setState(() => _review = _review.copyWith(userFlagged: true));
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final comment = _review.comment ?? '';
final isLong = comment.length > 150;
final displayComment = isLong && !_expanded ? '${comment.substring(0, 150)}...' : comment;
final initial = _review.username.isNotEmpty ? _review.username[0].toUpperCase() : '?';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF1F5F9)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row
Row(
children: [
ClipOval(
child: CachedNetworkImage(
imageUrl: 'https://api.dicebear.com/9.x/notionists/svg?seed=${Uri.encodeComponent(_review.username)}',
width: 36,
height: 36,
memCacheWidth: 72,
memCacheHeight: 72,
maxWidthDiskCache: 144,
maxHeightDiskCache: 144,
placeholder: (_, __) => CircleAvatar(
radius: 18,
backgroundColor: _avatarColor(_review.username),
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
),
errorWidget: (_, __, ___) => CircleAvatar(
radius: 18,
backgroundColor: _avatarColor(_review.username),
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
_review.username.split('@').first,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF1E293B)),
overflow: TextOverflow.ellipsis,
),
),
if (_review.isVerified) ...[
const SizedBox(width: 4),
const Icon(Icons.verified, size: 14, color: Color(0xFF22C55E)),
],
],
),
const SizedBox(height: 2),
Text(_timeAgo(_review.createdAt), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
],
),
),
StarDisplay(rating: _review.rating.toDouble(), size: 14),
],
),
// Comment
if (comment.isNotEmpty) ...[
const SizedBox(height: 10),
Text(displayComment, style: const TextStyle(fontSize: 13, color: Color(0xFF334155), height: 1.4)),
if (isLong)
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_expanded ? 'Show less' : 'Read more',
style: const TextStyle(fontSize: 12, color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
),
),
),
],
// Footer actions
const SizedBox(height: 10),
Row(
children: [
// Helpful button
InkWell(
onTap: _isOwnReview ? null : _handleHelpful,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_review.userMarkedHelpful ? Icons.thumb_up : Icons.thumb_up_outlined,
size: 15,
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
),
if (_review.helpfulCount > 0) ...[
const SizedBox(width: 4),
Text(
'${_review.helpfulCount}',
style: TextStyle(
fontSize: 12,
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
),
),
],
],
),
),
),
const SizedBox(width: 8),
// Flag button
if (!_isOwnReview)
InkWell(
onTap: _review.userFlagged ? null : _handleFlag,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Icon(
_review.userFlagged ? Icons.flag : Icons.flag_outlined,
size: 15,
color: _review.userFlagged ? const Color(0xFFEF4444) : const Color(0xFF94A3B8),
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,190 @@
// lib/features/reviews/widgets/review_form.dart
import 'package:flutter/material.dart';
import '../../../core/storage/token_storage.dart';
import '../../../core/utils/error_utils.dart';
import '../models/review_models.dart';
import 'star_rating_input.dart';
class ReviewForm extends StatefulWidget {
final int eventId;
final ReviewModel? existingReview;
final Future<void> Function(int rating, String? comment) onSubmit;
const ReviewForm({
Key? key,
required this.eventId,
this.existingReview,
required this.onSubmit,
}) : super(key: key);
@override
State<ReviewForm> createState() => _ReviewFormState();
}
enum _FormState { idle, loading, success }
class _ReviewFormState extends State<ReviewForm> with SingleTickerProviderStateMixin {
int _rating = 0;
final _commentController = TextEditingController();
_FormState _state = _FormState.idle;
bool _isLoggedIn = false;
String? _error;
late final AnimationController _checkController;
late final Animation<double> _checkScale;
@override
void initState() {
super.initState();
_checkAuth();
if (widget.existingReview != null) {
_rating = widget.existingReview!.rating;
_commentController.text = widget.existingReview!.comment ?? '';
}
_checkController = AnimationController(vsync: this, duration: const Duration(milliseconds: 600));
_checkScale = CurvedAnimation(parent: _checkController, curve: Curves.elasticOut);
}
Future<void> _checkAuth() async {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (mounted) setState(() => _isLoggedIn = token != null && username != null);
}
Future<void> _handleSubmit() async {
if (_rating == 0) {
setState(() => _error = 'Please select a rating');
return;
}
setState(() { _state = _FormState.loading; _error = null; });
try {
await widget.onSubmit(_rating, _commentController.text);
if (mounted) {
setState(() => _state = _FormState.success);
_checkController.forward(from: 0);
Future.delayed(const Duration(seconds: 3), () {
if (mounted) setState(() => _state = _FormState.idle);
});
}
} catch (e) {
if (mounted) setState(() { _state = _FormState.idle; _error = userFriendlyError(e); });
}
}
@override
void dispose() {
_commentController.dispose();
_checkController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_isLoggedIn) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
const Icon(Icons.login, color: Color(0xFF64748B), size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text('Log in to write a review', style: TextStyle(color: Color(0xFF64748B), fontSize: 14)),
),
],
),
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _state == _FormState.success
? Container(
key: const ValueKey('success'),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: const Color(0xFFF0FDF4),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF86EFAC)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ScaleTransition(
scale: _checkScale,
child: const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 24),
),
const SizedBox(width: 8),
const Text('Review submitted!', style: TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w600, fontSize: 15)),
],
),
)
: Container(
key: const ValueKey('form'),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.existingReview != null ? 'Update your review' : 'Write a review',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1E293B)),
),
const SizedBox(height: 12),
Center(child: StarRatingInput(rating: _rating, onRatingChanged: (r) => setState(() { _rating = r; _error = null; }))),
const SizedBox(height: 12),
TextField(
controller: _commentController,
maxLength: 500,
maxLines: 3,
decoration: InputDecoration(
hintText: 'Share your experience (optional)',
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
filled: true,
fillColor: const Color(0xFFF8FAFC),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF0F45CF), width: 1.5)),
counterStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
),
),
if (_error != null) ...[
const SizedBox(height: 4),
Text(_error!, style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12)),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 46,
child: ElevatedButton(
onPressed: _state == _FormState.loading ? null : _handleSubmit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F45CF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
disabledBackgroundColor: const Color(0xFF0F45CF).withValues(alpha: 0.5),
),
child: _state == _FormState.loading
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text(
widget.existingReview != null ? 'Update Review' : 'Submit Review',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,214 @@
// lib/features/reviews/widgets/review_section.dart
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import '../../../core/storage/token_storage.dart';
import '../../../widgets/bouncing_loader.dart';
import '../../../core/utils/error_utils.dart';
import '../models/review_models.dart';
import '../services/review_service.dart';
import '../../../core/analytics/posthog_service.dart';
import 'review_summary.dart';
import 'review_form.dart';
import 'review_card.dart';
class ReviewSection extends StatefulWidget {
final int eventId;
const ReviewSection({Key? key, required this.eventId}) : super(key: key);
@override
State<ReviewSection> createState() => _ReviewSectionState();
}
class _ReviewSectionState extends State<ReviewSection> {
final ReviewService _service = ReviewService();
List<ReviewModel> _reviews = [];
ReviewStatsModel? _stats;
ReviewModel? _userReview;
String? _currentUsername;
bool _loading = true;
String? _error;
int _page = 1;
int _total = 0;
bool _loadingMore = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
_currentUsername = await TokenStorage.getUsername();
await _loadReviews();
}
Future<void> _loadReviews() async {
setState(() { _loading = true; _error = null; });
try {
final response = await _service.getReviews(widget.eventId, page: 1);
if (mounted) {
setState(() {
_reviews = response.reviews;
_stats = response.stats;
_userReview = response.userReview;
_total = response.total;
_page = 1;
_loading = false;
});
}
} catch (e) {
if (mounted) setState(() { _loading = false; _error = userFriendlyError(e); });
}
}
Future<void> _loadMore() async {
if (_loadingMore || _reviews.length >= _total) return;
setState(() => _loadingMore = true);
try {
final response = await _service.getReviews(widget.eventId, page: _page + 1);
if (mounted) {
setState(() {
_reviews.addAll(response.reviews);
_page = response.page;
_total = response.total;
_loadingMore = false;
});
}
} catch (_) {
if (mounted) setState(() => _loadingMore = false);
}
}
Future<void> _handleSubmit(int rating, String? comment) async {
await _service.submitReview(widget.eventId, rating, comment);
PostHogService.instance.capture('review_submitted', properties: {
'event_id': widget.eventId,
'rating': rating,
});
await _loadReviews(); // Refresh to get updated stats + review list
}
Future<int> _handleHelpful(int reviewId) async {
return _service.markHelpful(reviewId);
}
Future<void> _handleFlag(int reviewId) async {
await _service.flagReview(reviewId);
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section header
const Text(
'Reviews & Ratings',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 16),
if (_loading)
const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: BouncingLoader(color: Color(0xFF0F45CF)),
),
)
else if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(_error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _loadReviews,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
),
],
),
)
else ...[
// Summary card
if (_stats != null && _stats!.reviewCount > 0) ...[
ReviewSummary(stats: _stats!),
const SizedBox(height: 16),
],
// Review form
ReviewForm(
eventId: widget.eventId,
existingReview: _userReview,
onSubmit: _handleSubmit,
),
const SizedBox(height: 16),
// Divider
if (_reviews.isNotEmpty)
const Divider(color: Color(0xFFF1F5F9), thickness: 1),
// Reviews list
if (_reviews.isEmpty && (_stats == null || _stats!.reviewCount == 0))
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text(
'No reviews yet. Be the first to share your experience!',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
textAlign: TextAlign.center,
),
),
)
else ...[
const SizedBox(height: 12),
AnimationLimiter(
child: Column(
children: AnimationConfiguration.toStaggeredList(
duration: const Duration(milliseconds: 375),
childAnimationBuilder: (widget) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(child: widget),
),
children: List.generate(_reviews.length, (i) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ReviewCard(
review: _reviews[i],
currentUsername: _currentUsername,
onHelpful: _handleHelpful,
onFlag: _handleFlag,
),
)),
),
),
),
],
// Load more
if (_reviews.length < _total)
Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(16),
child: BouncingLoader(color: Color(0xFF0F45CF)),
)
: TextButton(
onPressed: _loadMore,
child: const Text(
'Show more reviews',
style: TextStyle(color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,171 @@
// lib/features/reviews/widgets/review_summary.dart
import 'dart:math' show pi;
import 'package:flutter/material.dart';
import '../models/review_models.dart';
class _RatingRingPainter extends CustomPainter {
final double rating;
const _RatingRingPainter({required this.rating});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 6;
// Background track
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
2 * pi,
false,
Paint()
..color = Colors.white12
..style = PaintingStyle.stroke
..strokeWidth = 7
..strokeCap = StrokeCap.round,
);
// Filled arc
if (rating > 0) {
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
(rating.clamp(0.0, 5.0) / 5.0) * 2 * pi,
false,
Paint()
..color = const Color(0xFFFBBF24)
..style = PaintingStyle.stroke
..strokeWidth = 7
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(_RatingRingPainter old) => old.rating != rating;
}
class _RatingRingWidget extends StatelessWidget {
final double rating;
final int reviewCount;
const _RatingRingWidget({required this.rating, required this.reviewCount});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 84,
height: 84,
child: CustomPaint(
painter: _RatingRingPainter(rating: rating),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
rating.toStringAsFixed(1),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
const Text(
'/5',
style: TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
),
],
),
),
),
),
const SizedBox(height: 4),
Text(
'$reviewCount ${reviewCount == 1 ? 'review' : 'reviews'}',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
],
);
}
}
class ReviewSummary extends StatelessWidget {
final ReviewStatsModel stats;
const ReviewSummary({Key? key, required this.stats}) : super(key: key);
@override
Widget build(BuildContext context) {
final maxCount = stats.distribution.values.fold<int>(0, (a, b) => a > b ? a : b);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4)),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Left: circular rating ring
_RatingRingWidget(
rating: stats.averageRating,
reviewCount: stats.reviewCount,
),
const SizedBox(width: 24),
// Right: distribution bars
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final star = 5 - i;
final count = stats.distribution[star] ?? 0;
final fraction = maxCount > 0 ? count / maxCount : 0.0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
SizedBox(
width: 18,
child: Text('$star', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF64748B))),
),
const Icon(Icons.star_rounded, size: 12, color: Color(0xFFFBBF24)),
const SizedBox(width: 6),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 8,
child: LinearProgressIndicator(
value: fraction,
backgroundColor: const Color(0xFFF1F5F9),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFBBF24)),
minHeight: 8,
),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 24,
child: Text('$count', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
),
],
),
);
}),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,42 @@
// lib/features/reviews/widgets/star_display.dart
import 'package:flutter/material.dart';
class StarDisplay extends StatelessWidget {
final double rating;
final double size;
final Color filledColor;
final Color emptyColor;
const StarDisplay({
Key? key,
required this.rating,
this.size = 16,
this.filledColor = const Color(0xFFFBBF24),
this.emptyColor = const Color(0xFFD1D5DB),
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final starPos = i + 1;
IconData icon;
Color color;
if (rating >= starPos) {
icon = Icons.star_rounded;
color = filledColor;
} else if (rating >= starPos - 0.5) {
icon = Icons.star_half_rounded;
color = filledColor;
} else {
icon = Icons.star_outline_rounded;
color = emptyColor;
}
return Icon(icon, size: size, color: color);
}),
);
}
}

View File

@@ -0,0 +1,56 @@
// lib/features/reviews/widgets/star_rating_input.dart
import 'package:flutter/material.dart';
class StarRatingInput extends StatelessWidget {
final int rating;
final ValueChanged<int> onRatingChanged;
final double starSize;
const StarRatingInput({
Key? key,
required this.rating,
required this.onRatingChanged,
this.starSize = 36,
}) : super(key: key);
static const _labels = ['', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];
static const _starGold = Color(0xFFFBBF24);
static const _starEmpty = Color(0xFFD1D5DB);
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final starIndex = i + 1;
return GestureDetector(
onTap: () => onRatingChanged(starIndex),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Icon(
starIndex <= rating ? Icons.star_rounded : Icons.star_outline_rounded,
size: starSize,
color: starIndex <= rating ? _starGold : _starEmpty,
),
),
);
}),
),
if (rating > 0) ...[
const SizedBox(height: 4),
Text(
_labels[rating],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _starGold,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,547 @@
// lib/features/share/share_card_generator.dart
//
// Pure dart:ui Canvas generator — produces a 1080×1920 PNG story card
// without embedding any widget in the tree. Drop-in replacement for
// the old RepaintBoundary + ShareRankCard approach.
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// ── Tier theme data (ported from share_rank_card.dart) ─────────────────────
const _tierThemes = <String, _TierTheme>{
'Bronze': _TierTheme(
stops: [Color(0xFF92400E), Color(0xFFB45309), Color(0xFFD97706)],
ring: Color(0xFFD97706),
),
'Silver': _TierTheme(
stops: [Color(0xFF334155), Color(0xFF475569), Color(0xFF64748B)],
ring: Color(0xFF94A3B8),
),
'Gold': _TierTheme(
stops: [Color(0xFF78350F), Color(0xFF92400E), Color(0xFFB45309)],
ring: Color(0xFFF59E0B),
),
'Platinum': _TierTheme(
stops: [Color(0xFF4C1D95), Color(0xFF5B21B6), Color(0xFF7C3AED)],
ring: Color(0xFF8B5CF6),
),
'Diamond': _TierTheme(
stops: [Color(0xFF312E81), Color(0xFF4338CA), Color(0xFF6366F1)],
ring: Color(0xFF6366F1),
),
};
class _TierTheme {
final List<Color> stops;
final Color ring;
const _TierTheme({required this.stops, required this.ring});
}
// ── Public API ──────────────────────────────────────────────────────────────
/// Generates a 1080×1920 PNG share card entirely via dart:ui Canvas.
/// Returns raw PNG bytes ready for [Share.shareXFiles].
Future<Uint8List> generateShareCardPng({
required String username,
required String tier,
required int lifetimeEp,
required int currentEp,
required int rewardPoints,
String? eventifyId,
String? district,
String? imageUrl,
}) async {
const double w = 1080;
const double h = 1920;
// Resolve tier theme
final capTier = tier.isEmpty
? 'Bronze'
: (tier[0].toUpperCase() + tier.substring(1).toLowerCase());
final theme = _tierThemes[capTier] ?? _tierThemes['Bronze']!;
// Load avatar (if available)
ui.Image? avatarImage;
if (imageUrl != null && imageUrl.isNotEmpty) {
avatarImage = await _loadNetworkImage(imageUrl);
}
// ── Draw ────────────────────────────────────────────────────────────────
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, w, h));
// Layout constants (all at 3x of the original 360×640 widget)
const headerH = 894.0; // flex 45 of (1920-132)
const panelH = 894.0; // flex 45
const footerH = 132.0; // 44 * 3
const panelTop = headerH;
const footerTop = panelTop + panelH;
const cornerR = 84.0; // 28 * 3
const pad = 60.0; // 20 * 3
// 1. Gradient header background
final gradientPaint = Paint()
..shader = ui.Gradient.linear(
const Offset(w / 2, 0),
Offset(w / 2, headerH),
theme.stops,
[0.0, 0.5, 1.0],
);
canvas.drawRect(const Rect.fromLTWH(0, 0, w, headerH), gradientPaint);
// 2. White panel (rounded top corners)
final panelRRect = RRect.fromRectAndCorners(
const Rect.fromLTWH(0, panelTop, w, panelH),
topLeft: const Radius.circular(cornerR),
topRight: const Radius.circular(cornerR),
);
canvas.drawRRect(panelRRect, Paint()..color = Colors.white);
// 3. Footer
canvas.drawRect(
const Rect.fromLTWH(0, footerTop, w, footerH),
Paint()..color = const Color(0xFF0F45CF),
);
// ── Header content ────────────────────────────────────────────────────
// Avatar
const double avatarSize = 228; // 76 * 3
const double ringGap = 9; // 3 * 3
const double ringWidth = 15; // 5 * 3
const double totalSize = avatarSize + (ringGap + ringWidth) * 2;
const double avatarCenterY = 340;
const avatarCenter = Offset(w / 2, avatarCenterY);
// Draw ring
final ringPaint = Paint()
..color = theme.ring
..style = PaintingStyle.stroke
..strokeWidth = ringWidth;
canvas.drawCircle(avatarCenter, totalSize / 2 - ringWidth / 2, ringPaint);
// Draw avatar image or initials
const double avatarRadius = avatarSize / 2;
if (avatarImage != null) {
canvas.save();
final clipPath = Path()
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
canvas.clipPath(clipPath);
final src = Rect.fromLTWH(
0,
0,
avatarImage.width.toDouble(),
avatarImage.height.toDouble(),
);
final dst = Rect.fromCircle(center: avatarCenter, radius: avatarRadius);
canvas.drawImageRect(avatarImage, src, dst, Paint());
canvas.restore();
} else {
// Initials fallback
canvas.save();
final clipPath = Path()
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
canvas.clipPath(clipPath);
canvas.drawCircle(
avatarCenter,
avatarRadius,
Paint()..color = Colors.white.withValues(alpha: 0.25),
);
final initials = username.length >= 2
? username.substring(0, 2).toUpperCase()
: username.toUpperCase();
final tp = _layoutText(
initials,
fontSize: avatarSize * 0.32,
fontWeight: FontWeight.w800,
color: Colors.white,
);
tp.paint(
canvas,
Offset(
avatarCenter.dx - tp.width / 2,
avatarCenter.dy - tp.height / 2,
),
);
canvas.restore();
}
// Username (below avatar)
final displayName =
username.length > 20 ? username.substring(0, 20) : username;
final userTp = _layoutText(
displayName,
fontSize: 66, // 22 * 3
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.9,
maxWidth: w - pad * 2,
);
userTp.paint(
canvas,
Offset((w - userTp.width) / 2, avatarCenterY + totalSize / 2 + 30),
);
// Tier badge pill
final tierLabel = tier.isEmpty ? 'CONTRIBUTOR' : tier.toUpperCase();
final badgeText = '\u2605 $tierLabel EXPLORER';
final badgeTp = _layoutText(
badgeText,
fontSize: 33, // 11 * 3
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 1.5,
);
const badgePadH = 42.0; // 14 * 3
const badgePadV = 15.0; // 5 * 3
final badgeW = badgeTp.width + badgePadH * 2;
final badgeH = badgeTp.height + badgePadV * 2;
final badgeY =
avatarCenterY + totalSize / 2 + 30 + userTp.height + 18;
final badgeRRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w / 2, badgeY + badgeH / 2),
width: badgeW,
height: badgeH,
),
const Radius.circular(60),
);
canvas.drawRRect(
badgeRRect,
Paint()..color = Colors.black.withValues(alpha: 0.35),
);
badgeTp.paint(
canvas,
Offset((w - badgeTp.width) / 2, badgeY + badgePadV),
);
// ── White panel content ───────────────────────────────────────────────
double cy = panelTop + pad; // running y cursor inside panel
// Lifetime EP hero card
const heroCardH = 195.0; // approximate height for label + number + subtitle
final heroRect = RRect.fromRectAndRadius(
Rect.fromLTWH(pad, cy, w - pad * 2, heroCardH),
const Radius.circular(42), // 14 * 3
);
final heroBgPaint = Paint()
..shader = ui.Gradient.linear(
Offset(pad, cy),
Offset(w - pad, cy),
[
theme.stops.first.withValues(alpha: 0.12),
theme.stops.last.withValues(alpha: 0.06),
],
);
canvas.drawRRect(heroRect, heroBgPaint);
// "LIFETIME EP ⚡"
const heroInnerPad = 48.0; // 16 * 3
const heroInnerPadV = 42.0; // 14 * 3
final labelTp = _layoutText(
'LIFETIME EP \u26A1',
fontSize: 30, // 10 * 3
fontWeight: FontWeight.w600,
color: const Color(0xFF64748B),
letterSpacing: 1.5,
);
labelTp.paint(canvas, Offset(pad + heroInnerPad, cy + heroInnerPadV));
// Big EP number
final bigNumTp = _layoutText(
formatEp(lifetimeEp),
fontSize: 108, // 36 * 3
fontWeight: FontWeight.w900,
color: theme.stops.first,
);
bigNumTp.paint(
canvas,
Offset(pad + heroInnerPad, cy + heroInnerPadV + labelTp.height + 12),
);
// "Eventify Points earned"
final subTp = _layoutText(
'Eventify Points earned',
fontSize: 33, // 11 * 3
color: const Color(0xFF94A3B8),
);
subTp.paint(
canvas,
Offset(
pad + heroInnerPad,
cy + heroInnerPadV + labelTp.height + 12 + bigNumTp.height + 3,
),
);
cy += heroCardH + 30; // 10 * 3 gap
// ── Liquid EP + Reward Points side-by-side pills ──────────────────────
const pillGap = 24.0; // 8 * 3
final pillW = (w - pad * 2 - pillGap) / 2;
const pillH = 120.0;
// Left pill — Liquid EP
_drawStatPill(
canvas,
x: pad,
y: cy,
width: pillW,
height: pillH,
emoji: '\u26A1',
label: 'LIQUID EP',
value: formatEp(currentEp),
bgColor: const Color(0xFFEFF6FF),
textColor: const Color(0xFF1D4ED8),
);
// Right pill — Reward Points
_drawStatPill(
canvas,
x: pad + pillW + pillGap,
y: cy,
width: pillW,
height: pillH,
emoji: '\uD83C\uDFC6',
label: 'REWARD POINTS',
value: formatEp(rewardPoints),
bgColor: const Color(0xFFFFFBEB),
textColor: const Color(0xFF92400E),
);
cy += pillH + 36; // 12 * 3
// ── Dashed divider ────────────────────────────────────────────────────
final dashPaint = Paint()
..color = const Color(0xFFE2E8F0)
..strokeWidth = 3;
const dashW = 15.0;
const dashGap = 15.0;
double dx = pad;
while (dx < w - pad) {
canvas.drawLine(
Offset(dx, cy),
Offset((dx + dashW).clamp(0, w - pad), cy),
dashPaint,
);
dx += dashW + dashGap;
}
cy += 30; // 10 * 3
// ── CTA text ──────────────────────────────────────────────────────────
final ctaTp = _layoutText(
'Join me on Eventify Plus!',
fontSize: 42, // 14 * 3
fontWeight: FontWeight.w800,
color: const Color(0xFF0F172A),
);
ctaTp.paint(canvas, Offset((w - ctaTp.width) / 2, cy));
cy += ctaTp.height + 6;
final ctaSubTp = _layoutText(
'Discover events. Earn rewards.',
fontSize: 33, // 11 * 3
color: const Color(0xFF64748B),
);
ctaSubTp.paint(canvas, Offset((w - ctaSubTp.width) / 2, cy));
cy += ctaSubTp.height;
// ── Optional eventifyId pill ──────────────────────────────────────────
if (eventifyId != null && eventifyId.isNotEmpty) {
cy += 30;
final idTp = _layoutText(
eventifyId,
fontSize: 33,
fontWeight: FontWeight.w700,
color: const Color(0xFF1D4ED8),
fontFamily: 'monospace',
);
final idPillW = idTp.width + 72; // 12*3 * 2
final idPillH = idTp.height + 24; // 4*3 * 2
final idRRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(w / 2, cy + idPillH / 2),
width: idPillW,
height: idPillH,
),
const Radius.circular(36),
);
canvas.drawRRect(idRRect, Paint()..color = const Color(0xFFEFF6FF));
idTp.paint(canvas, Offset((w - idTp.width) / 2, cy + 12));
cy += idPillH;
}
// ── Optional district ─────────────────────────────────────────────────
if (district != null && district.isNotEmpty) {
cy += 18; // 6 * 3
final distTp = _layoutText(
'\uD83D\uDCCD $district',
fontSize: 33,
color: const Color(0xFF64748B),
);
distTp.paint(canvas, Offset((w - distTp.width) / 2, cy));
}
// ── Footer content ────────────────────────────────────────────────────
final boltTp = _layoutText(
'\u26A1',
fontSize: 42,
color: Colors.white,
);
final brandTp = _layoutText(
'E V E N T I F Y',
fontSize: 39, // 13 * 3
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 9,
);
final urlTp = _layoutText(
'eventifyplus.com',
fontSize: 30, // 10 * 3
fontWeight: FontWeight.w500,
color: const Color(0xFF93C5FD),
);
// Center the row: bolt + 18px + brand + 36px + url
const gap1 = 18.0;
const gap2 = 36.0;
final totalRowW =
boltTp.width + gap1 + brandTp.width + gap2 + urlTp.width;
final rowX = (w - totalRowW) / 2;
final footerCenterY = footerTop + footerH / 2;
boltTp.paint(
canvas,
Offset(rowX, footerCenterY - boltTp.height / 2),
);
brandTp.paint(
canvas,
Offset(rowX + boltTp.width + gap1, footerCenterY - brandTp.height / 2),
);
urlTp.paint(
canvas,
Offset(
rowX + boltTp.width + gap1 + brandTp.width + gap2,
footerCenterY - urlTp.height / 2,
),
);
// ── Finalize ──────────────────────────────────────────────────────────
avatarImage?.dispose();
final picture = recorder.endRecording();
final image = await picture.toImage(w.toInt(), h.toInt());
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
if (byteData == null) {
throw StateError('Failed to encode share card to PNG');
}
return byteData.buffer.asUint8List();
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/// Loads a network image as a [ui.Image] for Canvas drawing.
Future<ui.Image?> _loadNetworkImage(String url) async {
try {
final response =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final codec = await ui.instantiateImageCodec(response.bodyBytes);
final frame = await codec.getNextFrame();
return frame.image;
} catch (e) {
debugPrint('Share card avatar load failed: $e');
return null;
}
}
/// Creates and lays out a [TextPainter] for Canvas drawing.
TextPainter _layoutText(
String text, {
required double fontSize,
FontWeight fontWeight = FontWeight.w400,
Color color = Colors.black,
double letterSpacing = 0,
String fontFamily = 'Gilroy',
double maxWidth = 1080,
}) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: fontFamily,
fontSize: fontSize,
fontWeight: fontWeight,
color: color,
letterSpacing: letterSpacing,
height: 1.2,
),
),
textDirection: TextDirection.ltr,
textAlign: TextAlign.left,
)..layout(maxWidth: maxWidth);
return tp;
}
/// Draws a stat pill (e.g. Liquid EP, Reward Points).
void _drawStatPill(
Canvas canvas, {
required double x,
required double y,
required double width,
required double height,
required String emoji,
required String label,
required String value,
required Color bgColor,
required Color textColor,
}) {
const r = 36.0;
const pad = 36.0;
canvas.drawRRect(
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, width, height), const Radius.circular(r)),
Paint()..color = bgColor,
);
final labelTp = _layoutText(
'$emoji $label',
fontSize: 27, // 9 * 3
fontWeight: FontWeight.w600,
color: textColor,
letterSpacing: 0.9,
maxWidth: width - pad * 2,
);
labelTp.paint(canvas, Offset(x + pad, y + pad * 0.6));
final valTp = _layoutText(
value,
fontSize: 60, // 20 * 3
fontWeight: FontWeight.w900,
color: textColor,
maxWidth: width - pad * 2,
);
valTp.paint(canvas, Offset(x + pad, y + pad * 0.6 + labelTp.height + 12));
}
/// Formats a number with commas (e.g. 1234 → "1,234", 1234567 → "1.2M").
String formatEp(int n) {
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
if (n >= 1000) {
final s = n.toString();
final buf = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buf.write(',');
buf.write(s[i]);
}
return buf.toString();
}
return n.toString();
}

View File

@@ -2,17 +2,24 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'screens/home_desktop_screen.dart';
import 'screens/login_screen.dart';
import 'screens/desktop_login_screen.dart';
import 'screens/responsive_layout.dart'; // keep this path if your file is under lib/screens/
import 'screens/responsive_layout.dart';
import 'core/theme_manager.dart';
import 'core/analytics/posthog_service.dart';
import 'features/auth/providers/auth_provider.dart';
import 'features/gamification/providers/gamification_provider.dart';
import 'features/booking/providers/checkout_provider.dart';
import 'features/notifications/providers/notification_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await ThemeManager.init(); // load saved theme preference
await PostHogService.instance.init();
// Increase image cache for smoother scrolling and faster re-renders
PaintingBinding.instance.imageCache.maximumSize = 500;
@@ -90,18 +97,26 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) {
return MaterialApp(
title: 'Event App',
debugShowCheckedModeBanner: false,
theme: _lightTheme(),
darkTheme: _darkTheme(),
themeMode: mode,
home: const StartupScreen(),
);
},
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => AuthProvider()),
ChangeNotifierProvider(create: (_) => GamificationProvider()),
ChangeNotifierProvider(create: (_) => CheckoutProvider()),
ChangeNotifierProvider(create: (_) => NotificationProvider()),
],
child: ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) {
return MaterialApp(
title: 'Event App',
debugShowCheckedModeBanner: false,
theme: _lightTheme(),
darkTheme: _darkTheme(),
themeMode: mode,
home: const StartupScreen(),
);
},
),
);
}
}

View File

@@ -1,15 +1,19 @@
// lib/screens/booking_screen.dart
import 'package:flutter/material.dart';
import 'checkout_screen.dart';
class BookingScreen extends StatefulWidget {
// Keep onBook in the constructor if you want to use it later, but we won't call it here.
final VoidCallback? onBook;
final String image;
final int? eventId;
final String? eventName;
const BookingScreen({
Key? key,
this.onBook,
this.image = 'assets/images/event1.jpg',
this.eventId,
this.eventName,
}) : super(key: key);
@override
@@ -39,11 +43,22 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
bool _booked = false;
void _performLocalBooking() {
// mark locally booked (do NOT call widget.onBook())
// If event data is available, navigate to real checkout
if (widget.eventId != null) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CheckoutScreen(
eventId: widget.eventId!,
eventName: widget.eventName ?? 'Event',
eventImage: widget.image,
),
));
return;
}
// Fallback: demo booking for events without IDs
if (!_booked) {
setState(() => _booked = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Tickets booked (demo)')),
const SnackBar(content: Text('Tickets booked (coming soon)')),
);
}
}
@@ -205,15 +220,15 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
// action icons (scanner / chat / call)
_iconSquare(primary, Icons.qr_code_scanner, onTap: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner tapped (demo)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner (coming soon)')));
}),
SizedBox(width: 12),
_iconSquare(primary, Icons.chat, onTap: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat tapped (demo)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat (coming soon)')));
}),
SizedBox(width: 12),
_iconSquare(primary, Icons.call, onTap: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call tapped (demo)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call (coming soon)')));
}),
],
);

View File

@@ -1,5 +1,6 @@
// lib/screens/calendar_screen.dart
import 'package:flutter/material.dart';
import '../core/utils/error_utils.dart';
import 'package:intl/intl.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../features/events/services/events_service.dart';
@@ -94,7 +95,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
} finally {
if (mounted) setState(() => _loadingMonth = false);
}
@@ -117,7 +118,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
final events = await _service.getEventsForDate(yyyyMMdd);
if (mounted) setState(() => _eventsOfDay = events);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
} finally {
if (mounted) setState(() => _loadingDay = false);
}
@@ -503,7 +504,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
: (e.startDate != null && e.endDate != null ? '${e.startDate} - ${e.endDate}' : (e.startDate ?? ''));
return GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e))),
child: Card(
elevation: 6,
margin: const EdgeInsets.fromLTRB(20, 10, 20, 10),
@@ -518,6 +519,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
imageUrl: imgUrl,
memCacheWidth: 400,
memCacheHeight: 300,
maxWidthDiskCache: 800,
maxHeightDiskCache: 600,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
@@ -563,7 +566,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
: (e.startDate ?? ''));
return GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e))),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
decoration: BoxDecoration(
@@ -581,6 +584,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
imageUrl: imgUrl,
memCacheWidth: 300,
memCacheHeight: 300,
maxWidthDiskCache: 600,
maxHeightDiskCache: 600,
width: 100,
height: 100,
fit: BoxFit.cover,
@@ -837,7 +842,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
Positioned(
right: 0,
child: InkWell(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (coming soon)'))),
child: Container(
width: 40,
height: 40,

View File

@@ -0,0 +1,500 @@
// lib/screens/checkout_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../features/booking/providers/checkout_provider.dart';
import '../features/booking/services/payment_service.dart';
import '../features/booking/models/booking_models.dart';
import '../core/utils/error_utils.dart';
import 'tickets_booked_screen.dart';
class CheckoutScreen extends StatefulWidget {
final int eventId;
final String eventName;
final String? eventImage;
const CheckoutScreen({
Key? key,
required this.eventId,
required this.eventName,
this.eventImage,
}) : super(key: key);
@override
State<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends State<CheckoutScreen> {
late final PaymentService _paymentService;
final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _promoCtrl = TextEditingController();
@override
void initState() {
super.initState();
_paymentService = PaymentService();
_paymentService.initialize(
onSuccess: _onPaymentSuccess,
onError: _onPaymentError,
);
_prefillUserData();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<CheckoutProvider>().initForEvent(widget.eventId, widget.eventName);
});
}
Future<void> _prefillUserData() async {
final prefs = await SharedPreferences.getInstance();
_emailCtrl.text = prefs.getString('email') ?? '';
_nameCtrl.text = prefs.getString('display_name') ?? '';
_phoneCtrl.text = prefs.getString('phone_number') ?? '';
}
void _onPaymentSuccess(dynamic response) {
final provider = context.read<CheckoutProvider>();
provider.markPaymentSuccess(response.paymentId ?? 'success');
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const TicketsBookedScreen()),
);
}
void _onPaymentError(dynamic response) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Payment failed: ${response.message ?? "Please try again"}'),
backgroundColor: Colors.red,
),
);
}
@override
void dispose() {
_paymentService.dispose();
_nameCtrl.dispose();
_emailCtrl.dispose();
_phoneCtrl.dispose();
_promoCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Checkout', style: TextStyle(fontWeight: FontWeight.w600)),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: Consumer<CheckoutProvider>(
builder: (ctx, provider, _) {
if (provider.loading && provider.availableTickets.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null && provider.availableTickets.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(provider.error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => provider.initForEvent(widget.eventId, widget.eventName),
child: const Text('Retry'),
),
],
),
);
}
return Column(
children: [
_buildStepIndicator(provider),
Expanded(child: _buildCurrentStep(provider)),
_buildBottomBar(provider),
],
);
},
),
);
}
Widget _buildStepIndicator(CheckoutProvider provider) {
final steps = ['Tickets', 'Details', 'Payment'];
final currentIdx = provider.currentStep.index;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
color: Colors.white,
child: Row(
children: List.generate(steps.length, (i) {
final isActive = i <= currentIdx;
return Expanded(
child: Row(
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? const Color(0xFF0B63D6) : Colors.grey.shade300,
),
child: Center(
child: Text('${i + 1}', style: TextStyle(
color: isActive ? Colors.white : Colors.grey,
fontSize: 13, fontWeight: FontWeight.w600,
)),
),
),
const SizedBox(width: 6),
Text(steps[i], style: TextStyle(
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
color: isActive ? Colors.black : Colors.grey,
)),
if (i < steps.length - 1) Expanded(
child: Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 8),
color: i < currentIdx ? const Color(0xFF0B63D6) : Colors.grey.shade300,
),
),
],
),
);
}),
),
);
}
Widget _buildCurrentStep(CheckoutProvider provider) {
switch (provider.currentStep) {
case CheckoutStep.tickets:
return _buildTicketSelection(provider);
case CheckoutStep.details:
return _buildDetailsForm(provider);
case CheckoutStep.payment:
return _buildPaymentReview(provider);
case CheckoutStep.confirmation:
return const Center(child: Text('Booking confirmed!'));
}
}
Widget _buildTicketSelection(CheckoutProvider provider) {
if (provider.availableTickets.isEmpty) {
return const Center(child: Text('No tickets available for this event.'));
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(widget.eventName, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
const SizedBox(height: 20),
...provider.availableTickets.map((ticket) {
final cartMatches = provider.cart.where((c) => c.ticket.id == ticket.id);
final cartItem = cartMatches.isNotEmpty ? cartMatches.first : null;
final qty = cartItem?.quantity ?? 0;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: qty > 0 ? const Color(0xFF0B63D6) : Colors.grey.shade200),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ticket.ticketType, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 4),
Text('Rs ${ticket.price.toStringAsFixed(0)}', style: const TextStyle(color: Color(0xFF0B63D6), fontWeight: FontWeight.w700, fontSize: 18)),
if (ticket.description != null) ...[
const SizedBox(height: 4),
Text(ticket.description!, style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
],
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: qty > 0 ? () => provider.setTicketQuantity(ticket, qty - 1) : null,
),
Text('$qty', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
IconButton(
icon: const Icon(Icons.add_circle_outline, color: Color(0xFF0B63D6)),
onPressed: qty < ticket.availableQuantity
? () => provider.setTicketQuantity(ticket, qty + 1)
: null,
),
],
),
],
),
);
}),
],
);
}
Widget _buildDetailsForm(CheckoutProvider provider) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Contact Details', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 16),
_field('Full Name', _nameCtrl, validator: (v) => v!.isEmpty ? 'Required' : null),
_field('Email', _emailCtrl, type: TextInputType.emailAddress, validator: (v) => v!.isEmpty ? 'Required' : null),
_field('Phone', _phoneCtrl, type: TextInputType.phone, validator: (v) => v!.isEmpty ? 'Required' : null),
const SizedBox(height: 8),
Consumer<CheckoutProvider>(
builder: (context, provider, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextFormField(
controller: _promoCtrl,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Promo Code (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
filled: true,
fillColor: Colors.grey.shade50,
suffixIcon: provider.promoApplied
? const Icon(Icons.check_circle, color: Colors.green, size: 20)
: null,
),
enabled: !provider.promoApplied,
),
),
const SizedBox(width: 8),
SizedBox(
height: 56,
child: provider.promoApplied
? OutlinedButton(
onPressed: () {
provider.resetPromo();
_promoCtrl.clear();
},
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Remove'),
)
: ElevatedButton(
onPressed: provider.loading
? null
: () async {
final ok = await provider.applyPromo(_promoCtrl.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(provider.promoMessage ??
(ok ? 'Promo applied!' : 'Invalid promo code')),
backgroundColor: ok ? Colors.green : Colors.red,
),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
foregroundColor: Colors.white,
),
child: const Text('Apply'),
),
),
],
),
if (provider.promoApplied && provider.promoMessage != null) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.local_offer, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
'${provider.promoMessage} — saves \u20b9${provider.discountAmount.toStringAsFixed(0)}',
style: const TextStyle(fontSize: 12, color: Colors.green),
),
],
),
],
],
);
},
),
],
),
),
);
}
Widget _field(String label, TextEditingController ctrl, {TextInputType? type, String? Function(String?)? validator}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextFormField(
controller: ctrl,
keyboardType: type,
validator: validator,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
filled: true,
fillColor: Colors.grey.shade50,
),
),
);
}
Widget _buildPaymentReview(CheckoutProvider provider) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('Order Summary', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 16),
...provider.cart.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${item.ticket.ticketType} x${item.quantity}'),
Text('Rs ${item.subtotal.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
)),
const Divider(height: 32),
if (provider.promoApplied) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Subtotal', style: TextStyle(color: Colors.grey.shade700)),
Text('Rs ${provider.subtotal.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_offer, size: 14, color: Colors.green),
const SizedBox(width: 4),
Text(
provider.couponCode != null ? 'Promo (${provider.couponCode})' : 'Promo discount',
style: const TextStyle(color: Colors.green),
),
],
),
Text('- Rs ${provider.discountAmount.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.w600)),
],
),
const SizedBox(height: 8),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
Text('Rs ${provider.total.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Color(0xFF0B63D6))),
],
),
const SizedBox(height: 24),
if (_nameCtrl.text.isNotEmpty) ...[
Text('Name: ${_nameCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
Text('Email: ${_emailCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
Text('Phone: ${_phoneCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
],
],
);
}
Widget _buildBottomBar(CheckoutProvider provider) {
return Container(
padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.of(context).padding.bottom + 12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 12, offset: const Offset(0, -4))],
),
child: Row(
children: [
if (provider.currentStep != CheckoutStep.tickets)
TextButton(
onPressed: provider.previousStep,
child: const Text('Back'),
),
const Spacer(),
if (provider.currentStep == CheckoutStep.tickets)
ElevatedButton(
onPressed: provider.hasItems ? provider.nextStep : null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text('Continue Rs ${provider.subtotal.toStringAsFixed(0)}'),
)
else if (provider.currentStep == CheckoutStep.details)
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ?? false) {
provider.setShipping(ShippingDetails(
name: _nameCtrl.text,
email: _emailCtrl.text,
phone: _phoneCtrl.text,
));
provider.nextStep();
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Review Order'),
)
else if (provider.currentStep == CheckoutStep.payment)
ElevatedButton(
onPressed: provider.loading ? null : () => _processPayment(provider),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: provider.loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: Text('Pay Rs ${provider.total.toStringAsFixed(0)}'),
),
],
),
);
}
Future<void> _processPayment(CheckoutProvider provider) async {
try {
await provider.processCheckout();
_paymentService.openPayment(
amount: provider.total,
email: _emailCtrl.text,
phone: _phoneCtrl.text,
eventName: widget.eventName,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(userFriendlyError(e)), backgroundColor: Colors.red),
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
// lib/screens/contributor_profile_screen.dart
// CTR-004 — Public contributor profile page.
// Shows avatar, tier ring, EP stats, and submission grid for any contributor.
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/services/gamification_service.dart';
import '../widgets/tier_avatar_ring.dart';
class ContributorProfileScreen extends StatefulWidget {
final String contributorId;
final String contributorName;
const ContributorProfileScreen({
super.key,
required this.contributorId,
required this.contributorName,
});
@override
State<ContributorProfileScreen> createState() => _ContributorProfileScreenState();
}
class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
DashboardResponse? _data;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final data = await GamificationService().getDashboardForUser(widget.contributorId);
if (mounted) {
setState(() {
_data = data;
_loading = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_error = 'Could not load profile';
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF0F172A),
foregroundColor: Colors.white,
title: Text(
widget.contributorName,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
elevation: 0,
),
body: _loading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF3B82F6)),
)
: _error != null
? Center(
child: Text(
_error!,
style: const TextStyle(color: Colors.white54),
),
)
: _buildContent(),
);
}
Widget _buildContent() {
final profile = _data!.profile;
final submissions = _data!.submissions;
final tierStr = tierLabel(profile.tier);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Avatar with tier ring
TierAvatarRing(
username: widget.contributorName,
tier: tierStr,
size: 88,
),
const SizedBox(height: 12),
Text(
widget.contributorName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF1E3A8A),
borderRadius: BorderRadius.circular(12),
),
child: Text(
tierStr,
style: const TextStyle(fontSize: 12, color: Color(0xFF93C5FD)),
),
),
const SizedBox(height: 16),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_statCard('EP', '${profile.currentEp}'),
_statCard('Events', '${submissions.length}'),
_statCard(
'Approved',
'${submissions.where((s) => s.status.toUpperCase() == 'APPROVED').length}',
),
],
),
],
),
),
),
if (submissions.isNotEmpty)
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, i) => _buildSubmissionTile(submissions[i]),
childCount: submissions.length,
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.1,
),
),
)
else
const SliverToBoxAdapter(
child: Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'No submissions yet',
style: TextStyle(color: Colors.white38),
),
),
),
),
],
);
}
Widget _statCard(String label, String value) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
),
],
);
}
Widget _buildSubmissionTile(SubmissionModel s) {
final Color statusColor;
switch (s.status.toUpperCase()) {
case 'APPROVED':
statusColor = const Color(0xFF22C55E);
break;
case 'REJECTED':
statusColor = const Color(0xFFEF4444);
break;
default:
statusColor = const Color(0xFFFBBF24); // PENDING
}
// SubmissionModel.images is List<String>; use first image if present.
final String? firstImage = s.images.isNotEmpty ? s.images.first : null;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
),
child: Stack(
children: [
if (firstImage != null && firstImage.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.expand(
child: CachedNetworkImage(
imageUrl: firstImage,
fit: BoxFit.cover,
memCacheWidth: 400,
memCacheHeight: 300,
maxWidthDiskCache: 800,
maxHeightDiskCache: 600,
placeholder: (_, __) => Container(color: const Color(0xFF1E293B)),
errorWidget: (_, __, ___) => Container(color: const Color(0xFF334155)),
),
),
),
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(6),
),
child: Text(
s.status,
style: const TextStyle(
fontSize: 9,
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
),
),
if (s.eventName.isNotEmpty)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black87, Colors.transparent],
),
borderRadius: BorderRadius.vertical(bottom: Radius.circular(10)),
),
child: Text(
s.eventName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Colors.white),
),
),
),
],
),
);
}
}

View File

@@ -1,6 +1,7 @@
// lib/screens/desktop_login_screen.dart
import 'package:flutter/material.dart';
import '../core/utils/error_utils.dart';
import '../features/auth/services/auth_service.dart';
import '../core/auth/auth_guard.dart';
import 'home_desktop_screen.dart';
@@ -14,9 +15,23 @@ class DesktopLoginScreen extends StatefulWidget {
}
class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin {
// Login controllers
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController();
// Signup controllers
final TextEditingController _signupEmailCtrl = TextEditingController();
final TextEditingController _signupPhoneCtrl = TextEditingController();
final TextEditingController _signupPassCtrl = TextEditingController();
final TextEditingController _signupConfirmCtrl = TextEditingController();
String? _signupDistrict;
static const _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
final AuthService _auth = AuthService();
AnimationController? _controller;
@@ -30,13 +45,18 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
final Curve _curve = Curves.easeInOutCubic;
bool _isAnimating = false;
bool _loading = false; // network loading flag
bool _loading = false;
bool _isSignupMode = false;
@override
void dispose() {
_controller?.dispose();
_emailCtrl.dispose();
_passCtrl.dispose();
_signupEmailCtrl.dispose();
_signupPhoneCtrl.dispose();
_signupPassCtrl.dispose();
_signupConfirmCtrl.dispose();
super.dispose();
}
@@ -51,7 +71,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
_leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)),
);
_formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
);
@@ -67,9 +86,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
Future<void> _performLoginFlow(double initialLeftWidth) async {
if (_isAnimating || _loading) return;
setState(() {
_loading = true;
});
setState(() => _loading = true);
final email = _emailCtrl.text.trim();
final password = _passCtrl.text;
@@ -86,14 +103,9 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
}
try {
// Capture user model returned by AuthService (AuthService already saves prefs)
await _auth.login(email, password);
// on success run opening animation
await _startCollapseAnimation(initialLeftWidth);
if (!mounted) return;
Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
transitionDuration: Duration.zero,
@@ -101,24 +113,292 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
));
} catch (e) {
if (!mounted) return;
final message = e.toString().replaceAll('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
setState(() => _isAnimating = false);
} finally {
if (mounted) setState(() {
_loading = false;
});
if (mounted) setState(() => _loading = false);
}
}
void _openRegister() {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen()));
Future<void> _performSignupFlow(double initialLeftWidth) async {
if (_isAnimating || _loading) return;
final email = _signupEmailCtrl.text.trim();
final phone = _signupPhoneCtrl.text.trim();
final pass = _signupPassCtrl.text;
final confirm = _signupConfirmCtrl.text;
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
return;
}
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
return;
}
if (pass.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Password must be at least 6 characters')));
return;
}
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
}
setState(() => _loading = true);
try {
await _auth.register(
email: email,
phoneNumber: phone,
password: pass,
district: _signupDistrict,
);
await _startCollapseAnimation(initialLeftWidth);
if (!mounted) return;
Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
setState(() => _isAnimating = false);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _openForgotPasswordDialog() async {
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
bool submitting = false;
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialog) {
return AlertDialog(
title: const Text('Forgot Password'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Enter your email and we'll send reset instructions."),
const SizedBox(height: 12),
TextField(
controller: emailCtrl,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
keyboardType: TextInputType.emailAddress,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
ElevatedButton(
onPressed: submitting
? null
: () async {
final email = emailCtrl.text.trim();
if (email.isEmpty) return;
setDialog(() => submitting = true);
try {
await _auth.forgotPassword(email);
} catch (_) {
// safe-degrade
}
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("If that email is registered, we've sent reset instructions."),
duration: Duration(seconds: 4),
),
);
},
child: submitting
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Send reset link'),
),
],
);
},
);
},
);
emailCtrl.dispose();
}
Widget _buildLoginFields(double safeInitialWidth) {
return Column(
key: const ValueKey('login'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Sign In', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 6),
const Text('Please enter your details to continue', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
const SizedBox(height: 22),
TextField(
controller: _emailCtrl,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email Address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock),
labelText: 'Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Checkbox(value: true, onChanged: (_) {}),
const Text('Remember me'),
]),
TextButton(onPressed: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
],
),
const SizedBox(height: 8),
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: (_isAnimating || _loading) ? null : () => _performLoginFlow(safeInitialWidth),
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: (_isAnimating || _loading)
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Sign In', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => setState(() => _isSignupMode = true),
child: const Text("Don't have an account? Register"),
),
TextButton(onPressed: () {}, child: const Text('Contact support')),
TextButton(
onPressed: () {
AuthGuard.setGuest(true);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
(route) => false,
);
},
child: const Text('Continue as Guest'),
),
],
),
],
);
}
Widget _buildSignupFields(double safeInitialWidth) {
return Column(
key: const ValueKey('signup'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Create Account', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 6),
const Text('Fill in your details to get started', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
const SizedBox(height: 22),
TextField(
controller: _signupEmailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email Address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _signupPhoneCtrl,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.phone),
labelText: 'Phone Number',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _signupDistrict,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.location_on),
labelText: 'District (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
onChanged: (v) => setState(() => _signupDistrict = v),
),
const SizedBox(height: 12),
TextField(
controller: _signupPassCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock),
labelText: 'Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _signupConfirmCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_outline),
labelText: 'Confirm Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 16),
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: (_isAnimating || _loading) ? null : () => _performSignupFlow(safeInitialWidth),
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: (_isAnimating || _loading)
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Create Account', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 12),
Center(
child: TextButton(
onPressed: () => setState(() => _isSignupMode = false),
child: const Text('Already have an account? Sign in'),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
final bool animAvailable = _controller != null && _leftWidthAnim != null;
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
@@ -138,7 +418,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
Container(
width: leftWidth,
height: double.infinity,
// color: const Color(0xFF0B63D6),
decoration: AppDecoration.blueGradient,
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
child: Opacity(
@@ -149,11 +428,16 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
const SizedBox(height: 4),
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
const Spacer(),
const Text('Welcome Back!', style: TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold)),
Text(
_isSignupMode ? 'Join Eventify!' : 'Welcome Back!',
style: const TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text(
'Sign in to access your dashboard, manage events, and stay connected.',
style: TextStyle(color: Colors.white70, fontSize: 14),
Text(
_isSignupMode
? 'Create your account to discover events, book tickets, and connect with your community.'
: 'Sign in to access your dashboard, manage events, and stay connected.',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(flex: 2),
Opacity(
@@ -167,7 +451,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
),
),
),
Expanded(
child: Transform.translate(
offset: Offset(formOffset, 0),
@@ -177,85 +460,20 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Sign In', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 6),
const Text('Please enter your details to continue', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
const SizedBox(height: 22),
TextField(
controller: _emailCtrl,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email Address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock),
labelText: 'Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Checkbox(value: true, onChanged: (_) {}),
const Text('Remember me')
]),
TextButton(onPressed: () {}, child: const Text('Forgot Password?'))
],
),
const SizedBox(height: 8),
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: (_isAnimating || _loading)
? null
: () {
final double initial = safeInitialWidth;
_performLoginFlow(initial);
},
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: (_isAnimating || _loading)
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Sign In', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.spaceBetween,
children: [
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
TextButton(onPressed: () {}, child: const Text('Contact support')),
TextButton(
onPressed: () {
AuthGuard.setGuest(true);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
(route) => false,
);
},
child: const Text('Continue as Guest'),
),
],
)
],
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
child: _isSignupMode
? _buildSignupFields(safeInitialWidth)
: _buildLoginFields(safeInitialWidth),
),
),
),
),
@@ -273,113 +491,3 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
);
}
}
class DesktopRegisterScreen extends StatefulWidget {
const DesktopRegisterScreen({Key? key}) : super(key: key);
@override
State<DesktopRegisterScreen> createState() => _DesktopRegisterScreenState();
}
class _DesktopRegisterScreenState extends State<DesktopRegisterScreen> {
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _phoneCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController();
final TextEditingController _confirmCtrl = TextEditingController();
final AuthService _auth = AuthService();
bool _loading = false;
@override
void dispose() {
_emailCtrl.dispose();
_phoneCtrl.dispose();
_passCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
Future<void> _performRegister() async {
final email = _emailCtrl.text.trim();
final phone = _phoneCtrl.text.trim();
final pass = _passCtrl.text;
final confirm = _confirmCtrl.text;
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
return;
}
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
return;
}
if (pass.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter password')));
return;
}
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
}
setState(() => _loading = true);
try {
await _auth.register(
email: email,
phoneNumber: phone,
password: pass,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)));
} catch (e) {
if (!mounted) return;
final message = e.toString().replaceAll('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Column(
children: [
TextField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email')),
const SizedBox(height: 8),
TextField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone')),
const SizedBox(height: 8),
TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
const SizedBox(height: 8),
TextField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password')),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(onPressed: _loading ? null : _performRegister, child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register')),
const SizedBox(width: 12),
OutlinedButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
],
)
],
),
),
),
),
),
),
),
);
}
}

View File

@@ -55,10 +55,10 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> {
return const SettingsScreen();
default:
return _HomeContent(
onEventTap: (eventId) {
onEventTap: (eventId, event) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: eventId)),
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: eventId, initialEvent: event)),
);
},
);
@@ -70,7 +70,7 @@ class _HomeDesktopScreenState extends State<HomeDesktopScreen> {
// Home content — hero, categories, event grid
// ---------------------------------------------------------------------------
class _HomeContent extends StatefulWidget {
final void Function(int eventId) onEventTap;
final void Function(int eventId, EventModel event) onEventTap;
const _HomeContent({required this.onEventTap});
@override
@@ -319,6 +319,9 @@ class _HomeContentState extends State<_HomeContent>
width: double.infinity,
height: double.infinity,
memCacheWidth: 1400,
memCacheHeight: 800,
maxWidthDiskCache: 1400,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(
color: const Color(0xFF0A0E1A),
),
@@ -527,6 +530,9 @@ class _HomeContentState extends State<_HomeContent>
imageUrl: img,
fit: BoxFit.cover,
memCacheWidth: 1400,
memCacheHeight: 800,
maxWidthDiskCache: 1400,
maxHeightDiskCache: 800,
)
else
Container(color: const Color(0xFF0A0E1A)),
@@ -573,7 +579,7 @@ class _HomeContentState extends State<_HomeContent>
child: GestureDetector(
onTap: () {
Navigator.of(ctx).pop();
widget.onEventTap(event.id);
widget.onEventTap(event.id, event);
},
child: Container(
padding: const EdgeInsets.symmetric(
@@ -753,7 +759,7 @@ class _HomeContentState extends State<_HomeContent>
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onEventTap(e.id),
onTap: () => widget.onEventTap(e.id, e),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -780,6 +786,8 @@ class _HomeContentState extends State<_HomeContent>
imageUrl: img,
memCacheWidth: 600,
memCacheHeight: 320,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 640,
width: double.infinity,
height: imageHeight,
fit: BoxFit.cover,

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
// google_maps_flutter removed — using OpenStreetMap static map preview instead
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -11,19 +12,46 @@ import 'package:cached_network_image/cached_network_image.dart';
import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart';
import '../core/auth/auth_guard.dart';
import '../core/utils/error_utils.dart';
import '../core/constants.dart';
import '../features/reviews/widgets/review_section.dart';
import '../widgets/tier_avatar_ring.dart';
import 'contributor_profile_screen.dart';
import 'checkout_screen.dart';
import '../core/analytics/posthog_service.dart';
class LearnMoreScreen extends StatefulWidget {
final int eventId;
const LearnMoreScreen({Key? key, required this.eventId}) : super(key: key);
final EventModel? initialEvent;
final String? heroTag;
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent, this.heroTag}) : super(key: key);
@override
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
}
class _LearnMoreScreenState extends State<LearnMoreScreen> {
class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProviderStateMixin {
final EventsService _service = EventsService();
late final AnimationController _fadeController;
late final Animation<double> _fade;
void _navigateToCheckout() {
if (!AuthGuard.requireLogin(context, reason: 'Sign in to book this event.')) return;
if (_event == null) return;
PostHogService.instance.capture('book_now_tapped', properties: {
'event_id': _event!.id,
'event_name': _event!.name ?? _event!.title ?? '',
});
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => CheckoutScreen(
eventId: _event!.id,
eventName: _event!.name,
eventImage: _event!.thumbImg,
),
));
}
bool _loading = true;
EventModel? _event;
String? _error;
@@ -41,14 +69,30 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// Google Map
GoogleMapController? _mapController;
MapType _mapType = MapType.normal;
bool _showMapControls = false;
// Related events (EVT-002)
List<EventModel> _relatedEvents = [];
bool _loadingRelated = false;
@override
void initState() {
super.initState();
PostHogService.instance.screen('EventDetail', properties: {'event_id': widget.eventId});
_fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 350));
_fade = CurvedAnimation(parent: _fadeController, curve: Curves.easeIn);
_pageNotifier = ValueNotifier(0);
_loadEvent();
if (widget.initialEvent != null) {
_event = widget.initialEvent;
_loading = false;
_fadeController.forward();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startAutoScroll();
// Fetch full event details in background to get important_information, images, etc.
_loadFullDetails();
});
} else {
_loadEvent();
}
}
@override
@@ -57,6 +101,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
_pageController.dispose();
_pageNotifier.dispose();
_mapController?.dispose();
_fadeController.dispose();
super.dispose();
}
@@ -64,6 +109,28 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// Data loading
// ---------------------------------------------------------------------------
/// Fetch full event details to fill in fields missing from the list
/// endpoint (important_information, images, etc.).
Future<void> _loadFullDetails() async {
for (int attempt = 0; attempt < 2; attempt++) {
try {
final ev = await _service.getEventDetails(widget.eventId);
if (!mounted) return;
setState(() {
_event = ev;
});
_startAutoScroll();
_loadRelatedEvents();
return; // success
} catch (e) {
debugPrint('_loadFullDetails attempt ${attempt + 1} failed for event ${widget.eventId}: $e');
if (attempt == 0) {
await Future.delayed(const Duration(seconds: 1)); // wait before retry
}
}
}
}
Future<void> _loadEvent() async {
setState(() {
_loading = true;
@@ -74,11 +141,28 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (!mounted) return;
setState(() => _event = ev);
_startAutoScroll();
_loadRelatedEvents();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
setState(() => _error = userFriendlyError(e));
} finally {
if (mounted) setState(() => _loading = false);
if (mounted) {
setState(() => _loading = false);
_fadeController.forward();
}
}
}
/// Fetch related events by the same event type (EVT-002).
Future<void> _loadRelatedEvents() async {
if (_event?.eventTypeId == null) return;
if (mounted) setState(() => _loadingRelated = true);
try {
final events = await _service.getEventsByCategory(_event!.eventTypeId!, limit: 6);
final filtered = events.where((e) => e.id != widget.eventId).take(5).toList();
if (mounted) setState(() => _relatedEvents = filtered);
} finally {
if (mounted) setState(() => _loadingRelated = false);
}
}
@@ -141,7 +225,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
Future<void> _shareEvent() async {
final title = _event?.title ?? _event?.name ?? 'Check out this event';
final url =
'https://uat.eventifyplus.com/events/${widget.eventId}';
'https://app.eventifyplus.com/event/${widget.eventId}';
await Share.share('$title\n$url', subject: title);
}
@@ -164,18 +248,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
'https://www.google.com/maps/dir/?api=1&destination=${_event!.latitude},${_event!.longitude}');
}
// ---------------------------------------------------------------------------
// Map camera helpers
// ---------------------------------------------------------------------------
void _moveCamera(double latDelta, double lngDelta) {
_mapController?.animateCamera(CameraUpdate.scrollBy(lngDelta * 80, -latDelta * 80));
}
void _zoom(double amount) {
_mapController?.animateCamera(CameraUpdate.zoomBy(amount));
}
// ---------------------------------------------------------------------------
// BUILD
// ---------------------------------------------------------------------------
@@ -230,7 +302,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
final mediaQuery = MediaQuery.of(context);
final screenWidth = mediaQuery.size.width;
final screenHeight = mediaQuery.size.height;
final imageHeight = screenHeight * 0.45;
final imageHeight = screenHeight * 0.52;
final topPadding = mediaQuery.padding.top;
// ── DESKTOP layout ──────────────────────────────────────────────────
@@ -258,6 +330,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
CachedNetworkImage(
imageUrl: heroImage,
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -365,9 +441,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
right: 32,
bottom: 36,
child: ElevatedButton(
onPressed: () {
// TODO: implement booking action
},
onPressed: _navigateToCheckout,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
foregroundColor: Colors.white,
@@ -407,6 +481,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
// EVT-001: Contributor widget
_buildContributorSection(theme),
const SizedBox(height: 24),
ReviewSection(eventId: widget.eventId),
// EVT-002: Related events horizontal row
_buildRelatedEventsSection(theme),
],
),
),
@@ -464,6 +544,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
CachedNetworkImage(
imageUrl: images[i],
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
@@ -503,8 +587,46 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Stack(
children: [
bottomNavigationBar: (_event != null && _event!.isBookable)
? Container(
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: SafeArea(
top: false,
child: SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _navigateToCheckout,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: const Text(
'Book Now',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
),
)
: null,
body: FadeTransition(
opacity: _fade,
child: Stack(
children: [
// ── Scrollable content (carousel + card scroll together) ──
SingleChildScrollView(
child: Column(
@@ -545,6 +667,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
// EVT-001: Contributor widget
_buildContributorSection(theme),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: ReviewSection(eventId: widget.eventId),
),
// EVT-002: Related events horizontal row
_buildRelatedEventsSection(theme),
const SizedBox(height: 100),
],
),
@@ -627,6 +758,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
),
],
),
),
);
}
@@ -717,6 +849,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
builder: (context, currentPage, _) => CachedNetworkImage(
imageUrl: images[currentPage],
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => Container(
@@ -762,19 +898,25 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// ---- Foreground image with rounded corners ----
if (images.isNotEmpty)
Positioned(
top: topPad + 56, // below the icon row
top: topPad + 70, // safely below the icon row
left: 20,
right: 20,
bottom: 16,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: PageView.builder(
bottom: 40, // clear from the bottom card's -28 overlap
child: Hero(
tag: widget.heroTag ?? 'event-hero-${widget.eventId}',
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: PageView.builder(
controller: _pageController,
onPageChanged: (i) => _pageNotifier.value = i,
itemCount: images.length,
itemBuilder: (_, i) => CachedNetworkImage(
imageUrl: images[i],
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
width: double.infinity,
placeholder: (_, __) => Container(
color: theme.dividerColor,
@@ -788,14 +930,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
),
),
),
),
// ---- No-image placeholder ----
if (images.isEmpty)
Positioned(
top: topPad + 56,
top: topPad + 70,
left: 20,
right: 20,
bottom: 16,
bottom: 40,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
@@ -930,7 +1073,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
}
// ---------------------------------------------------------------------------
// 5. VENUE LOCATION (Google Map)
// 5. VENUE LOCATION (Native Google Map on mobile, fallback on web)
// ---------------------------------------------------------------------------
Widget _buildVenueSection(ThemeData theme) {
@@ -956,45 +1099,26 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
height: 280,
height: 250,
width: double.infinity,
child: Stack(
children: [
// Use static map image on web (Google Maps JS SDK not configured),
// native GoogleMap widget on mobile
// Native Google Maps SDK on mobile, tappable fallback on web
if (kIsWeb)
GestureDetector(
onTap: _viewLargerMap,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(20),
),
child: Stack(
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(
color: const Color(0xFFE8EAF6),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.map_outlined, size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Text(
'Tap to view on Google Maps',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
],
color: const Color(0xFFE8EAF6),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.map_outlined, size: 48, color: theme.colorScheme.primary),
const SizedBox(height: 8),
Text('Tap to view on Google Maps',
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
],
),
),
),
)
@@ -1004,7 +1128,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
target: LatLng(lat, lng),
zoom: 15,
),
mapType: _mapType,
markers: {
Marker(
markerId: const MarkerId('event'),
@@ -1013,14 +1136,14 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
),
},
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
zoomControlsEnabled: true,
scrollGesturesEnabled: true,
rotateGesturesEnabled: false,
tiltGesturesEnabled: false,
onMapCreated: (c) => _mapController = c,
),
// "View larger map" top left
// "View larger map" overlay button — top left
Positioned(
top: 10,
left: 10,
@@ -1032,112 +1155,23 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 6,
),
BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 6),
],
),
child: Text(
'View larger map',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 13,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.open_in_new, size: 14, color: theme.colorScheme.primary),
const SizedBox(width: 4),
Text(
'View larger map',
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600, fontSize: 13),
),
],
),
),
),
),
// Map type toggle bottom left (native only)
if (!kIsWeb)
Positioned(
bottom: 12,
left: 12,
child: _mapControlButton(
icon: _mapType == MapType.normal
? Icons.satellite_alt
: Icons.map_outlined,
onTap: () {
setState(() {
_mapType = _mapType == MapType.normal
? MapType.satellite
: MapType.normal;
});
},
),
),
// Map controls toggle bottom right (native only)
if (!kIsWeb)
Positioned(
bottom: 12,
right: 12,
child: _mapControlButton(
icon: Icons.open_with_rounded,
onTap: () => setState(() => _showMapControls = !_showMapControls),
),
),
// Directional pad overlay (native only)
if (!kIsWeb && _showMapControls)
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.25),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_mapControlButton(
icon: Icons.keyboard_arrow_up,
onTap: () => _moveCamera(1, 0)),
const SizedBox(width: 16),
_mapControlButton(
icon: Icons.add,
onTap: () => _zoom(1)),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_mapControlButton(
icon: Icons.keyboard_arrow_left,
onTap: () => _moveCamera(0, -1)),
const SizedBox(width: 60),
_mapControlButton(
icon: Icons.keyboard_arrow_right,
onTap: () => _moveCamera(0, 1)),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_mapControlButton(
icon: Icons.keyboard_arrow_down,
onTap: () => _moveCamera(-1, 0)),
const SizedBox(width: 16),
_mapControlButton(
icon: Icons.remove,
onTap: () => _zoom(-1)),
const SizedBox(width: 16),
_mapControlButton(
icon: Icons.close,
onTap: () =>
setState(() => _showMapControls = false)),
],
),
],
),
),
),
],
),
),
@@ -1154,7 +1188,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.06),
color: theme.shadowColor.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
@@ -1163,21 +1197,11 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
venueLabel,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(venueLabel, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
if (_event!.place != null && _event!.place != venueLabel)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_event!.place!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.hintColor,
),
),
child: Text(_event!.place!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
),
],
),
@@ -1187,30 +1211,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
);
}
Widget _mapControlButton({
required IconData icon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.92),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.15),
blurRadius: 6,
),
],
),
child: Icon(icon, color: Colors.grey.shade700, size: 22),
),
);
}
// ---------------------------------------------------------------------------
// 6. GET DIRECTIONS BUTTON
// ---------------------------------------------------------------------------
@@ -1335,11 +1335,20 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
/// Parse an HTML important_information string into a list of {title, value} maps
List<Map<String, String>> _parseHtmlImportantInfo(String raw) {
// Strip HTML tags, preserving <br> as a newline separator first
var text = raw
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
.replaceAll(RegExp(r'<[^>]*>'), '');
// Decode entities
var text = raw;
// 1. Remove <style>...</style> blocks entirely (content + tags)
text = text.replaceAll(RegExp(r'<style[^>]*>.*?</style>', caseSensitive: false, dotAll: true), '');
// 2. Remove <script>...</script> blocks
text = text.replaceAll(RegExp(r'<script[^>]*>.*?</script>', caseSensitive: false, dotAll: true), '');
// 3. Convert block-level closers to newlines
text = text.replaceAll(RegExp(r'</div>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</p>', caseSensitive: false), '\n');
text = text.replaceAll(RegExp(r'</li>', caseSensitive: false), '\n');
// 4. Convert <br> to newlines
text = text.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n');
// 5. Strip all remaining HTML tags
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
// 6. Decode HTML entities
text = text
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
@@ -1386,6 +1395,231 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
return items;
}
// ---------------------------------------------------------------------------
// 8. CONTRIBUTOR WIDGET (EVT-001)
// ---------------------------------------------------------------------------
Widget _buildContributorSection(ThemeData theme) {
final name = _event?.contributorName;
if (name == null || name.isEmpty) return const SizedBox.shrink();
final tier = _event!.contributorTier ?? '';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.brightness == Brightness.dark
? const Color(0xFF1E293B)
: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.brightness == Brightness.dark
? Colors.white.withOpacity(0.08)
: theme.dividerColor,
),
),
child: Row(
children: [
TierAvatarRing(
username: name,
tier: tier,
size: 40,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contributed by',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontSize: 11,
),
),
const SizedBox(height: 2),
Text(
name,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (tier.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
tier,
style: TextStyle(
fontSize: 10,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
if (_event?.contributorId != null)
IconButton(
icon: Icon(Icons.arrow_forward_ios,
size: 14, color: theme.hintColor),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ContributorProfileScreen(
contributorId: _event!.contributorId!,
contributorName: _event!.contributorName!,
),
),
);
},
),
],
),
),
);
}
// ---------------------------------------------------------------------------
// 9. RELATED EVENTS ROW (EVT-002)
// ---------------------------------------------------------------------------
Widget _buildRelatedEventsSection(ThemeData theme) {
if (_loadingRelated) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Related Events',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
const SizedBox(height: 12),
const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
);
}
if (_relatedEvents.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
child: Text(
'Related Events',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 18,
),
),
),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _relatedEvents.length,
itemBuilder: (context, i) {
final e = _relatedEvents[i];
final displayName = e.title ?? e.name;
final imageUrl = e.thumbImg ?? '';
return GestureDetector(
onTap: () => Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LearnMoreScreen(eventId: e.id),
),
),
child: Container(
width: 140,
margin: const EdgeInsets.only(right: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.brightness == Brightness.dark
? const Color(0xFF1E293B)
: theme.cardColor,
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: imageUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: imageUrl,
height: 100,
width: 140,
memCacheWidth: 280,
memCacheHeight: 200,
maxWidthDiskCache: 560,
maxHeightDiskCache: 400,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(
height: 100,
width: 140,
color: theme.dividerColor,
child: Icon(Icons.event,
size: 32, color: theme.hintColor),
),
)
: Container(
height: 100,
width: 140,
color: theme.dividerColor,
child: Icon(Icons.event,
size: 32, color: theme.hintColor),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
displayName,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
height: 1.35,
),
),
),
],
),
),
);
},
),
),
const SizedBox(height: 8),
],
);
}
Widget _buildImportantInfoFallback(ThemeData theme) {
final parsed = _parseHtmlImportantInfo(_event!.importantInformation!);

View File

@@ -1,12 +1,16 @@
// lib/screens/login_screen.dart
import 'dart:ui';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../core/utils/error_utils.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:provider/provider.dart';
import '../features/auth/services/auth_service.dart';
import '../features/auth/providers/auth_provider.dart';
import '../core/auth/auth_guard.dart';
import 'home_screen.dart';
import 'responsive_layout.dart';
import 'home_desktop_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({Key? key}) : super(key: key);
@@ -17,18 +21,36 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _signupFormKey = GlobalKey<FormState>();
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController();
final FocusNode _emailFocus = FocusNode();
final FocusNode _passFocus = FocusNode();
// Signup-specific controllers
final TextEditingController _signupEmailCtrl = TextEditingController();
final TextEditingController _signupPhoneCtrl = TextEditingController();
final TextEditingController _signupPassCtrl = TextEditingController();
final TextEditingController _signupConfirmCtrl = TextEditingController();
String? _signupDistrict;
bool _signupObscurePass = true;
bool _signupObscureConfirm = true;
static const _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
bool _isSignupMode = false;
final AuthService _auth = AuthService();
bool _loading = false;
bool _obscurePassword = true;
bool _rememberMe = false;
late VideoPlayerController _videoController;
VideoPlayerController? _videoController;
bool _videoInitialized = false;
// Glassmorphism color palette
@@ -45,24 +67,35 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() {
super.initState();
_initVideo();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ScaffoldMessenger.of(context).clearSnackBars();
});
}
Future<void> _initVideo() async {
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
await _videoController.initialize();
_videoController.setLooping(true);
_videoController.setVolume(0);
_videoController.play();
if (mounted) setState(() => _videoInitialized = true);
try {
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
await _videoController!.initialize();
_videoController!.setLooping(true);
_videoController!.setVolume(0);
_videoController!.play();
if (mounted) setState(() => _videoInitialized = true);
} catch (_) {
// Video asset not available — skip background video
}
}
@override
void dispose() {
_videoController.dispose();
_videoController?.dispose();
_emailCtrl.dispose();
_passCtrl.dispose();
_emailFocus.dispose();
_passFocus.dispose();
_signupEmailCtrl.dispose();
_signupPhoneCtrl.dispose();
_signupPassCtrl.dispose();
_signupConfirmCtrl.dispose();
super.dispose();
}
@@ -106,7 +139,7 @@ class _LoginScreenState extends State<LoginScreen> {
));
} catch (e) {
if (!mounted) return;
final message = e.toString().replaceAll('Exception: ', '');
final message = userFriendlyError(e);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _loading = false);
@@ -114,7 +147,11 @@ class _LoginScreenState extends State<LoginScreen> {
}
void _openRegister() {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false)));
setState(() => _isSignupMode = true);
}
void _openLogin() {
setState(() => _isSignupMode = false);
}
void _showComingSoon() {
@@ -123,6 +160,203 @@ class _LoginScreenState extends State<LoginScreen> {
);
}
Future<void> _performSignup() async {
if (!(_signupFormKey.currentState?.validate() ?? false)) return;
final email = _signupEmailCtrl.text.trim();
final phone = _signupPhoneCtrl.text.trim();
final pass = _signupPassCtrl.text;
final confirm = _signupConfirmCtrl.text;
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
}
setState(() => _loading = true);
try {
await _auth.register(
email: email,
phoneNumber: phone,
password: pass,
district: _signupDistrict,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (context, a1, a2) => const HomeScreen(),
transitionDuration: const Duration(milliseconds: 650),
transitionsBuilder: (context, animation, _, child) => FadeTransition(opacity: animation, child: child),
));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _openForgotPasswordSheet() async {
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
final sheetFormKey = GlobalKey<FormState>();
bool submitting = false;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setSheetState) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.75),
border: Border.all(color: _glassBorder, width: 0.8),
),
child: Form(
key: sheetFormKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 18),
const Text(
'Forgot Password',
style: TextStyle(color: _textWhite, fontSize: 20, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
const Text(
"Enter your email and we'll send you reset instructions.",
style: TextStyle(color: _textMuted, fontSize: 13),
),
const SizedBox(height: 20),
TextFormField(
controller: emailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Enter your email',
prefixIcon: Icons.mail_outline_rounded,
),
validator: _emailValidator,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: const LinearGradient(
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
),
border: Border.all(color: const Color(0x33FFFFFF)),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: submitting
? null
: () async {
if (!(sheetFormKey.currentState?.validate() ?? false)) return;
setSheetState(() => submitting = true);
final email = emailCtrl.text.trim();
try {
await _auth.forgotPassword(email);
} catch (_) {
// safe-degrade: don't leak whether email exists or backend status
}
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("If that email is registered, we've sent reset instructions."),
duration: Duration(seconds: 4),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: _textWhite),
)
: const Text(
'Send reset link',
style: TextStyle(color: _textWhite, fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
),
),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel', style: TextStyle(color: _textMuted)),
),
],
),
),
),
),
),
);
},
);
},
);
emailCtrl.dispose();
}
Future<void> _performGoogleLogin() async {
try {
setState(() => _loading = true);
await Provider.of<AuthProvider>(context, listen: false).googleLogin();
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ResponsiveLayout(mobile: HomeScreen(), desktop: const HomeDesktopScreen())),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(userFriendlyError(e))),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// Glassmorphism pill-shaped input decoration
InputDecoration _glassInputDecoration({
required String hint,
@@ -214,14 +448,14 @@ class _LoginScreenState extends State<LoginScreen> {
body: Stack(
children: [
// Video background
if (_videoInitialized)
if (_videoInitialized && _videoController != null)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _videoController.value.size.width,
height: _videoController.value.size.height,
child: VideoPlayer(_videoController),
width: _videoController!.value.size.width,
height: _videoController!.value.size.height,
child: VideoPlayer(_videoController!),
),
),
),
@@ -251,15 +485,23 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand name
Center(
child: Text(
'Eventify',
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _isSignupMode
? KeyedSubtree(key: const ValueKey('signup'), child: _buildSignupForm(context))
: KeyedSubtree(
key: const ValueKey('login'),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Brand name
Center(
child: Text(
'Eventify',
style: TextStyle(
color: _textWhite.withOpacity(0.7),
fontSize: 16,
@@ -380,7 +622,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
// Forgot Password
GestureDetector(
onTap: _showComingSoon,
onTap: _openForgotPasswordSheet,
child: const Text(
'Forgot Password?',
style: TextStyle(color: _textMuted, fontSize: 12),
@@ -473,7 +715,7 @@ class _LoginScreenState extends State<LoginScreen> {
color: Color(0xFF4285F4),
),
),
onTap: _showComingSoon,
onTap: _performGoogleLogin,
),
const SizedBox(width: 12),
_socialButton(
@@ -538,8 +780,10 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
],
),
],
),
),
),
),
),
),
@@ -549,129 +793,233 @@ class _LoginScreenState extends State<LoginScreen> {
),
);
}
}
/// Register screen calls backend register endpoint via AuthService.register
class RegisterScreen extends StatefulWidget {
final bool isDesktop;
const RegisterScreen({Key? key, this.isDesktop = false}) : super(key: key);
Widget _buildSignupForm(BuildContext context) {
return Form(
key: _signupFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
'Eventify',
style: TextStyle(
color: _textWhite.withOpacity(0.7),
fontSize: 16,
fontWeight: FontWeight.w500,
fontStyle: FontStyle.italic,
letterSpacing: 1.5,
),
),
),
const SizedBox(height: 12),
const Center(
child: Text(
'Create Your\nAccount',
textAlign: TextAlign.center,
style: TextStyle(
color: _textWhite,
fontSize: 28,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.italic,
height: 1.2,
),
),
),
const SizedBox(height: 28),
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
// Email
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Email', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupEmailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Enter your email',
prefixIcon: Icons.mail_outline_rounded,
),
validator: _emailValidator,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _phoneCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController();
final TextEditingController _confirmCtrl = TextEditingController();
final AuthService _auth = AuthService();
// Phone
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Phone', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupPhoneCtrl,
keyboardType: TextInputType.phone,
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Enter your phone number',
prefixIcon: Icons.phone_outlined,
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Enter phone number';
if (v.trim().length < 7) return 'Enter a valid phone number';
return null;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
bool _loading = false;
// District
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('District (optional)', style: TextStyle(color: _textMuted, fontSize: 13)),
),
DropdownButtonFormField<String>(
value: _signupDistrict,
dropdownColor: const Color(0xFF1A1A1A),
iconEnabledColor: _textMuted,
style: const TextStyle(color: _textWhite, fontSize: 14),
decoration: _glassInputDecoration(
hint: 'Select your district',
prefixIcon: Icons.location_on_outlined,
),
items: _districts
.map((d) => DropdownMenuItem(
value: d,
child: Text(d, style: const TextStyle(color: _textWhite)),
))
.toList(),
onChanged: (v) => setState(() => _signupDistrict = v),
),
const SizedBox(height: 16),
@override
void dispose() {
_emailCtrl.dispose();
_phoneCtrl.dispose();
_passCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
// Password
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Password', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupPassCtrl,
obscureText: _signupObscurePass,
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Create a password',
prefixIcon: Icons.lock_outline_rounded,
suffixIcon: IconButton(
icon: Icon(
_signupObscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined,
color: _textMuted,
size: 20,
),
onPressed: () => setState(() => _signupObscurePass = !_signupObscurePass),
),
),
validator: _passwordValidator,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
Future<void> _performRegister() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
// Confirm password
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Confirm password', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupConfirmCtrl,
obscureText: _signupObscureConfirm,
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Re-enter your password',
prefixIcon: Icons.lock_outline_rounded,
suffixIcon: IconButton(
icon: Icon(
_signupObscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined,
color: _textMuted,
size: 20,
),
onPressed: () => setState(() => _signupObscureConfirm = !_signupObscureConfirm),
),
),
validator: _passwordValidator,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _performSignup(),
),
const SizedBox(height: 24),
final email = _emailCtrl.text.trim();
final phone = _phoneCtrl.text.trim();
final pass = _passCtrl.text;
final confirm = _confirmCtrl.text;
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
}
setState(() => _loading = true);
try {
await _auth.register(
email: email,
phoneNumber: phone,
password: pass,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
} catch (e) {
if (!mounted) return;
final message = e.toString().replaceAll('Exception: ', '');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
String? _emailValidator(String? v) {
if (v == null || v.trim().isEmpty) return 'Enter email';
final emailRegex = RegExp(r"^[^@]+@[^@]+\.[^@]+");
if (!emailRegex.hasMatch(v.trim())) return 'Enter a valid email';
return null;
}
String? _phoneValidator(String? v) {
if (v == null || v.trim().isEmpty) return 'Enter phone number';
if (v.trim().length < 7) return 'Enter a valid phone number';
return null;
}
String? _passwordValidator(String? v) {
if (v == null || v.isEmpty) return 'Enter password';
if (v.length < 6) return 'Password must be at least 6 characters';
return null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email'), validator: _emailValidator, keyboardType: TextInputType.emailAddress),
const SizedBox(height: 8),
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
const SizedBox(height: 8),
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
const SizedBox(height: 8),
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _performRegister,
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'),
),
),
],
// Create Account button
SizedBox(
width: double.infinity,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: const LinearGradient(
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
),
border: Border.all(color: const Color(0x33FFFFFF)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.4),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(28),
onTap: _loading ? null : _performSignup,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: _loading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: _textWhite),
)
: const Text(
'Create Account',
style: TextStyle(color: _textWhite, fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
),
),
),
),
),
const SizedBox(height: 24),
// Back to Sign in
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Already have an account? ',
style: TextStyle(color: _textMuted, fontSize: 13),
),
GestureDetector(
onTap: _openLogin,
child: const Text(
'Sign in',
style: TextStyle(
color: _textWhite,
fontSize: 13,
fontWeight: FontWeight.w600,
decoration: TextDecoration.underline,
decorationColor: _textWhite,
),
),
),
],
),
),
],
),
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,23 @@
// lib/screens/search_screen.dart
import 'dart:convert';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/utils/error_utils.dart';
// Location packages
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart';
/// Data model for a location suggestion (city + optional pincode).
/// Data model for a location suggestion (city + optional pincode + optional coords).
class _LocationItem {
final String city;
final String? district;
final String? pincode;
final double? lat;
final double? lng;
const _LocationItem({required this.city, this.district, this.pincode});
const _LocationItem({required this.city, this.district, this.pincode, this.lat, this.lng});
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
String get displaySubtitle => pincode ?? '';
@@ -45,49 +50,43 @@ class _SearchScreenState extends State<SearchScreen> {
'Kottayam',
];
/// Searchable location database Kerala towns/cities with pincodes.
static const List<_LocationItem> _locationDb = [
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
];
/// Searchable location database loaded from assets/data/kerala_pincodes.json.
List<_LocationItem> _locationDb = [];
bool _pinsLoaded = false;
List<_LocationItem> _searchResults = [];
bool _showSearchResults = false;
bool _loadingLocation = false;
bool _isSearching = false;
@override
void initState() {
super.initState();
_loadKeralaData();
}
Future<void> _loadKeralaData() async {
if (_pinsLoaded) return;
try {
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
final List<dynamic> list = jsonDecode(jsonStr);
final loaded = list.map((e) => _LocationItem(
city: e['city'] as String,
district: e['district'] as String?,
pincode: e['pincode'] as String?,
lat: (e['lat'] as num?)?.toDouble(),
lng: (e['lng'] as num?)?.toDouble(),
)).toList();
if (mounted) {
setState(() {
_locationDb = loaded;
_pinsLoaded = true;
});
}
} catch (_) {
// fallback: keep empty list, search won't crash
}
}
@override
void dispose() {
@@ -112,8 +111,62 @@ class _SearchScreenState extends State<SearchScreen> {
});
}
void _selectAndClose(String location) {
Navigator.of(context).pop(location);
/// Pop with a structured result so home_screen can update the display label,
/// pincode, and GPS coordinates used for haversine filtering.
void _selectWithPincode(String label, {String? pincode, double? lat, double? lng}) {
final result = <String, dynamic>{
'label': label,
'pincode': pincode ?? 'all',
};
if (lat != null && lng != null) {
result['lat'] = lat;
result['lng'] = lng;
}
Navigator.of(context).pop(result);
}
Future<void> _selectAndClose(String location) async {
// Looks up pincode + coordinates from the database for the given city name.
final match = _locationDb.cast<_LocationItem?>().firstWhere(
(loc) => loc != null && (loc.city.toLowerCase() == location.toLowerCase() ||
loc.displayTitle.toLowerCase() == location.toLowerCase()),
orElse: () => null,
);
if (match != null) {
_selectWithPincode(location, pincode: match.pincode, lat: match.lat, lng: match.lng);
return;
}
// Fallback: Geocode the location name
setState(() => _isSearching = true);
try {
final placemarksByAddress = await locationFromAddress(location);
if (placemarksByAddress.isNotEmpty) {
final loc = placemarksByAddress.first;
final placemarks = await placemarkFromCoordinates(loc.latitude, loc.longitude);
String label = location;
String? pincode;
if (placemarks.isNotEmpty) {
final p = placemarks.first;
final parts = <String>[];
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
if ((p.subAdministrativeArea ?? '').isNotEmpty && p.subAdministrativeArea != p.locality) parts.add(p.subAdministrativeArea!);
if (parts.isNotEmpty) label = parts.join(', ');
pincode = p.postalCode;
}
if (mounted) {
_selectWithPincode(label, pincode: pincode ?? 'all', lat: loc.latitude, lng: loc.longitude);
}
return;
}
} catch (_) {
// Geocoding failed, proceed with just the text label
} finally {
if (mounted) setState(() => _isSearching = false);
}
_selectWithPincode(location);
}
Future<void> _useCurrentLocation() async {
@@ -128,13 +181,14 @@ class _SearchScreenState extends State<SearchScreen> {
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
Navigator.of(context).pop('Current Location');
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
}
return;
}
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
String label = 'Current Location';
try {
final placemarks = await placemarkFromCoordinates(pos.latitude, pos.longitude);
if (placemarks.isNotEmpty) {
@@ -143,17 +197,24 @@ class _SearchScreenState extends State<SearchScreen> {
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
if (mounted) Navigator.of(context).pop(label);
return;
if (parts.isNotEmpty) label = parts.join(', ');
}
} catch (_) {}
if (mounted) Navigator.of(context).pop('Current Location');
if (mounted) {
// Return lat/lng so home_screen can use haversine filtering
Navigator.of(context).pop(<String, dynamic>{
'label': label,
'pincode': 'all',
'lat': pos.latitude,
'lng': pos.longitude,
});
}
return;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
Navigator.of(context).pop('Current Location');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
}
} finally {
if (mounted) setState(() => _loadingLocation = false);
@@ -237,6 +298,7 @@ class _SearchScreenState extends State<SearchScreen> {
Expanded(
child: TextField(
controller: _ctrl,
enabled: !_isSearching,
decoration: const InputDecoration(
hintText: 'Search city, area or locality',
hintStyle: TextStyle(color: Color(0xFF9CA3AF)),
@@ -256,7 +318,12 @@ class _SearchScreenState extends State<SearchScreen> {
},
),
),
if (_ctrl.text.isNotEmpty)
if (_isSearching)
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
@@ -326,7 +393,7 @@ class _SearchScreenState extends State<SearchScreen> {
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
: null,
onTap: () => _selectAndClose(loc.returnValue),
onTap: () => _selectWithPincode(loc.displayTitle, pincode: loc.pincode, lat: loc.lat, lng: loc.lng),
);
},
),

View File

@@ -15,7 +15,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true;
String _appVersion = '1.6(p)';
String _appVersion = '2.0.4';
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
@override
@@ -314,7 +314,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))),
InkWell(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help (coming soon)'))),
child: Container(
width: 40,
height: 40,
@@ -338,7 +338,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: Icons.person,
title: 'Edit Profile',
subtitle: 'Change username, email or photo',
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (coming soon)'))),
),
const SizedBox(height: 12),
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
@@ -379,7 +379,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
subtitle: 'Demo app',
subtitle: 'Coming Soon',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
),
const SizedBox(height: 24),

View File

@@ -6,19 +6,19 @@ class TicketsBookedScreen extends StatelessWidget {
void _onScannerTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scanner tapped (demo)')),
SnackBar(content: Text('Scanner tapped (coming soon)')),
);
}
void _onWhatsappTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')),
SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
);
}
void _onCallTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Call tapped (demo)')),
SnackBar(content: Text('Call (coming soon)')),
);
}

View File

@@ -0,0 +1,99 @@
// lib/widgets/bouncing_loader.dart
import 'package:flutter/material.dart';
/// Three-dot bouncing loader using Curves.bounceOut.
/// Drop-in replacement for CircularProgressIndicator on full-screen loads.
class BouncingLoader extends StatefulWidget {
final Color? color;
final double dotSize;
final double spacing;
const BouncingLoader({
Key? key,
this.color,
this.dotSize = 8.0,
this.spacing = 6.0,
}) : super(key: key);
@override
State<BouncingLoader> createState() => _BouncingLoaderState();
}
class _BouncingLoaderState extends State<BouncingLoader> with TickerProviderStateMixin {
late final List<AnimationController> _controllers;
late final List<Animation<double>> _animations;
static const _duration = Duration(milliseconds: 600);
static const _staggerDelay = Duration(milliseconds: 200);
@override
void initState() {
super.initState();
_controllers = List.generate(
3,
(i) => AnimationController(vsync: this, duration: _duration),
);
_animations = _controllers.map((c) {
return Tween<double>(begin: 0.0, end: -12.0).animate(
CurvedAnimation(parent: c, curve: Curves.bounceOut),
);
}).toList();
_startWithStagger();
}
void _startWithStagger() async {
for (int i = 0; i < _controllers.length; i++) {
await Future.delayed(i == 0 ? Duration.zero : _staggerDelay);
if (!mounted) return;
_startLoop(i);
}
}
void _startLoop(int index) {
if (!mounted) return;
_controllers[index].forward(from: 0).whenComplete(() {
if (mounted) {
Future.delayed(
Duration(milliseconds: _staggerDelay.inMilliseconds * (_controllers.length - 1)),
() { if (mounted) _startLoop(index); },
);
}
});
}
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final dotColor = widget.color ?? Theme.of(context).colorScheme.primary;
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: widget.spacing / 2),
child: AnimatedBuilder(
animation: _animations[i],
builder: (_, __) => Transform.translate(
offset: Offset(0, _animations[i].value),
child: Container(
width: widget.dotSize,
height: widget.dotSize,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
),
),
);
}),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class DesktopTopBar extends StatelessWidget {
@@ -108,7 +109,11 @@ class DesktopTopBar extends StatelessWidget {
return CircleAvatar(
radius: 20,
backgroundColor: Colors.grey.shade200,
backgroundImage: NetworkImage(url),
backgroundImage: CachedNetworkImageProvider(
url,
maxWidth: 80,
maxHeight: 80,
),
onBackgroundImageError: (_, __) {},
);
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
void showEventifyBottomSheet(
BuildContext context, {
required String title,
required Widget child,
double initialSize = 0.5,
double minSize = 0.3,
double maxSize = 0.9,
bool isDismissible = true,
}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: isDismissible,
backgroundColor: Colors.transparent,
builder: (_) => DraggableScrollableSheet(
initialChildSize: initialSize,
minChildSize: minSize,
maxChildSize: maxSize,
expand: false,
builder: (_, scrollController) => _EventifyBottomSheetContent(
title: title,
child: child,
scrollController: scrollController,
),
),
);
}
class _EventifyBottomSheetContent extends StatelessWidget {
const _EventifyBottomSheetContent({
required this.title,
required this.child,
required this.scrollController,
});
final String title;
final Widget child;
final ScrollController scrollController;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color(0xFF0F172A),
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white54),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(color: Colors.white12, height: 1),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
child: child,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:ui';
import 'package:flutter/material.dart';
class GlassCard extends StatelessWidget {
const GlassCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.borderRadius = 16,
this.blur = 10,
this.backgroundColor,
this.borderColor,
});
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final double borderRadius;
final double blur;
final Color? backgroundColor;
final Color? borderColor;
@override
Widget build(BuildContext context) {
final effectiveBackground =
backgroundColor ?? const Color(0xFF1E293B).withOpacity(0.6);
final effectiveBorder =
borderColor ?? Colors.white.withOpacity(0.08);
Widget card = ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: effectiveBackground,
borderRadius: BorderRadius.circular(borderRadius),
border: Border.all(color: effectiveBorder, width: 1),
),
child: child,
),
),
);
if (margin != null) {
return Container(margin: margin, child: card);
}
return card;
}
}

View File

@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
/// Generic shimmer rectangle with configurable dimensions and border radius.
class SkeletonBox extends StatelessWidget {
final double width;
final double height;
final double borderRadius;
const SkeletonBox({
Key? key,
this.width = double.infinity,
required this.height,
this.borderRadius = 8,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Shimmer.fromColors(
baseColor: isDark ? const Color(0xFF2D2D2D) : Colors.grey[300]!,
highlightColor: isDark ? const Color(0xFF3D3D3D) : Colors.grey[100]!,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(borderRadius),
),
),
);
}
}
/// Shimmer placeholder for a compact event card (used in horizontal lists).
class EventCardSkeleton extends StatelessWidget {
const EventCardSkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
width: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
SkeletonBox(height: 140, borderRadius: 12),
SizedBox(height: 8),
SkeletonBox(height: 14, width: 160),
SizedBox(height: 6),
SkeletonBox(height: 12, width: 100),
],
),
);
}
}
/// Shimmer placeholder for a full-width event list row.
class EventListSkeleton extends StatelessWidget {
const EventListSkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
child: Row(
children: const [
SkeletonBox(width: 64, height: 64, borderRadius: 10),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(height: 14),
SizedBox(height: 8),
SkeletonBox(height: 12, width: 140),
],
),
),
],
),
);
}
}
/// Shimmer placeholder for hero carousel area.
class HeroCarouselSkeleton extends StatelessWidget {
const HeroCarouselSkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonBox(height: 320, borderRadius: 24),
);
}
}
/// Shimmer grid for achievements tab.
class AchievementGridSkeleton extends StatelessWidget {
const AchievementGridSkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
padding: const EdgeInsets.all(16),
children: List.generate(4, (_) => const SkeletonBox(height: 160, borderRadius: 16)),
);
}
}
/// Shimmer placeholder for profile stat cards row.
class ProfileStatsSkeleton extends StatelessWidget {
const ProfileStatsSkeleton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: List.generate(3, (_) => const Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: SkeletonBox(height: 80, borderRadius: 12),
),
)),
),
);
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
class TierAvatarRing extends StatelessWidget {
final String username;
final String tier;
final double size;
final bool showDiceBear;
final String? imageUrl;
final VoidCallback? onTap;
static const Map<String, Color> _tierColors = {
'Bronze': Color(0xFFFED7AA),
'Silver': Color(0xFFE2E8F0),
'Gold': Color(0xFFFEF3C7),
'Platinum': Color(0xFFEDE9FE),
'Diamond': Color(0xFFE0E7FF),
};
static const Color _fallbackColor = Color(0xFF475569);
const TierAvatarRing({
super.key,
required this.username,
required this.tier,
this.size = 48.0,
this.showDiceBear = true,
this.imageUrl,
this.onTap,
});
Color get _ringColor => _tierColors[tier] ?? _fallbackColor;
String get _avatarUrl {
if (imageUrl != null && imageUrl!.isNotEmpty) {
return imageUrl!;
}
return 'https://api.dicebear.com/9.x/notionists/svg?seed=$username';
}
Widget _buildAvatar() {
final double radius = size / 2 - 5;
if (!showDiceBear) {
return CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: Icon(
Icons.person,
color: Colors.white54,
size: size * 0.5,
),
);
}
return CachedNetworkImage(
imageUrl: _avatarUrl,
memCacheWidth: (size * 2).round(),
memCacheHeight: (size * 2).round(),
maxWidthDiskCache: (size * 4).round(),
maxHeightDiskCache: (size * 4).round(),
imageBuilder: (context, imageProvider) => CircleAvatar(
radius: radius,
backgroundImage: imageProvider,
),
placeholder: (context, url) => CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: SizedBox(
width: size * 0.4,
height: size * 0.4,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white38,
),
),
),
errorWidget: (context, url, error) => CircleAvatar(
radius: radius,
backgroundColor: const Color(0xFF1E293B),
child: Icon(
Icons.person,
color: Colors.white54,
size: size * 0.5,
),
),
);
}
@override
Widget build(BuildContext context) {
final Color ringColor = _ringColor;
final double containerSize = size + 6;
final Widget ring = Container(
width: containerSize,
height: containerSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: ringColor, width: 3),
boxShadow: [
BoxShadow(
color: ringColor.withOpacity(0.4),
blurRadius: 8,
spreadRadius: 1,
),
],
),
child: Center(child: _buildAvatar()),
);
if (onTap != null) {
return GestureDetector(
onTap: onTap,
child: ring,
);
}
return ring;
}
}

View File

@@ -7,6 +7,7 @@ import Foundation
import file_selector_macos
import geolocator_apple
import google_sign_in_ios
import path_provider_foundation
import share_plus
import shared_preferences_foundation
@@ -17,6 +18,7 @@ import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -137,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
eventify:
dependency: transitive
description:
name: eventify
sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66
url: "https://pub.dev"
source: hosted
version: "1.0.1"
fake_async:
dependency: transitive
description:
@@ -238,6 +246,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.30"
flutter_staggered_animations:
dependency: "direct main"
description:
name: flutter_staggered_animations
sha256: "81d3c816c9bb0dca9e8a5d5454610e21ffb068aedb2bde49d2f8d04f75538351"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter_svg:
dependency: "direct main"
description:
@@ -336,6 +352,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.3"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_maps:
dependency: transitive
description:
@@ -384,6 +408,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.14+3"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
url: "https://pub.dev"
source: hosted
version: "6.2.1"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
url: "https://pub.dev"
source: hosted
version: "5.9.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
url: "https://pub.dev"
source: hosted
version: "0.12.4+4"
html:
dependency: transitive
description:
@@ -393,7 +457,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
@@ -585,7 +649,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -672,6 +736,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
razorpay_flutter:
dependency: "direct main"
description:
name: razorpay_flutter
sha256: "8d985b769808cb6c8d3f2fbcc25f9ab78e29191965c31c98e2d69d55d9d20ff1"
url: "https://pub.dev"
source: hosted
version: "1.4.3"
rxdart:
dependency: transitive
description:
@@ -760,6 +832,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
simple_gesture_detector:
dependency: transitive
description:
@@ -793,10 +873,10 @@ packages:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
version: "2.4.2+2"
sqflite_common:
dependency: transitive
description:
@@ -1009,10 +1089,10 @@ packages:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
version: "2.10.1"
video_player_android:
dependency: transitive
description:
@@ -1025,10 +1105,10 @@ packages:
dependency: transitive
description:
name: video_player_avfoundation
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
url: "https://pub.dev"
source: hosted
version: "2.9.4"
version: "2.8.9"
video_player_platform_interface:
dependency: transitive
description:
@@ -1094,5 +1174,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"

View File

@@ -1,7 +1,7 @@
name: figma
description: A Flutter event app
publish_to: 'none'
version: 1.6.1+17
version: 2.0.4+24
environment:
sdk: ">=2.17.0 <3.0.0"
@@ -19,9 +19,15 @@ dependencies:
google_maps_flutter: ^2.5.0
url_launcher: ^6.2.1
share_plus: ^7.2.1
path_provider: ^2.1.0
provider: ^6.1.2
video_player: ^2.8.1
cached_network_image: ^3.3.1
razorpay_flutter: ^1.3.7
google_sign_in: ^6.2.2
http: ^1.2.0
shimmer: ^3.0.0
flutter_staggered_animations: ^1.1.1
dev_dependencies:
flutter_test:
@@ -35,6 +41,7 @@ flutter:
- assets/images/
- assets/icon/hand_stop.svg
- assets/login-bg.mp4
- assets/data/kerala_pincodes.json
fonts:
- family: Gilroy
fonts: