Compare commits

..

40 Commits

Author SHA1 Message Date
1bb25b026c chore: remove _notes and .obsidian from git tracking
- Untrack _notes/ vault and .obsidian/ config
- Update .gitignore to exclude both permanently

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:05:39 +05:30
924c80da00 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 22:04:06 +05:30
d275324205 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
7b9873a80a 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:10:07 +05:30
195509abb6 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)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:56:40 +05:30
6c2efbccc6 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:45:19 +05:30
34d6586afa 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:43:02 +05:30
692f96bfce feat: UX-005 — Hero transitions, fade screen load, AnimatedList leaderboard stagger 2026-04-04 17:49:37 +05:30
ac95a44a07 feat: UX-002 — BouncingLoader widget replacing CircularProgressIndicator in key screens 2026-04-04 17:41:57 +05:30
9676ede50b feat: REV-004 — spring elasticOut animation on review submit success 2026-04-04 17:39:32 +05:30
c6121d7754 feat: REV-003 — stagger slide/fade animations on review list 2026-04-04 17:38:39 +05:30
b4c4c4bd53 feat: REV-001 — DiceBear Notionists avatars on review cards 2026-04-04 17:35:29 +05:30
64e7323213 feat: HOME-007 — server-side event title/description search (q param) 2026-04-04 17:33:56 +05:30
632754415d 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 17:17:36 +05:30
fe8af7cfe6 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
29e326b8fc 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:46:53 +05:30
847577c09d 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
e63e9daa0c 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
605c9277a5 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
cd7864f4fb 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
760fc920fc 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
7df6bb6c56 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
2366d25478 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
d2b49d4eb5 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
b55f02e057 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
87cc56dc64 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
6503d9bc1b 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
dd7268cd98 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
04af387945 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
cac2671fd6 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
cf21e0a58c 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
a26b7544f5 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 12:03:13 +05:30
9dcd5bae16 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:16:38 +05:30
48f143399d 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 17:00:25 +05:30
378d054dc4 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:43:40 +05:30
f98e0fe617 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:39:48 +05:30
e8e2e7ac28 chore: bump version to 1.5.0+15
versionCode: 15, versionName: 1.5(p)
Includes all performance fixes from previous commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:31:40 +05:30
ee0151efe5 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:28:32 +05:30
5b373e8694 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()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:39:42 +05:30
97245e01c4 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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:10:56 +05:30
36 changed files with 1308 additions and 4130 deletions

View File

@@ -6,50 +6,6 @@ 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.

Binary file not shown.

View File

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

View File

@@ -26,62 +26,3 @@
-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,18 +18,6 @@
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"
@@ -43,6 +31,11 @@
<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

@@ -1,80 +0,0 @@
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()
}
}

View File

@@ -47,18 +47,5 @@
<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

@@ -1,15 +1,11 @@
// 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: 25);
static const Duration _retryDelay = Duration(milliseconds: 600);
static const Duration _timeout = Duration(seconds: 10);
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
static const bool _developmentMode = false;
@@ -31,7 +27,13 @@ class ApiClient {
late http.Response response;
try {
response = await _postWithRetry(url, headers, finalBody);
response = await http
.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(finalBody),
)
.timeout(_timeout);
} catch (e) {
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
@@ -97,82 +99,6 @@ 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.
@@ -183,24 +109,21 @@ class ApiClient {
bool requiresAuth = true,
}) async {
// build final query params including auth if needed
final originalUri = Uri.parse(url);
final queryParams = <String, String>{...originalUri.queryParameters};
final Map<String, dynamic> finalParams = {};
if (requiresAuth) {
final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername();
if (token != null && username != null) {
queryParams['token'] = token;
queryParams['username'] = username;
finalParams['token'] = token;
finalParams['username'] = username;
}
// Guest mode: proceed without token — let backend decide
}
if (params != null) {
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
}
if (params != null) finalParams.addAll(params);
final uri = originalUri.replace(queryParameters: queryParams);
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
late http.Response response;
try {
@@ -210,7 +133,7 @@ class ApiClient {
throw Exception('Network error: $e');
}
return _handleResponse(url, response, queryParams);
return _handleResponse(url, response, finalParams);
}
// ---------------------------------------------------------------------------
@@ -227,8 +150,8 @@ class ApiClient {
'end_date': '2026-04-16',
'start_time': '09:00',
'end_time': '18:00',
'pincode': '680001',
'place': 'Thekkinkadu Maidanam',
'pincode': '560001',
'place': 'Bengaluru International Exhibition Centre',
'is_bookable': true,
'event_type': 5,
'thumb_img': 'https://picsum.photos/seed/event1/600/400',
@@ -237,11 +160,11 @@ class ApiClient {
{'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',
'venue_name': 'BIEC Hall 2',
'event_status': 'active',
'latitude': 10.5276,
'longitude': 76.2144,
'location_name': 'Thrissur',
'latitude': 13.0147,
'longitude': 77.5636,
'location_name': 'Bengaluru',
'important_info': [
{'title': 'Entry', 'value': 'Free with registration'},
{'title': 'Parking', 'value': 'Available on-site'},

View File

@@ -2,20 +2,18 @@
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"
// 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";
// For UAT: "https://uat.eventifyplus.com/api"
static const String baseUrl = "https://em.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";
static const String mediaBaseUrl = "https://em.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
@@ -24,8 +22,6 @@ 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/";
@@ -42,9 +38,6 @@ class ApiEndpoints {
// Node.js gamification server (same host as reviews)
static const String _nodeBase = "https://app.eventifyplus.com/api";
// File upload (Node.js — routes to OneDrive or GDrive via STORAGE_BACKEND env)
static const String uploadFile = "$_nodeBase/v1/upload/file";
// Gamification / Contributor Module
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
@@ -65,5 +58,5 @@ class ApiEndpoints {
// Notifications
static const String notificationList = "$baseUrl/notifications/list/";
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
static const String notificationCount = "$baseUrl/notifications/count";
static const String notificationCount = "$baseUrl/notifications/count/";
}

View File

@@ -12,13 +12,6 @@ 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 {
@@ -67,18 +60,6 @@ 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',
@@ -165,10 +146,7 @@ class AuthService {
/// GOOGLE OAUTH LOGIN → returns UserModel
Future<UserModel> googleLogin() async {
try {
final googleSignIn = GoogleSignIn(
scopes: const ['email', 'profile'],
serverClientId: _googleWebClientId,
);
final googleSignIn = GoogleSignIn(scopes: ['email']);
final account = await googleSignIn.signIn();
if (account == null) throw Exception('Google sign-in cancelled');
@@ -200,18 +178,6 @@ class AuthService {
}
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
// Save profile photo from Google login response
final rawPhoto = res['profile_photo']?.toString() ?? '';
if (rawPhoto.isNotEmpty) {
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
await prefs.setString('profileImage_$serverEmail', photoUrl);
await prefs.setString('profileImage', photoUrl);
}
// Save Eventify ID
final eventifyId = res['eventify_id']?.toString() ?? '';
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
PostHogService.instance.identify(serverEmail, properties: {
'username': displayName,
'login_method': 'google',
@@ -225,16 +191,6 @@ class AuthService {
}
}
/// 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 {

View File

@@ -77,10 +77,6 @@ class EventModel {
final String? contributorName;
final String? contributorTier;
// Curation flags
final bool isFeatured;
final bool isTopEvent;
EventModel({
required this.id,
required this.name,
@@ -109,8 +105,6 @@ class EventModel {
this.contributorId,
this.contributorName,
this.contributorTier,
this.isFeatured = false,
this.isTopEvent = false,
});
/// Safely parse a double from backend (may arrive as String or num)
@@ -173,8 +167,6 @@ class EventModel {
contributorId: j['contributor_id']?.toString(),
contributorName: j['contributor_name'] as String?,
contributorTier: j['contributor_tier'] as String?,
isFeatured: j['is_featured'] == true || j['is_featured']?.toString().toLowerCase() == 'true',
isTopEvent: j['is_top_event'] == true || j['is_top_event']?.toString().toLowerCase() == 'true',
);
}
}

View File

@@ -109,11 +109,15 @@ class EventsService {
}
/// Get events by GPS coordinates using haversine distance filtering.
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
/// Automatically expands radius (10 → 25 → 50 → 100 km) until ≥ 6 events found.
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double initialRadiusKm = 10}) async {
const radii = [10.0, 25.0, 50.0, 100.0];
for (final radius in radii) {
if (radius < initialRadiusKm) continue;
final body = {
'latitude': lat,
'longitude': lng,
'radius_km': radiusKm,
'radius_km': radius,
'page': 1,
'page_size': 50,
'per_type': 5,
@@ -126,31 +130,7 @@ class EventsService {
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
}
}
return list;
}
/// Featured events for the home screen hero carousel.
Future<List<EventModel>> getFeaturedEvents() async {
final res = await _api.post(ApiEndpoints.featuredEvents, requiresAuth: false);
final events = res['events'] ?? res['data'] ?? [];
if (events is List) {
return events
.whereType<Map<String, dynamic>>()
.map((e) => EventModel.fromJson(e))
.toList();
}
return [];
}
/// Top events for the home screen top events section.
Future<List<EventModel>> getTopEvents() async {
final res = await _api.post(ApiEndpoints.topEvents, requiresAuth: false);
final events = res['events'] ?? res['data'] ?? [];
if (events is List) {
return events
.whereType<Map<String, dynamic>>()
.map((e) => EventModel.fromJson(e))
.toList();
if (list.length >= 6 || radius >= 100) return list;
}
return [];
}

View File

@@ -1,8 +1,6 @@
// 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)
// ---------------------------------------------------------------------------
@@ -70,10 +68,6 @@ int tierStartEp(ContributorTier tier) {
// ---------------------------------------------------------------------------
class UserGamificationProfile {
final String userId;
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.
@@ -81,10 +75,6 @@ class UserGamificationProfile {
const UserGamificationProfile({
required this.userId,
required this.username,
this.avatarUrl,
this.district,
this.eventifyId,
required this.lifetimeEp,
required this.currentEp,
required this.currentRp,
@@ -92,17 +82,12 @@ class UserGamificationProfile {
});
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
debugPrint('Mapping UserGamificationProfile from JSON: $json');
final ep = (json['lifetime_ep'] as int?) ?? (json['points'] as int?) ?? (json['total_points'] as int?) ?? 0;
final ep = (json['lifetime_ep'] as int?) ?? 0;
return UserGamificationProfile(
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(),
userId: json['user_id'] as String? ?? '',
lifetimeEp: ep,
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,
currentEp: (json['current_ep'] as int?) ?? 0,
currentRp: (json['current_rp'] as int?) ?? 0,
tier: tierFromEp(ep),
);
}

View File

@@ -4,8 +4,6 @@ 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();
@@ -14,18 +12,16 @@ class GamificationProvider extends ChangeNotifier {
UserGamificationProfile? profile;
List<LeaderboardEntry> leaderboard = [];
List<ShopItem> shopItems = [];
List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
List<AchievementBadge> achievements = [];
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
@@ -36,10 +32,8 @@ class GamificationProvider extends ChangeNotifier {
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
// ---------------------------------------------------------------------------
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');
// Skip if recently loaded (within 2 minutes) unless forced
if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
return;
}
@@ -48,37 +42,11 @@ class GamificationProvider extends ChangeNotifier {
notifyListeners();
try {
debugPrint('GamificationProvider: Requesting dashboard, leaderboard, etc...');
final results = await Future.wait([
_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>[];
}),
_service.getDashboard(),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
_service.getShopItems(),
_service.getAchievements(),
]);
final dashboard = results[0] as DashboardResponse;
@@ -86,27 +54,16 @@ class GamificationProvider extends ChangeNotifier {
submissions = dashboard.submissions;
final lbResponse = results[1] as LeaderboardResponse;
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
leaderboard = lbResponse.entries;
currentUserStats = lbResponse.currentUser;
totalParticipants = lbResponse.totalParticipants;
shopItems = results[2] as List<ShopItem>;
// Prefer achievements from dashboard API; fall back to fetched or existing defaults
// Prefer achievements from dashboard API; fall back to getAchievements()
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
achievements = dashAchievements.isNotEmpty ? dashAchievements : fetchedAchievements;
_lastLoadTime = DateTime.now();
} catch (e) {
@@ -117,45 +74,20 @@ class GamificationProvider extends ChangeNotifier {
}
}
// ---------------------------------------------------------------------------
// 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 {
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
leaderboard = response.entries;
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
}
notifyListeners();
}
@@ -166,17 +98,14 @@ class GamificationProvider extends ChangeNotifier {
Future<void> setTimePeriod(String period) async {
if (leaderboardTimePeriod == period) return;
leaderboardTimePeriod = period;
isLeaderboardLoading = true;
notifyListeners();
try {
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
leaderboard = response.entries;
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
}
notifyListeners();
}
@@ -191,10 +120,6 @@ 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,
@@ -211,10 +136,6 @@ 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,
@@ -232,41 +153,4 @@ 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

@@ -23,7 +23,7 @@ class GamificationService {
// ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboard() async {
final email = await _getUserEmail();
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(email)}';
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$email';
final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
@@ -50,7 +50,7 @@ class GamificationService {
// GET /v1/gamification/dashboard?user_id={userId}
// ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboardForUser(String userId) async {
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(userId)}';
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$userId';
final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
@@ -152,28 +152,11 @@ class GamificationService {
// ---------------------------------------------------------------------------
// Submit Contribution
// 1. Upload each image to /v1/upload/file → get back { url, fileId, ... }
// 2. POST /v1/gamification/submit-event with `media` (uploaded objects)
// POST /v1/gamification/submit-event body: event data
// ---------------------------------------------------------------------------
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,
};
final body = <String, dynamic>{'user_id': email, ...data};
await _api.post(
ApiEndpoints.contributeSubmit,
body: body,
@@ -192,17 +175,20 @@ class GamificationService {
} catch (_) {
// Fall through to defaults
}
return defaultBadges;
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),
static const _defaultBadges = [
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-07', title: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-08', title: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
];
}

View File

@@ -125,10 +125,6 @@ class _ReviewCardState extends State<ReviewCard> {
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),

View File

@@ -1,547 +0,0 @@
// 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

@@ -0,0 +1,197 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../widgets/tier_avatar_ring.dart';
class ShareRankCard extends StatefulWidget {
final String username;
final String tier;
final int rank;
final int ep;
final int rewardPoints;
const ShareRankCard({
super.key,
required this.username,
required this.tier,
required this.rank,
required this.ep,
this.rewardPoints = 0,
});
@override
State<ShareRankCard> createState() => _ShareRankCardState();
}
class _ShareRankCardState extends State<ShareRankCard> {
final GlobalKey _boundaryKey = GlobalKey();
bool _sharing = false;
static const _tierGradients = {
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
};
List<Color> get _gradient {
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
}
Future<void> _share() async {
if (_sharing) return;
setState(() => _sharing = true);
try {
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3.0);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) return;
final bytes = byteData.buffer.asUint8List();
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
await file.writeAsBytes(bytes);
await Share.shareXFiles(
[XFile(file.path)],
text: 'I\'m ranked #${widget.rank} on Eventify with ${widget.ep} EP! 🏆 #Eventify #Kerala',
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not share rank card')),
);
}
} finally {
if (mounted) setState(() => _sharing = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
RepaintBoundary(
key: _boundaryKey,
child: Container(
width: 320,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Tier gradient header bar
Container(
height: 6,
decoration: BoxDecoration(
gradient: LinearGradient(colors: _gradient),
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(height: 20),
// Avatar
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
const SizedBox(height: 12),
// Username
Text(
widget.username,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
),
const SizedBox(height: 4),
// Tier badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
gradient: LinearGradient(colors: _gradient),
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.tier.isEmpty ? 'Contributor' : widget.tier,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
),
),
const SizedBox(height: 20),
// Stats row
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_stat('Rank', '#${widget.rank}'),
Container(width: 1, height: 40, color: Colors.white12),
_stat('EP', '${widget.ep}'),
Container(width: 1, height: 40, color: Colors.white12),
_stat('RP', '${widget.rewardPoints}'),
],
),
const SizedBox(height: 20),
// Branding
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
SizedBox(width: 4),
Text(
'EVENTIFY',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w900,
color: Color(0xFF3B82F6),
letterSpacing: 2,
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _sharing ? null : _share,
icon: _sharing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.share, size: 18),
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1D4ED8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
],
);
}
Widget _stat(String label, String value) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
),
],
);
}
}

View File

@@ -58,7 +58,7 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
if (!_booked) {
setState(() => _booked = true);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Tickets booked (coming soon)')),
const SnackBar(content: Text('Tickets booked (demo)')),
);
}
}
@@ -220,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 (coming soon)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner tapped (demo)')));
}),
SizedBox(width: 12),
_iconSquare(primary, Icons.chat, onTap: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat (coming soon)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat tapped (demo)')));
}),
SizedBox(width: 12),
_iconSquare(primary, Icons.call, onTap: () {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call (coming soon)')));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call tapped (demo)')));
}),
],
);

View File

@@ -519,8 +519,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
imageUrl: imgUrl,
memCacheWidth: 400,
memCacheHeight: 300,
maxWidthDiskCache: 800,
maxHeightDiskCache: 600,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
@@ -584,8 +582,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
imageUrl: imgUrl,
memCacheWidth: 300,
memCacheHeight: 300,
maxWidthDiskCache: 600,
maxHeightDiskCache: 600,
width: 100,
height: 100,
fit: BoxFit.cover,
@@ -842,7 +838,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
Positioned(
right: 0,
child: InkWell(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (coming soon)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
child: Container(
width: 40,
height: 40,

View File

@@ -3,7 +3,6 @@
// 3 tabs: My Events · Submit Event · Reward Shop
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -12,7 +11,6 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import '../core/auth/auth_guard.dart';
import '../core/utils/error_utils.dart';
import '../features/gamification/models/gamification_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
@@ -46,7 +44,7 @@ const _districts = [
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', 'Other',
];
const _categories_fallback = [
const _categories = [
'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
];
@@ -90,7 +88,7 @@ class _ContributeScreenState extends State<ContributeScreen>
DateTime? _selectedDate;
TimeOfDay? _selectedTime;
String _selectedCategory = 'Music';
String _selectedCategory = _categories.first;
String _selectedDistrict = _districts.first;
List<XFile> _images = [];
bool _submitting = false;
@@ -106,12 +104,7 @@ class _ContributeScreenState extends State<ContributeScreen>
super.initState();
PostHogService.instance.screen('Contribute');
WidgetsBinding.instance.addPostFrameCallback((_) {
// Gamification endpoints are authed — guests would hit 401 and pollute logs.
// AuthGuard.requireLogin prompts guests when they tap any gated action.
if (AuthGuard.isGuest) return;
final p = context.read<GamificationProvider>();
p.loadAll();
p.loadLeaderboard(); // independent — always fires regardless of loadAll TTL
context.read<GamificationProvider>().loadAll();
});
}
@@ -128,63 +121,26 @@ class _ContributeScreenState extends State<ContributeScreen>
// ─────────────────────────────────────────────────────────────────────────
// Build
// ─────────────────────────────────────────────────────────────────────────
int _mainTab = 0; // 0: Contribute, 1: Leaderboard, 2: Achievements
@override
@override
Widget build(BuildContext context) {
return Consumer<GamificationProvider>(
builder: (context, provider, _) {
if (provider.isLoading && provider.profile == null) {
return const Scaffold(
backgroundColor: _blue,
body: Center(child: BouncingLoader(color: Colors.white)),
backgroundColor: _pageBg,
body: Center(child: BouncingLoader(color: _blue)),
);
}
// Sync _selectedCategory with provider data if it's missing from current list
if (provider.eventCategories.isNotEmpty && !provider.eventCategories.contains(_selectedCategory)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _selectedCategory = provider.eventCategories.first);
});
}
return Scaffold(
backgroundColor: Colors.white, // Changed from _blue
backgroundColor: _pageBg,
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(provider),
Transform.translate(
offset: const Offset(0, -24),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_mainTab == 0) ...[
_buildStatsBar(provider),
_buildTierRoadmap(provider),
const SizedBox(height: 12),
_buildTabBar(),
_buildTabContent(provider),
] else if (_mainTab == 1) ...[
_buildLeaderboardTab(provider),
] else if (_mainTab == 2) ...[
_buildAchievementsTab(provider),
Expanded(child: _buildTabContent(provider)),
],
const SizedBox(height: 100),
],
),
),
),
],
),
),
),
);
@@ -192,561 +148,6 @@ class _ContributeScreenState extends State<ContributeScreen>
);
}
// ═══════════════════════════════════════════════════════════════════════════
// LEADERBOARD TAB
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildLeaderboardTab(GamificationProvider provider) {
final leaderboard = provider.leaderboard;
final currentPeriod = provider.leaderboardTimePeriod;
final currentDistrict = provider.leaderboardDistrict;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
// Time Period Toggle
Center(
child: Container(
height: 48,
width: 300,
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(24),
),
child: Stack(
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
alignment: currentPeriod == 'all_time' ? Alignment.centerLeft : Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.all(4),
child: Container(
width: 144,
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(20),
),
),
),
),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => provider.setTimePeriod('all_time'),
behavior: HitTestBehavior.opaque,
child: Center(
child: Text(
'All Time',
style: TextStyle(
color: currentPeriod == 'all_time' ? Colors.white : _subText,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => provider.setTimePeriod('this_month'),
behavior: HitTestBehavior.opaque,
child: Center(
child: Text(
'This Month',
style: TextStyle(
color: currentPeriod == 'this_month' ? Colors.white : _subText,
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
),
),
],
),
],
),
),
),
const SizedBox(height: 20),
// District Chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
_buildDistrictChip(provider, 'Overall Kerala'),
..._districts.where((d) => d != 'Other').map((d) => _buildDistrictChip(provider, d)),
],
),
),
const SizedBox(height: 16),
// Leaderboard List
if (provider.isLeaderboardLoading && leaderboard.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
)
else if (leaderboard.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 60),
child: Center(
child: Column(
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.emoji_events_rounded, size: 72, color: Colors.amber),
),
const SizedBox(height: 24),
const Text(
'No Contributor Yet',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: _darkText,
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
'No contributors in $currentDistrict yet. Be the first to join the ranks!',
textAlign: TextAlign.center,
style: const TextStyle(
color: _subText,
fontSize: 15,
height: 1.5,
),
),
),
],
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 20),
itemCount: leaderboard.length,
separatorBuilder: (_, __) => Divider(height: 1, color: _border.withValues(alpha: 0.5)),
itemBuilder: (context, index) {
final entry = leaderboard[index];
return _buildLeaderboardTile(entry);
},
),
const SizedBox(height: 100), // Bottom padding
],
);
}
Widget _buildDistrictChip(GamificationProvider provider, String district) {
final isSelected = provider.leaderboardDistrict == district;
return GestureDetector(
onTap: () => provider.setDistrict(district),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? _blue : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: isSelected ? _blue : _border),
),
child: Text(
district,
style: TextStyle(
color: isSelected ? Colors.white : _darkText,
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
}
Widget _buildLeaderboardTile(LeaderboardEntry entry) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
SizedBox(
width: 32,
child: Text(
'${entry.rank}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: entry.rank <= 3 ? _blue : _subText,
),
),
),
const SizedBox(width: 8),
CircleAvatar(
radius: 20,
backgroundColor: _lightBlueBg,
backgroundImage: entry.avatarUrl != null ? CachedNetworkImageProvider(entry.avatarUrl!, maxWidth: 80, maxHeight: 80) : null,
child: entry.avatarUrl == null
? const Icon(Icons.person_outline, color: _blue, size: 20)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Text(
entry.username,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.normal, color: _darkText),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
'${entry.lifetimeEp} pts',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF10B981), // Emerald green
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// ACHIEVEMENTS TAB
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildAchievementsTab(GamificationProvider provider) {
final achievements = provider.achievements;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (achievements.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: Center(
child: Text('No achievements found.', style: TextStyle(color: _subText)),
),
)
else
Column(
children: achievements.map((badge) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _buildAchievementCard(badge),
);
}).toList(),
),
const SizedBox(height: 100),
],
),
);
}
Widget _buildAchievementCard(AchievementBadge badge) {
final bool isLocked = !badge.isUnlocked;
final Color iconColor = _getAchievementColor(badge.iconName);
final IconData iconData = _getAchievementIcon(badge.iconName);
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: _border.withValues(alpha: 0.8)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Large Icon Container
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: isLocked ? Colors.grey.shade100 : iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
isLocked ? Icons.lock_outline : iconData,
color: isLocked ? Colors.grey.shade400 : iconColor,
size: 32,
),
),
const SizedBox(height: 20),
// Title with Lock Icon if needed
Row(
children: [
Text(
badge.title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: isLocked ? Colors.grey : _darkText,
),
),
if (isLocked) ...[
const SizedBox(width: 8),
Icon(Icons.lock_outline, size: 18, color: Colors.grey.shade300),
],
],
),
const SizedBox(height: 6),
// Description
Text(
badge.description,
style: TextStyle(
fontSize: 14,
color: isLocked ? Colors.grey.shade400 : _subText,
height: 1.4,
),
),
// Progress Section
if (!badge.isUnlocked && badge.progress > 0) ...[
const SizedBox(height: 24),
Stack(
children: [
Container(
height: 6,
width: double.infinity,
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(3),
),
),
FractionallySizedBox(
widthFactor: badge.progress,
child: Container(
height: 6,
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(3),
),
),
),
],
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: Text(
'${(badge.progress * 100).toInt()}%',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black26,
),
),
),
],
],
),
);
}
Color _getAchievementColor(String iconName) {
switch (iconName.toLowerCase()) {
case 'star': return const Color(0xFF3B82F6); // Blue
case 'crown': return const Color(0xFFF59E0B); // Amber
case 'fire': return const Color(0xFFEF4444); // Red
case 'verified': return const Color(0xFF10B981); // Emerald
case 'community': return const Color(0xFF8B5CF6); // Purple
case 'expert': return const Color(0xFF6366F1); // Indigo
default: return _blue;
}
}
IconData _getAchievementIcon(String iconName) {
switch (iconName.toLowerCase()) {
case 'star': return Icons.star_rounded;
case 'crown': return Icons.emoji_events_rounded;
case 'fire': return Icons.local_fire_department_rounded;
case 'verified': return Icons.verified_rounded;
case 'community': return Icons.people_alt_rounded;
case 'expert': return Icons.workspace_premium_rounded;
case 'precision': return Icons.gps_fixed_rounded;
default: return Icons.stars_rounded;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// NEW BLUE HEADER DESIGN
// ═══════════════════════════════════════════════════════════════════════════
Widget _buildHeader(GamificationProvider provider) {
return Container(
width: double.infinity,
color: _blue,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), // Increased bottom padding
child: Column(
children: [
const Text(
'Contributor Dashboard',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.normal),
),
const SizedBox(height: 6),
const Text(
'Track your impact, earn rewards, and climb\nthe ranks!',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 14, height: 1.4),
),
const SizedBox(height: 24),
_buildMainTabGlider(),
const SizedBox(height: 20),
_buildContributorLevelCard(provider),
],
),
),
);
}
Widget _buildMainTabGlider() {
const labels = ['Contribute', 'Leaderboard', 'Achievements'];
return Container(
height: 48,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
),
child: LayoutBuilder(
builder: (context, constraints) {
final tabWidth = constraints.maxWidth / 3;
return Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: tabWidth * _mainTab,
top: 0,
bottom: 0,
child: Container(
width: tabWidth,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2)),
],
),
),
),
Row(
children: List.generate(3, (i) {
final active = _mainTab == i;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _mainTab = i),
behavior: HitTestBehavior.opaque,
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (i == 0) ...[
Icon(Icons.edit_square, size: 16, color: active ? _blue : Colors.white),
const SizedBox(width: 6),
],
Text(
labels[i],
style: TextStyle(
color: active ? _blue : Colors.white,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
],
),
),
),
);
}),
),
],
);
},
),
);
}
Widget _buildContributorLevelCard(GamificationProvider provider) {
final profile = provider.profile;
final tier = profile?.tier ?? ContributorTier.BRONZE;
final currentEp = profile?.lifetimeEp ?? 0;
int nextThreshold = _tierThresholds.last;
String nextTierLabel = 'Max';
for (int i = 0; i < ContributorTier.values.length; i++) {
if (currentEp < _tierThresholds[i]) {
nextThreshold = _tierThresholds[i];
nextTierLabel = tierLabel(ContributorTier.values[i]);
break;
}
}
double progress = (currentEp / nextThreshold).clamp(0.0, 1.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.12),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Contributor Level', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.normal)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(color: Colors.white.withOpacity(0.25), borderRadius: BorderRadius.circular(20)),
child: Text(tierLabel(tier), style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600)),
),
],
),
const SizedBox(height: 8),
Text('Start earning rewards by\ncontributing!', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 14)),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$currentEp pts', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.normal, fontSize: 13)),
Text('Next: $nextTierLabel ($nextThreshold pts)', style: TextStyle(color: Colors.white.withOpacity(0.8), fontSize: 12)),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: Colors.white.withOpacity(0.2),
valueColor: const AlwaysStoppedAnimation(Colors.white),
minHeight: 8,
),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════════════════════
// 1. COMPACT STATS BAR
// ═══════════════════════════════════════════════════════════════════════════
@@ -757,14 +158,12 @@ class _ContributeScreenState extends State<ContributeScreen>
final tierIcon = _tierIcons[tier] ?? Icons.shield_outlined;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Row(
children: [
// Tier pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _blue,
borderRadius: BorderRadius.circular(20),
@@ -772,49 +171,51 @@ class _ContributeScreenState extends State<ContributeScreen>
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(tierIcon, color: Colors.white, size: 14),
Icon(tierIcon, color: tierColor, size: 16),
const SizedBox(width: 6),
Text(
tierLabel(tier).toUpperCase(),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.normal, fontSize: 13, letterSpacing: 0.5),
tierLabel(tier),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
),
],
),
),
const SizedBox(width: 16),
const SizedBox(width: 12),
// Liquid EP
Icon(Icons.bolt_outlined, color: _blue, size: 18),
Icon(Icons.bolt, color: _blue, size: 18),
const SizedBox(width: 4),
Text('${profile?.currentEp ?? 0}', style: const TextStyle(color: _darkText, fontWeight: FontWeight.normal, fontSize: 15)),
Text(
'${profile?.currentEp ?? 0}',
style: const TextStyle(color: _darkText, fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(width: 4),
const Text('Liquid EP', style: TextStyle(color: _subText, fontSize: 12)),
const Text('EP', style: TextStyle(color: _subText, fontSize: 12)),
const SizedBox(width: 16),
// RP
Icon(Icons.card_giftcard_outlined, color: _rpOrange, size: 18),
Icon(Icons.card_giftcard, color: _rpOrange, size: 18),
const SizedBox(width: 4),
Text('${profile?.currentRp ?? 0}', style: TextStyle(color: _rpOrange, fontWeight: FontWeight.normal, fontSize: 15)),
Text(
'${profile?.currentRp ?? 0}',
style: TextStyle(color: _rpOrange, fontWeight: FontWeight.w700, fontSize: 15),
),
const SizedBox(width: 4),
const Text('RP', style: TextStyle(color: _subText, fontSize: 12)),
],
),
const SizedBox(height: 16),
// Share Rank button
const Spacer(),
// Share button
GestureDetector(
onTap: () => _shareRank(provider),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: _border),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.ios_share_outlined, color: _blue, size: 16),
const SizedBox(width: 8),
const Text('Share Rank', style: TextStyle(color: _blue, fontWeight: FontWeight.normal, fontSize: 13)),
],
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.share_outlined, color: _subText, size: 18),
),
),
],
@@ -936,7 +337,7 @@ class _ContributeScreenState extends State<ContributeScreen>
left: tabWidth * _activeTab + 4,
top: 4,
child: Container(
width: tabWidth > 8 ? tabWidth - 8 : 0,
width: tabWidth - 8,
height: 44,
decoration: BoxDecoration(
color: _blue,
@@ -962,22 +363,18 @@ class _ContributeScreenState extends State<ContributeScreen>
children: [
Icon(
_tabIcons[i],
size: 16, // Slightly smaller icon
size: 18,
color: isActive ? Colors.white : const Color(0xFF64748B),
),
const SizedBox(width: 4), // Tighter spacing
Flexible(
child: Text(
const SizedBox(width: 6),
Text(
_tabLabels[i],
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 11, // Smaller font for better fit
fontSize: 13,
fontWeight: FontWeight.w600,
color: isActive ? Colors.white : const Color(0xFF64748B),
),
),
),
],
),
),
@@ -1073,8 +470,6 @@ class _ContributeScreenState extends State<ContributeScreen>
return ListView.separated(
key: const ValueKey('list'),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: submissions.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
@@ -1220,13 +615,7 @@ class _ContributeScreenState extends State<ContributeScreen>
children: [
_inputLabel('Category', required: true),
const SizedBox(height: 6),
if (provider.eventCategories.isEmpty)
const SizedBox(
height: 48,
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
)
else
_dropdown(_selectedCategory, provider.eventCategories, (v) => setState(() => _selectedCategory = v!)),
_dropdown(_selectedCategory, _categories, (v) => setState(() => _selectedCategory = v!)),
],
),
),
@@ -1339,14 +728,12 @@ class _ContributeScreenState extends State<ContributeScreen>
}
Widget _textField(TextEditingController ctl, String placeholder, {
Key? key,
int maxLines = 1,
String? Function(String?)? validator,
TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters,
}) {
return TextFormField(
key: key,
controller: ctl,
maxLines: maxLines,
keyboardType: keyboardType,
@@ -1370,8 +757,7 @@ class _ContributeScreenState extends State<ContributeScreen>
Widget _dropdown(String value, List<String> items, ValueChanged<String?> onChanged) {
return DropdownButtonFormField<String>(
value: value,
isExpanded: true,
items: items.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 14), overflow: TextOverflow.ellipsis))).toList(),
items: items.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 14)))).toList(),
onChanged: onChanged,
decoration: InputDecoration(
filled: true,
@@ -1480,14 +866,12 @@ class _ContributeScreenState extends State<ContributeScreen>
children: [
Expanded(
child: _textField(_latCtl, 'Latitude (e.g. 9.93123)',
key: const ValueKey('coord_lat'),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))]),
),
const SizedBox(width: 12),
Expanded(
child: _textField(_lngCtl, 'Longitude (e.g. 76.26730)',
key: const ValueKey('coord_lng'),
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.\-]'))]),
),
@@ -1497,9 +881,7 @@ class _ContributeScreenState extends State<ContributeScreen>
Row(
children: [
Expanded(
child: _textField(_mapsLinkCtl, 'Paste Google Maps URL here',
key: const ValueKey('coord_maps_url'),
keyboardType: TextInputType.url),
child: _textField(_mapsLinkCtl, 'Paste Google Maps URL here'),
),
const SizedBox(width: 8),
SizedBox(
@@ -1796,7 +1178,7 @@ class _ContributeScreenState extends State<ContributeScreen>
try {
final data = <String, dynamic>{
'event_name': _titleCtl.text.trim(),
'title': _titleCtl.text.trim(),
'category': _selectedCategory,
'district': _selectedDistrict,
'date': _selectedDate!.toIso8601String(),
@@ -1850,7 +1232,7 @@ class _ContributeScreenState extends State<ContributeScreen>
_mapsLinkCtl.clear();
_selectedDate = null;
_selectedTime = null;
_selectedCategory = _categories_fallback.first;
_selectedCategory = _categories.first;
_selectedDistrict = _districts.first;
_images.clear();
_coordMessage = null;

View File

@@ -2,7 +2,6 @@
// 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';
@@ -216,15 +215,10 @@ class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox.expand(
child: CachedNetworkImage(
imageUrl: firstImage,
child: Image.network(
firstImage,
fit: BoxFit.cover,
memCacheWidth: 400,
memCacheHeight: 300,
maxWidthDiskCache: 800,
maxHeightDiskCache: 600,
placeholder: (_, __) => Container(color: const Color(0xFF1E293B)),
errorWidget: (_, __, ___) => Container(color: const Color(0xFF334155)),
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)),
),
),
),

View File

@@ -15,23 +15,9 @@ 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;
@@ -45,18 +31,13 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
final Curve _curve = Curves.easeInOutCubic;
bool _isAnimating = false;
bool _loading = false;
bool _isSignupMode = false;
bool _loading = false; // network loading flag
@override
void dispose() {
_controller?.dispose();
_emailCtrl.dispose();
_passCtrl.dispose();
_signupEmailCtrl.dispose();
_signupPhoneCtrl.dispose();
_signupPassCtrl.dispose();
_signupConfirmCtrl.dispose();
super.dispose();
}
@@ -71,6 +52,7 @@ 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)),
);
@@ -86,7 +68,9 @@ 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;
@@ -103,9 +87,14 @@ 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,
@@ -113,135 +102,90 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
final message = userFriendlyError(e);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
setState(() => _isAnimating = false);
} finally {
if (mounted) setState(() => _loading = false);
if (mounted) setState(() {
_loading = false;
});
}
}
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;
void _openRegister() {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen()));
}
setState(() => _loading = true);
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
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);
}
}
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);
Future<void> _openForgotPasswordDialog() async {
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
bool submitting = false;
return Scaffold(
body: SafeArea(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
await showDialog<void>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setDialog) {
return AlertDialog(
title: const Text('Forgot Password'),
content: SizedBox(
width: 360,
return Row(
children: [
Container(
width: leftWidth,
height: double.infinity,
// color: const Color(0xFF0B63D6),
decoration: AppDecoration.blueGradient,
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
child: Opacity(
opacity: leftTextOpacity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Enter your email and we'll send reset instructions."),
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)),
const SizedBox(height: 12),
TextField(
controller: emailCtrl,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
const Text(
'Sign in to access your dashboard, manage events, and stay connected.',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(flex: 2),
Opacity(
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
child: const Padding(
padding: EdgeInsets.only(bottom: 12.0),
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
),
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'),
Expanded(
child: Transform.translate(
offset: Offset(formOffset, 0),
child: Opacity(
opacity: formOpacity,
child: Container(
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: [
@@ -273,16 +217,21 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
children: [
Row(children: [
Checkbox(value: true, onChanged: (_) {}),
const Text('Remember me'),
const Text('Remember me')
]),
TextButton(onPressed: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
TextButton(onPressed: () {}, child: const Text('Forgot Password?'))
],
),
const SizedBox(height: 8),
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: (_isAnimating || _loading) ? null : () => _performLoginFlow(safeInitialWidth),
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))
@@ -293,10 +242,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
Wrap(
alignment: WrapAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => setState(() => _isSignupMode = true),
child: const Text("Don't have an account? Register"),
),
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
TextButton(onPressed: () {}, child: const Text('Contact support')),
TextButton(
onPressed: () {
@@ -309,171 +255,8 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
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);
return Scaffold(
body: SafeArea(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
return Row(
children: [
Container(
width: leftWidth,
height: double.infinity,
decoration: AppDecoration.blueGradient,
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
child: Opacity(
opacity: leftTextOpacity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
const Spacer(),
Text(
_isSignupMode ? 'Join Eventify!' : 'Welcome Back!',
style: const TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
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(
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
child: const Padding(
padding: EdgeInsets.only(bottom: 12.0),
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
),
),
],
),
),
),
Expanded(
child: Transform.translate(
offset: Offset(formOffset, 0),
child: Opacity(
opacity: formOpacity,
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
child: Center(
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),
),
),
),
),
@@ -491,3 +274,113 @@ 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 = userFriendlyError(e);
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

@@ -320,8 +320,6 @@ class _HomeContentState extends State<_HomeContent>
height: double.infinity,
memCacheWidth: 1400,
memCacheHeight: 800,
maxWidthDiskCache: 1400,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(
color: const Color(0xFF0A0E1A),
),
@@ -531,8 +529,6 @@ class _HomeContentState extends State<_HomeContent>
fit: BoxFit.cover,
memCacheWidth: 1400,
memCacheHeight: 800,
maxWidthDiskCache: 1400,
maxHeightDiskCache: 800,
)
else
Container(color: const Color(0xFF0A0E1A)),
@@ -786,8 +782,6 @@ class _HomeContentState extends State<_HomeContent>
imageUrl: img,
memCacheWidth: 600,
memCacheHeight: 320,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 640,
width: double.infinity,
height: imageHeight,
fit: BoxFit.cover,

View File

@@ -1,7 +1,7 @@
// lib/screens/home_screen.dart
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
import '../core/utils/error_utils.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/auth/auth_guard.dart';
@@ -46,8 +46,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// backend-driven
List<EventModel> _allEvents = []; // master copy, never filtered
List<EventModel> _events = [];
List<EventModel> _featuredEvents = [];
List<EventModel> _topEventsList = [];
List<EventTypeModel> _types = [];
int _selectedTypeId = -1; // -1 == All
bool _categoriesExpanded = false;
@@ -64,7 +62,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
super.initState();
_heroPageNotifier = ValueNotifier(0);
_loadUserDataAndEvents();
_loadCuratedEvents();
_startAutoScroll();
PostHogService.instance.screen('Home');
}
@@ -96,7 +93,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
setState(() => _loading = true);
final prefs = await SharedPreferences.getInstance();
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? '';
final storedLocation = prefs.getString('location') ?? 'Thrissur';
final storedLocation = prefs.getString('location') ?? 'Whitefield, Bengaluru';
// Fix legacy lat,lng strings saved before the reverse-geocoding fix
final coordMatch = RegExp(r'^(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$').firstMatch(storedLocation);
if (coordMatch != null) {
@@ -115,7 +112,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
_userLng = prefs.getDouble('user_lng');
try {
// Fetch types and location-based events in parallel.
// Fetch types and events in parallel for faster loading.
// Prefer haversine (lat/lng) when GPS coords are available; fall back to pincode.
final results = await Future.wait([
_events_service_getEventTypesSafe(),
@@ -137,12 +134,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
});
}
} catch (e) {
if (kDebugMode) debugPrint('HomeScreen init unexpected error: $e');
if (mounted) setState(() => _loading = false);
if (mounted) {
setState(() => _loading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
}
}
// Refresh notification badge count (fire-and-forget, skip for guests — endpoint is authed)
if (mounted && !AuthGuard.isGuest) {
// Refresh notification badge count (fire-and-forget)
if (mounted) {
context.read<NotificationProvider>().refreshUnreadCount();
}
}
@@ -190,24 +189,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
}
}
/// Loads featured carousel + top events once globally — no pincode, never re-fetched on location change.
Future<void> _loadCuratedEvents() async {
try {
final results = await Future.wait([
_eventsService.getFeaturedEvents(),
_eventsService.getTopEvents(),
]);
if (mounted) {
setState(() {
_featuredEvents = results[0] as List<EventModel>;
_topEventsList = results[1] as List<EventModel>;
});
}
} catch (_) {
// Non-critical — fallback getters handle empty lists gracefully
}
}
Future<void> _refresh() async {
await _loadUserDataAndEvents();
}
@@ -261,8 +242,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: imageUrl,
memCacheWidth: 112,
memCacheHeight: 112,
maxWidthDiskCache: 224,
maxHeightDiskCache: 224,
fit: BoxFit.contain,
placeholder: (_, __) => Icon(
icon ?? Icons.category,
@@ -475,8 +454,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 112,
memCacheHeight: 112,
maxWidthDiskCache: 224,
maxHeightDiskCache: 224,
width: 56,
height: 56,
fit: BoxFit.cover,
@@ -490,7 +467,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
onTap: () {
Navigator.of(context).pop();
if (ev.id != null) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev, heroTag: 'event-hero-${ev.id}-search')));
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
}
},
);
@@ -606,51 +583,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
}
// Featured events for the hero carousel — from dedicated endpoint, fallback to first 6
List<EventModel> get _heroEvents =>
_featuredEvents.isNotEmpty ? _featuredEvents : _allEvents.take(6).toList();
// Top events respecting the active date filter — from dedicated endpoint, fallback to date-filtered all
List<EventModel> get _topEventsFiltered {
if (_topEventsList.isEmpty) return _allFilteredByDate;
if (_selectedDateFilter.isEmpty) return _topEventsList;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
DateTime filterStart;
DateTime filterEnd;
switch (_selectedDateFilter) {
case 'Today':
filterStart = today;
filterEnd = today;
break;
case 'Tomorrow':
filterStart = today.add(const Duration(days: 1));
filterEnd = filterStart;
break;
case 'This week':
filterStart = today;
filterEnd = today.add(Duration(days: 7 - today.weekday));
break;
case 'Date':
if (_selectedCustomDate == null) return _topEventsList;
filterStart = DateTime(_selectedCustomDate!.year, _selectedCustomDate!.month, _selectedCustomDate!.day);
filterEnd = filterStart;
break;
default:
return _topEventsList;
}
return _topEventsList.where((e) {
try {
final s = DateTime.parse(e.startDate);
final eEnd = DateTime.parse(e.endDate);
final eStart = DateTime(s.year, s.month, s.day);
final eEndDay = DateTime(eEnd.year, eEnd.month, eEnd.day);
return !eEndDay.isBefore(filterStart) && !eStart.isAfter(filterEnd);
} catch (_) {
return false;
}
}).toList();
}
// Get hero events (first 4 events for the carousel)
List<EventModel> get _heroEvents => _events.take(6).toList();
String _formatDate(String dateStr) {
try {
@@ -958,8 +892,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: imageUrl,
memCacheWidth: 160,
memCacheHeight: 160,
maxWidthDiskCache: 320,
maxHeightDiskCache: 320,
width: 80,
height: 80,
fit: BoxFit.cover,
@@ -987,7 +919,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
onTap: () {
Navigator.of(context).pop();
if (ev.id != null) {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev, heroTag: 'event-hero-${ev.id}-sheet')));
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
}
},
child: Container(
@@ -1265,60 +1197,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
);
}
/// Returns the image URL for a given event (for blurred bg).
String? _getEventImageUrl(EventModel event) {
if (event.thumbImg != null && event.thumbImg!.isNotEmpty) return event.thumbImg;
if (event.images.isNotEmpty && event.images.first.image.isNotEmpty) return event.images.first.image;
return null;
}
Widget _buildHeroSection() {
return SafeArea(
bottom: false,
child: ValueListenableBuilder<int>(
valueListenable: _heroPageNotifier,
builder: (context, currentPage, _) {
final currentImg = _heroEvents.isNotEmpty ? _getEventImageUrl(_heroEvents[currentPage.clamp(0, _heroEvents.length - 1)]) : null;
return Stack(
children: [
// ── Blurred background image layer ──
if (currentImg != null && currentImg.isNotEmpty)
Positioned.fill(
child: ClipRect(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: CachedNetworkImage(
key: ValueKey(currentImg),
imageUrl: currentImg,
memCacheWidth: 200,
memCacheHeight: 200,
maxWidthDiskCache: 400,
maxHeightDiskCache: 400,
fit: BoxFit.cover,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
imageBuilder: (context, imageProvider) => Stack(
fit: StackFit.expand,
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Image(
image: imageProvider,
fit: BoxFit.cover,
),
),
Container(
color: Colors.black.withOpacity(0.35),
),
],
),
),
),
),
),
// ── Foreground content ──
Column(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Top bar: location pill + search button
@@ -1439,10 +1321,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
const SizedBox(height: 24),
],
),
],
);
},
),
);
}
@@ -1512,13 +1390,13 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
'source': 'hero_carousel',
});
Navigator.push(context,
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event, heroTag: 'event-hero-${event.id}-carousel')));
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
}
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Hero(
tag: 'event-hero-${event.id}-carousel',
tag: 'event-hero-${event.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Stack(
@@ -1530,8 +1408,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 700,
memCacheHeight: 400,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 700,
fit: BoxFit.cover,
placeholder: (_, __) => const _HeroShimmer(radius: radius),
errorWidget: (_, __, ___) =>
@@ -1713,12 +1589,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
const SizedBox(height: 16),
SizedBox(
height: 200,
child: _topEventsFiltered.isEmpty && _loading
child: _allFilteredByDate.isEmpty && _loading
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: List.generate(3, (_) => const Padding(padding: EdgeInsets.only(right: 12), child: EventCardSkeleton()))),
)
: _topEventsFiltered.isEmpty
: _allFilteredByDate.isEmpty
? Center(child: Text(
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
style: const TextStyle(color: Color(0xFF9CA3AF)),
@@ -1726,10 +1602,10 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
: PageView.builder(
controller: PageController(viewportFraction: 0.85),
physics: const PageScrollPhysics(),
itemCount: _topEventsFiltered.length,
itemCount: _allFilteredByDate.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildTopEventCard(_topEventsFiltered[index]),
child: _buildTopEventCard(_allFilteredByDate[index]),
),
),
),
@@ -1939,11 +1815,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return GestureDetector(
onTap: () {
if (event.id != null) {
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event, heroTag: 'event-hero-${event.id}-top')));
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
}
},
child: Hero(
tag: 'event-hero-${event.id}-top',
tag: 'event-hero-${event.id}',
child: Container(
width: 150,
decoration: BoxDecoration(
@@ -1959,8 +1835,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 300,
memCacheHeight: 200,
maxWidthDiskCache: 600,
maxHeightDiskCache: 400,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
@@ -2157,8 +2031,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 192,
memCacheHeight: 192,
maxWidthDiskCache: 384,
maxHeightDiskCache: 384,
width: 96,
height: double.infinity,
fit: BoxFit.cover,
@@ -2226,8 +2098,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 440,
memCacheHeight: 360,
maxWidthDiskCache: 880,
maxHeightDiskCache: 720,
width: 220,
height: 180,
fit: BoxFit.cover,
@@ -2401,8 +2271,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
imageUrl: img,
memCacheWidth: 800,
memCacheHeight: 400,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 600,
width: double.infinity,
height: 200,
fit: BoxFit.cover,

View File

@@ -23,8 +23,7 @@ import '../core/analytics/posthog_service.dart';
class LearnMoreScreen extends StatefulWidget {
final int eventId;
final EventModel? initialEvent;
final String? heroTag;
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent, this.heroTag}) : super(key: key);
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent}) : super(key: key);
@override
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
@@ -225,7 +224,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
Future<void> _shareEvent() async {
final title = _event?.title ?? _event?.name ?? 'Check out this event';
final url =
'https://app.eventifyplus.com/event/${widget.eventId}';
'https://uat.eventifyplus.com/events/${widget.eventId}';
await Share.share('$title\n$url', subject: title);
}
@@ -302,7 +301,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
final mediaQuery = MediaQuery.of(context);
final screenWidth = mediaQuery.size.width;
final screenHeight = mediaQuery.size.height;
final imageHeight = screenHeight * 0.52;
final imageHeight = screenHeight * 0.45;
final topPadding = mediaQuery.padding.top;
// ── DESKTOP layout ──────────────────────────────────────────────────
@@ -332,8 +331,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
@@ -546,8 +543,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
@@ -851,8 +846,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
width: double.infinity,
height: double.infinity,
placeholder: (_, __) => Container(
@@ -898,12 +891,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
// ---- Foreground image with rounded corners ----
if (images.isNotEmpty)
Positioned(
top: topPad + 70, // safely below the icon row
top: topPad + 56, // below the icon row
left: 20,
right: 20,
bottom: 40, // clear from the bottom card's -28 overlap
bottom: 16,
child: Hero(
tag: widget.heroTag ?? 'event-hero-${widget.eventId}',
tag: 'event-hero-${widget.eventId}',
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: PageView.builder(
@@ -915,8 +908,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
fit: BoxFit.cover,
memCacheWidth: 800,
memCacheHeight: 500,
maxWidthDiskCache: 1200,
maxHeightDiskCache: 800,
width: double.infinity,
placeholder: (_, __) => Container(
color: theme.dividerColor,
@@ -935,10 +926,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
// ---- No-image placeholder ----
if (images.isEmpty)
Positioned(
top: topPad + 70,
top: topPad + 56,
left: 20,
right: 20,
bottom: 40,
bottom: 16,
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
@@ -1575,10 +1566,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
imageUrl: imageUrl,
height: 100,
width: 140,
memCacheWidth: 280,
memCacheHeight: 200,
maxWidthDiskCache: 560,
maxHeightDiskCache: 400,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => Container(
height: 100,

View File

@@ -2,6 +2,7 @@
import 'dart:ui';
import '../core/utils/error_utils.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:provider/provider.dart';
@@ -21,36 +22,18 @@ 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;
VideoPlayerController? _videoController;
late VideoPlayerController _videoController;
bool _videoInitialized = false;
// Glassmorphism color palette
@@ -67,35 +50,24 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() {
super.initState();
_initVideo();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ScaffoldMessenger.of(context).clearSnackBars();
});
}
Future<void> _initVideo() async {
try {
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
await _videoController!.initialize();
_videoController!.setLooping(true);
_videoController!.setVolume(0);
_videoController!.play();
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();
}
@@ -147,11 +119,7 @@ class _LoginScreenState extends State<LoginScreen> {
}
void _openRegister() {
setState(() => _isSignupMode = true);
}
void _openLogin() {
setState(() => _isSignupMode = false);
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false)));
}
void _showComingSoon() {
@@ -160,182 +128,6 @@ 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);
@@ -448,14 +240,14 @@ class _LoginScreenState extends State<LoginScreen> {
body: Stack(
children: [
// Video background
if (_videoInitialized && _videoController != null)
if (_videoInitialized)
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),
),
),
),
@@ -485,14 +277,6 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
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(
@@ -622,7 +406,7 @@ class _LoginScreenState extends State<LoginScreen> {
),
// Forgot Password
GestureDetector(
onTap: _openForgotPasswordSheet,
onTap: _showComingSoon,
child: const Text(
'Forgot Password?',
style: TextStyle(color: _textMuted, fontSize: 12),
@@ -787,240 +571,149 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
),
),
],
),
);
}
}
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),
/// 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);
// 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),
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
// 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) {
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();
bool _loading = false;
String? _selectedDistrict;
static const _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
@override
void dispose() {
_emailCtrl.dispose();
_phoneCtrl.dispose();
_passCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
Future<void> _performRegister() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
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,
district: _selectedDistrict,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
} catch (e) {
if (!mounted) return;
final message = userFriendlyError(e);
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;
},
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
}
// District
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('District (optional)', style: TextStyle(color: _textMuted, fontSize: 13)),
),
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),
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),
value: _selectedDistrict,
decoration: const InputDecoration(labelText: 'District (optional)'),
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
onChanged: (v) => setState(() => _selectedDistrict = v),
),
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),
// 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),
// 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),
// 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,
),
),
),
],
child: ElevatedButton(
onPressed: _loading ? null : _performRegister,
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'),
),
),
],
),
),
),
),
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,6 @@ class _SearchScreenState extends State<SearchScreen> {
List<_LocationItem> _searchResults = [];
bool _showSearchResults = false;
bool _loadingLocation = false;
bool _isSearching = false;
@override
void initState() {
@@ -125,48 +124,14 @@ class _SearchScreenState extends State<SearchScreen> {
Navigator.of(context).pop(result);
}
Future<void> _selectAndClose(String location) async {
void _selectAndClose(String location) {
// 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()),
(loc) => 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);
_selectWithPincode(location, pincode: match?.pincode, lat: match?.lat, lng: match?.lng);
}
Future<void> _useCurrentLocation() async {
@@ -298,7 +263,6 @@ 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)),
@@ -318,12 +282,7 @@ class _SearchScreenState extends State<SearchScreen> {
},
),
),
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)
if (_ctrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {

View File

@@ -15,7 +15,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true;
String _appVersion = '2.0.4';
String _appVersion = '1.6(p)';
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 (coming soon)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))),
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 (coming soon)'))),
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
),
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: 'Coming Soon',
subtitle: 'Demo app',
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 (coming soon)')),
SnackBar(content: Text('Scanner tapped (demo)')),
);
}
void _onWhatsappTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')),
);
}
void _onCallTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Call (coming soon)')),
SnackBar(content: Text('Call tapped (demo)')),
);
}

View File

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

View File

@@ -55,10 +55,6 @@ class TierAvatarRing extends StatelessWidget {
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,

View File

@@ -873,10 +873,10 @@ packages:
dependency: transitive
description:
name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+2"
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
@@ -1089,10 +1089,10 @@ packages:
dependency: "direct main"
description:
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.10.1"
version: "2.11.1"
video_player_android:
dependency: transitive
description:
@@ -1105,10 +1105,10 @@ packages:
dependency: transitive
description:
name: video_player_avfoundation
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
url: "https://pub.dev"
source: hosted
version: "2.8.9"
version: "2.9.4"
video_player_platform_interface:
dependency: transitive
description:
@@ -1174,5 +1174,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"

View File

@@ -1,7 +1,7 @@
name: figma
description: A Flutter event app
publish_to: 'none'
version: 2.0.4+24
version: 1.6.1+17
environment:
sdk: ">=2.17.0 <3.0.0"