Compare commits

...

14 Commits

Author SHA1 Message Date
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
38 changed files with 6742 additions and 2243 deletions

12
.claude/launch.json Normal file
View File

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

6
.gitignore vendored
View File

@@ -46,3 +46,9 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
web/assets/login-bg.mp4 web/assets/login-bg.mp4
# Keystore files (signing keys)
*.jks
*.keystore
# large binary assets — keep local only, not tracked in git
assets/login-bg.mp4

71
CHANGELOG.md Normal file
View File

@@ -0,0 +1,71 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [1.4.0] - 2026-03-18
### Added
- **Desktop Contribute Dashboard**: Full desktop layout for the Contribute screen, matching the web version at eventifyplus.com/contribute
- "Contributor Dashboard" title with 3-tab navigation (Contribute, Leaderboard, Achievements)
- Two-column submit event form — Event Title + Category side-by-side, Date + Location side-by-side
- Contributor Level gradient card with 5-tier milestone progress bar (Bronze → Silver → Gold → Platinum → Diamond)
- Sub-navigation row: My Events / Submit Event / Reward Shop
- Desktop Leaderboard with All Time / This Month toggle, district pills, podium, and full rank table
- Desktop Achievements with 3-column badge grid, progress bars, and lock icons
- Inline Reward Shop with RP balance badge and shop item cards
- **Gamification Feature Module** (`lib/features/gamification/`):
- `GamificationProvider` — ChangeNotifier-based state management
- `GamificationService` — mock data for EP, RP, leaderboard entries, achievements, and shop items
- Models: `LeaderboardUser`, `Achievement`, `ShopItem`, `ContributorStats`
- **Bottom Sheet Date Filters**: Home screen event-category filter chips now open in a modal bottom sheet on mobile
- Web runner script (`run_web.sh`) for local Flutter web development server
### Changed
- **Profile Screen**: Completely redesigned to match the web app layout — gradient header card, avatar, stats row (Likes / Posts / Views), and tabbed content
- **Profile Card Animations**: Smooth entrance animations matching the React web component
- **Contribute Screen (Mobile)**: Full 4-tab rebuild — Contribute, Leaderboard, Achievements, Shop — with animated glass-glider tab bar indicator
- **Login Screen**: Updated UI design aligned with the web version
- **Event Detail Screen**: Layout updates and improved API data binding
- **Theme**: Refreshed dark/light mode colour palette and surface colours
- **API Client**: Updated base URL and endpoint paths in `lib/core/api/api_endpoints.dart`
- **Fonts**: Integrated full Gilroy font family (Light, Regular, Medium, SemiBold, Bold, ExtraBold — with italic variants)
- **Responsive Layout**: Improved breakpoint handling; desktop threshold set at 820 px
### Fixed
- Profile card pixel-perfect alignment with the web version
- Calendar screen date-range filter and location search integration
- District dropdown naming conflict in leaderboard (`_lbDistricts` vs. `_districts`)
- Green points colour (#16A34A) on desktop leaderboard matching web (was blue #0F45CF on mobile)
---
## [1.3.0] - 2026-02-xx
### Added
- Leaderboard tab and Achievements tab added to the Contribute screen
- Bouncy sliding glass-glider animation for Contribute tab bar
---
## [1.2.0] - 2026-01-xx
### Added
- Responsive dual-layout system with 820 px breakpoint
- Date filtering on the Home screen event feed
- Location search integration
- Calendar screen bug fixes and improvements
---
## [1.1.0] - 2025-12-xx
### Added
- Initial screens: Login, Home, Events, Profile, Calendar, Search, Booking
- Desktop variants: `DesktopLoginScreen`, `HomeDesktopScreen`
- Flutter launcher icons and native splash screen
- Gilroy font integration (initial)
- `shared_preferences` session caching

View File

@@ -17,6 +17,7 @@
[![Flutter](https://img.shields.io/badge/Flutter-%2302569B.svg?style=for-the-badge&logo=flutter&logoColor=white)](https://flutter.dev/) [![Flutter](https://img.shields.io/badge/Flutter-%2302569B.svg?style=for-the-badge&logo=flutter&logoColor=white)](https://flutter.dev/)
[![Dart](https://img.shields.io/badge/Dart-%230175C2.svg?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev/) [![Dart](https://img.shields.io/badge/Dart-%230175C2.svg?style=for-the-badge&logo=dart&logoColor=white)](https://dart.dev/)
[![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20Android%20%7C%20Web%20%7C%20Desktop-lightgrey?style=for-the-badge)](#) [![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20Android%20%7C%20Web%20%7C%20Desktop-lightgrey?style=for-the-badge)](#)
[![Version](https://img.shields.io/badge/version-1.4.0--preview-blue?style=for-the-badge)](#)
</div> </div>
@@ -123,6 +124,18 @@ The app uses an initialization check in `main.dart` that intercepts the launch v
--- ---
## 📋 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for a full history of changes.
### Latest (v1.4.0 — Preview)
- **Desktop Contribute Dashboard** rebuilt to match the web version (Contributor Dashboard, 3-tab nav, two-column form, leaderboard, achievements, reward shop)
- **Gamification module** — EP, RP, leaderboard, achievements, shop with Provider state management
- **Profile screen** redesigned to match the web app layout with animations
- Enhanced animations, responsive improvements, Gilroy font suite, and API updates
---
<div align="center"> <div align="center">
<sub>Built with ❤️ by the Eventify Team</sub> <sub>Built with ❤️ by the Eventify Team</sub>
</div> </div>

View File

@@ -22,8 +22,8 @@ android {
applicationId = "com.sicherhaven.eventify" applicationId = "com.sicherhaven.eventify"
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = 11 versionCode = 17
versionName = "1.2(p)" versionName = "1.6.1(p)"
} }
// ---------- SIGNING CONFIG ---------- // ---------- SIGNING CONFIG ----------
@@ -51,9 +51,9 @@ android {
// Use the release signing config created above // Use the release signing config created above
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = false isShrinkResources = true
// proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
} }

28
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,28 @@
# Flutter
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Google Maps
-keep class com.google.android.gms.maps.** { *; }
-keep interface com.google.android.gms.maps.** { *; }
# Keep annotations
-keepattributes *Annotation*
-keepattributes SourceFile,LineNumberTable
# Play Core (deferred components)
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
-dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task

View File

@@ -0,0 +1,15 @@
Component,Old Implementation,New Implementation (matching mvnew.eventifyplus.com/home)
Hero Height,120px blue gradient banner,400px immersive full-width hero with rounded corners (20px)
Background Image,None (solid blue gradient via AppDecoration),Featured event image with CachedNetworkImage and BoxFit.cover
Ken Burns Animation,None,AnimationController 12s loop scaling 1.0 to 1.08 with easeInOut curve
Image Rotation,None,Timer.periodic every 6s cycling through first 5 events with AnimatedSwitcher crossfade
Dark Overlay,None,LinearGradient top-to-bottom rgba(0/0/0/0.3) to rgba(0/0/0/0.6)
Title Text,Welcome Back + username (14px + 22px),Featured event title (36px weight 800) or fallback Discover Amazing Events Near You
Subtitle,None,Event date + venue with calendar and location icons (14px white70)
CTA Button,None,Learn More or Explore Events button (#2563EB bg white text rounded 12px with blue glow shadow)
Indicator Dots,None,Bottom-right pill dots (active=24px white elongated / inactive=8px white40) for up to 5 events
Fallback (no events),Blue gradient with username,Blue gradient (#0F45CF to #082369) with static heading and subtitle
Featured Modal,None,showDialog with dark overlay (black87) plus 700x500 modal with event image gradient title and Learn More CTA
Close Button,None,Circular black50 button with white X icon top-right of modal
Animation Mixin,None,SingleTickerProviderStateMixin added to _HomeContentState
Disposal,None,AnimationController.dispose and Timer.cancel in dispose()
1 Component Old Implementation New Implementation (matching mvnew.eventifyplus.com/home)
2 Hero Height 120px blue gradient banner 400px immersive full-width hero with rounded corners (20px)
3 Background Image None (solid blue gradient via AppDecoration) Featured event image with CachedNetworkImage and BoxFit.cover
4 Ken Burns Animation None AnimationController 12s loop scaling 1.0 to 1.08 with easeInOut curve
5 Image Rotation None Timer.periodic every 6s cycling through first 5 events with AnimatedSwitcher crossfade
6 Dark Overlay None LinearGradient top-to-bottom rgba(0/0/0/0.3) to rgba(0/0/0/0.6)
7 Title Text Welcome Back + username (14px + 22px) Featured event title (36px weight 800) or fallback Discover Amazing Events Near You
8 Subtitle None Event date + venue with calendar and location icons (14px white70)
9 CTA Button None Learn More or Explore Events button (#2563EB bg white text rounded 12px with blue glow shadow)
10 Indicator Dots None Bottom-right pill dots (active=24px white elongated / inactive=8px white40) for up to 5 events
11 Fallback (no events) Blue gradient with username Blue gradient (#0F45CF to #082369) with static heading and subtitle
12 Featured Modal None showDialog with dark overlay (black87) plus 700x500 modal with event image gradient title and Learn More CTA
13 Close Button None Circular black50 button with white X icon top-right of modal
14 Animation Mixin None SingleTickerProviderStateMixin added to _HomeContentState
15 Disposal None AnimationController.dispose and Timer.cancel in dispose()

View File

@@ -0,0 +1,27 @@
Phase,File,Action,Priority,Description
1,lib/core/constants.dart,MODIFY,Critical,"Add desktop breakpoints: wideDesktopBreakpoint=1200, sidebarExpandedWidth=240, sidebarCollapsedWidth=72, topBarHeight=64"
1,lib/widgets/desktop_sidebar.dart,CREATE,Critical,"Collapsible sidebar with icon-only (820-1200px) and full (1200px+) modes, AnimatedContainer transitions"
1,lib/widgets/desktop_topbar.dart,CREATE,Critical,"Responsive top bar with LayoutBuilder: search field, notifications, avatar, location"
1,lib/widgets/responsive_shell.dart,CREATE,Critical,"Master responsive scaffold wrapping all desktop screens: sidebar + topbar + content"
1,lib/screens/responsive_layout.dart,MODIFY,High,"Add three-tier breakpoint support (mobile/tablet/desktop) and ScreenSize enum"
1,lib/main.dart,MODIFY,Critical,"Update StartupScreen to use ResponsiveShell for desktop routing"
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Split 1077-line monolith into thin orchestrator + focused content widget"
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Replace marquee with rich hero carousel (PageView.builder matching mobile)"
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Replace fixed 3-column grid with SliverGrid + SliverGridDelegateWithMaxCrossAxisExtent"
2,lib/screens/home_desktop_screen.dart,REWRITE,High,"Add MouseRegion + SystemMouseCursors.click on all tappable cards"
3,lib/screens/desktop_login_screen.dart,REWRITE,High,"Video background + glassmorphism card matching mobile login style"
3,lib/screens/desktop_login_screen.dart,REWRITE,High,"Continue as Guest button prominent"
4,lib/screens/calendar_screen.dart,MODIFY,Medium,"Remove LandscapeShell; use Row with Card-based calendar left + event list right inside ResponsiveShell"
4,lib/screens/calendar_screen.dart,MODIFY,Medium,"New _eventCardLandscape() with larger images (200px), full title, date, location"
4,lib/screens/profile_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with profile card left + tabbed events right"
4,lib/screens/profile_screen.dart,MODIFY,Medium,"DefaultTabController with Ongoing/Upcoming/Past tabs + SliverList per tab"
4,lib/screens/contribute_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with contributor info/nav left + tab content right"
4,lib/screens/contribute_screen.dart,MODIFY,Medium,"Vertical nav list for landscape tabs instead of horizontal TabBar"
4,lib/screens/settings_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with settings nav left + content right"
4,lib/screens/settings_screen.dart,MODIFY,Medium,"Fix logout navigation to use unified approach"
4,lib/screens/search_screen.dart,MODIFY,Medium,"Add landscape layout: full-width search bar + Row(popular cities, results)"
4,lib/screens/learn_more_screen.dart,MODIFY,Low,"Replace hard-coded 820 with AppConstants; add RepaintBoundary; LayoutBuilder for height"
5,lib/widgets/landscape_shell.dart,DELETE,High,"Replaced by responsive_shell.dart"
5,lib/widgets/landscape_section_header.dart,DELETE or REWRITE,Low,"Generalize into SectionHeader or delete if unused"
5,lib/core/app_decoration.dart,MODIFY,Low,"Add desktop-specific decorations: cardShadow, sidebarDecoration, topBarShadow"
8,lib/features/events/services/events_service.dart,MODIFY,Critical,"Set requiresAuth: false on getEventTypes, getEventsByPincode, getEventDetails, getEventsByMonthYear"
1 Phase File Action Priority Description
2 1 lib/core/constants.dart MODIFY Critical Add desktop breakpoints: wideDesktopBreakpoint=1200, sidebarExpandedWidth=240, sidebarCollapsedWidth=72, topBarHeight=64
3 1 lib/widgets/desktop_sidebar.dart CREATE Critical Collapsible sidebar with icon-only (820-1200px) and full (1200px+) modes, AnimatedContainer transitions
4 1 lib/widgets/desktop_topbar.dart CREATE Critical Responsive top bar with LayoutBuilder: search field, notifications, avatar, location
5 1 lib/widgets/responsive_shell.dart CREATE Critical Master responsive scaffold wrapping all desktop screens: sidebar + topbar + content
6 1 lib/screens/responsive_layout.dart MODIFY High Add three-tier breakpoint support (mobile/tablet/desktop) and ScreenSize enum
7 1 lib/main.dart MODIFY Critical Update StartupScreen to use ResponsiveShell for desktop routing
8 2 lib/screens/home_desktop_screen.dart REWRITE Critical Split 1077-line monolith into thin orchestrator + focused content widget
9 2 lib/screens/home_desktop_screen.dart REWRITE Critical Replace marquee with rich hero carousel (PageView.builder matching mobile)
10 2 lib/screens/home_desktop_screen.dart REWRITE Critical Replace fixed 3-column grid with SliverGrid + SliverGridDelegateWithMaxCrossAxisExtent
11 2 lib/screens/home_desktop_screen.dart REWRITE High Add MouseRegion + SystemMouseCursors.click on all tappable cards
12 3 lib/screens/desktop_login_screen.dart REWRITE High Video background + glassmorphism card matching mobile login style
13 3 lib/screens/desktop_login_screen.dart REWRITE High Continue as Guest button prominent
14 4 lib/screens/calendar_screen.dart MODIFY Medium Remove LandscapeShell; use Row with Card-based calendar left + event list right inside ResponsiveShell
15 4 lib/screens/calendar_screen.dart MODIFY Medium New _eventCardLandscape() with larger images (200px), full title, date, location
16 4 lib/screens/profile_screen.dart MODIFY Medium Remove LandscapeShell; Row with profile card left + tabbed events right
17 4 lib/screens/profile_screen.dart MODIFY Medium DefaultTabController with Ongoing/Upcoming/Past tabs + SliverList per tab
18 4 lib/screens/contribute_screen.dart MODIFY Medium Remove LandscapeShell; Row with contributor info/nav left + tab content right
19 4 lib/screens/contribute_screen.dart MODIFY Medium Vertical nav list for landscape tabs instead of horizontal TabBar
20 4 lib/screens/settings_screen.dart MODIFY Medium Remove LandscapeShell; Row with settings nav left + content right
21 4 lib/screens/settings_screen.dart MODIFY Medium Fix logout navigation to use unified approach
22 4 lib/screens/search_screen.dart MODIFY Medium Add landscape layout: full-width search bar + Row(popular cities, results)
23 4 lib/screens/learn_more_screen.dart MODIFY Low Replace hard-coded 820 with AppConstants; add RepaintBoundary; LayoutBuilder for height
24 5 lib/widgets/landscape_shell.dart DELETE High Replaced by responsive_shell.dart
25 5 lib/widgets/landscape_section_header.dart DELETE or REWRITE Low Generalize into SectionHeader or delete if unused
26 5 lib/core/app_decoration.dart MODIFY Low Add desktop-specific decorations: cardShadow, sidebarDecoration, topBarShadow
27 8 lib/features/events/services/events_service.dart MODIFY Critical Set requiresAuth: false on getEventTypes, getEventsByPincode, getEventDetails, getEventsByMonthYear

View File

@@ -0,0 +1,17 @@
Phase,File,Change Type,Description
1,lib/core/constants.dart,MODIFY,Updated sidebarExpandedWidth to 262px and removed sidebarCollapsedWidth
1,lib/widgets/desktop_sidebar.dart,REWRITE,Figma match: 262px fixed width with blue gradient and white pill selection and EVENTIFY logo with sparkle icon
1,lib/widgets/desktop_topbar.dart,REWRITE,Figma match: search input field plus notification bell plus user avatar and removed location widget
1,lib/widgets/responsive_shell.dart,REWRITE,Simplified to always show 262px sidebar on desktop and removed collapsed mode
2,lib/screens/home_desktop_screen.dart,REWRITE,Website-matching hero (400px Ken Burns animation plus dark overlay plus CTA) replacing welcome banner
2,lib/screens/home_desktop_screen.dart,MODIFY,Pill-shaped category chips (border-radius 999px) with blue active state matching website
2,lib/screens/home_desktop_screen.dart,MODIFY,Event cards enhanced with date badge overlay on image and blue/green icons for date/venue
2,lib/screens/home_desktop_screen.dart,MODIFY,Background color changed to #FAFBFC (off-white) matching website
2,lib/screens/home_desktop_screen.dart,ADD,Featured event modal dialog with large image plus gradient plus Learn More CTA plus close button
3,lib/screens/calendar_screen.dart,MODIFY,Desktop layout: calendar grid 60% left plus events panel 40% right with white background
3,lib/screens/calendar_screen.dart,MODIFY,Event cards use blue and green dot indicators for date and venue
4,lib/screens/profile_screen.dart,MODIFY,Desktop layout: full-width profile banner plus 3-column event grids for Upcoming and Past
4,lib/screens/profile_screen.dart,MODIFY,Added _buildDesktopLayout and _buildDesktopEventSection and _buildDesktopEventGridCard
5,lib/screens/learn_more_screen.dart,MODIFY,Desktop layout: full-width 300px hero image plus About/Venue two-column plus gallery strip
5,lib/screens/learn_more_screen.dart,MODIFY,Added horizontal gallery with overflow count badge and Book Your Spot CTA
6,lib/features/events/services/events_service.dart,VERIFIED,Guest access confirmed working with requiresAuth false on 4 read endpoints
1 Phase File Change Type Description
2 1 lib/core/constants.dart MODIFY Updated sidebarExpandedWidth to 262px and removed sidebarCollapsedWidth
3 1 lib/widgets/desktop_sidebar.dart REWRITE Figma match: 262px fixed width with blue gradient and white pill selection and EVENTIFY logo with sparkle icon
4 1 lib/widgets/desktop_topbar.dart REWRITE Figma match: search input field plus notification bell plus user avatar and removed location widget
5 1 lib/widgets/responsive_shell.dart REWRITE Simplified to always show 262px sidebar on desktop and removed collapsed mode
6 2 lib/screens/home_desktop_screen.dart REWRITE Website-matching hero (400px Ken Burns animation plus dark overlay plus CTA) replacing welcome banner
7 2 lib/screens/home_desktop_screen.dart MODIFY Pill-shaped category chips (border-radius 999px) with blue active state matching website
8 2 lib/screens/home_desktop_screen.dart MODIFY Event cards enhanced with date badge overlay on image and blue/green icons for date/venue
9 2 lib/screens/home_desktop_screen.dart MODIFY Background color changed to #FAFBFC (off-white) matching website
10 2 lib/screens/home_desktop_screen.dart ADD Featured event modal dialog with large image plus gradient plus Learn More CTA plus close button
11 3 lib/screens/calendar_screen.dart MODIFY Desktop layout: calendar grid 60% left plus events panel 40% right with white background
12 3 lib/screens/calendar_screen.dart MODIFY Event cards use blue and green dot indicators for date and venue
13 4 lib/screens/profile_screen.dart MODIFY Desktop layout: full-width profile banner plus 3-column event grids for Upcoming and Past
14 4 lib/screens/profile_screen.dart MODIFY Added _buildDesktopLayout and _buildDesktopEventSection and _buildDesktopEventGridCard
15 5 lib/screens/learn_more_screen.dart MODIFY Desktop layout: full-width 300px hero image plus About/Venue two-column plus gallery strip
16 5 lib/screens/learn_more_screen.dart MODIFY Added horizontal gallery with overflow count badge and Book Your Spot CTA
17 6 lib/features/events/services/events_service.dart VERIFIED Guest access confirmed working with requiresAuth false on 4 read endpoints

View File

@@ -81,12 +81,12 @@ class ApiClient {
if (requiresAuth) { if (requiresAuth) {
final token = await TokenStorage.getToken(); final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername(); final username = await TokenStorage.getUsername();
if (token == null || username == null) { if (token != null && username != null) {
throw Exception('Authentication required');
}
finalParams['token'] = token; finalParams['token'] = token;
finalParams['username'] = username; finalParams['username'] = username;
} }
// Guest mode: proceed without token — let backend decide
}
if (params != null) finalParams.addAll(params); if (params != null) finalParams.addAll(params);
@@ -103,7 +103,7 @@ class ApiClient {
return _handleResponse(url, response, finalParams); return _handleResponse(url, response, finalParams);
} }
/// Build request body and attach token + username if required /// Build request body and attach token + username if available
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async { Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
final Map<String, dynamic> finalBody = {}; final Map<String, dynamic> finalBody = {};
@@ -111,13 +111,12 @@ class ApiClient {
final token = await TokenStorage.getToken(); final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername(); final username = await TokenStorage.getUsername();
if (token == null || username == null) { if (token != null && username != null) {
throw Exception('Authentication required');
}
finalBody['token'] = token; finalBody['token'] = token;
finalBody['username'] = username; finalBody['username'] = username;
} }
// Guest mode: proceed without token — let backend decide
}
if (body != null) finalBody.addAll(body); if (body != null) finalBody.addAll(body);

View File

@@ -23,4 +23,12 @@ class ApiEndpoints {
// static const String bookEvent = "$baseUrl/events/book-event/"; // static const String bookEvent = "$baseUrl/events/book-event/";
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/"; // static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/"; // static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
// Gamification / Contributor Module (TechDocs v2)
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
static const String leaderboard = "$baseUrl/v1/leaderboard/";
static const String shopItems = "$baseUrl/v1/shop/items/";
static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
} }

View File

@@ -0,0 +1,71 @@
// lib/core/auth/auth_guard.dart
import 'package:flutter/material.dart';
import '../../screens/login_screen.dart';
class AuthGuard {
static bool _isGuest = false;
static bool get isGuest => _isGuest;
static bool get isLoggedIn => !_isGuest;
static void setGuest(bool value) => _isGuest = value;
/// Call before any action that requires login.
/// Returns true if logged in (proceed). Returns false if guest (shows prompt).
static bool requireLogin(BuildContext context,
{String reason = 'This feature requires an account.'}) {
if (!_isGuest) return true;
_showLoginPrompt(context, reason);
return false;
}
static void _showLoginPrompt(BuildContext context, String reason) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock_outline, size: 48, color: Color(0xFF0B63D6)),
const SizedBox(height: 16),
Text(reason,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Sign in or create an account to continue.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0B63D6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
Navigator.of(ctx).pop();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
},
child: const Text('Sign In',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w700)),
),
),
const SizedBox(height: 12),
],
),
),
);
}
}

View File

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppConstants { class AppConstants {
// Layout // Layout — breakpoints
static const double desktopBreakpoint = 820; static const double desktopBreakpoint = 820;
static const double wideDesktopBreakpoint = 1200;
static const double tabletBreakpoint = 600; static const double tabletBreakpoint = 600;
// Desktop sidebar
static const double sidebarExpandedWidth = 262;
static const double topBarHeight = 64;
static const double desktopHorizontalPadding = 24;
// Padding & Radius // Padding & Radius
static const double defaultPadding = 16; static const double defaultPadding = 16;
static const double cardRadius = 14; static const double cardRadius = 14;

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../core/api/api_client.dart'; import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart'; import '../../../core/api/api_endpoints.dart';
import '../../../core/auth/auth_guard.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../models/user_model.dart'; import '../models/user_model.dart';
@@ -33,6 +34,9 @@ class AuthService {
// candidate display name (server username or email fallback) // candidate display name (server username or email fallback)
final displayCandidate = serverUsername ?? savedEmail; final displayCandidate = serverUsername ?? savedEmail;
// clear guest mode on successful login
AuthGuard.setGuest(false);
// save token (TokenStorage stays responsible for token) // save token (TokenStorage stays responsible for token)
await TokenStorage.saveToken(token.toString(), savedEmail); await TokenStorage.saveToken(token.toString(), savedEmail);
@@ -90,6 +94,9 @@ class AuthService {
final savedRole = (res['role'] ?? 'user').toString(); final savedRole = (res['role'] ?? 'user').toString();
final savedPhone = (res['phone_number'] ?? phoneNumber)?.toString(); final savedPhone = (res['phone_number'] ?? phoneNumber)?.toString();
// clear guest mode on successful registration
AuthGuard.setGuest(false);
// Save token + canonical user id for token storage // Save token + canonical user id for token storage
await TokenStorage.saveToken(token.toString(), savedEmail); await TokenStorage.saveToken(token.toString(), savedEmail);

View File

@@ -9,7 +9,7 @@ class EventsService {
/// Get event types (POST to /events/type-list/) /// Get event types (POST to /events/type-list/)
Future<List<EventTypeModel>> getEventTypes() async { Future<List<EventTypeModel>> getEventTypes() async {
final res = await _api.post(ApiEndpoints.eventTypes); final res = await _api.post(ApiEndpoints.eventTypes, requiresAuth: false);
final list = <EventTypeModel>[]; final list = <EventTypeModel>[];
final data = res['event_types'] ?? res['event_types'] ?? res; final data = res['event_types'] ?? res['event_types'] ?? res;
if (data is List) { if (data is List) {
@@ -27,7 +27,7 @@ class EventsService {
/// Get events filtered by pincode (POST to /events/pincode-events/) /// Get events filtered by pincode (POST to /events/pincode-events/)
/// Use pincode='all' to fetch all events. /// Use pincode='all' to fetch all events.
Future<List<EventModel>> getEventsByPincode(String pincode) async { Future<List<EventModel>> getEventsByPincode(String pincode) async {
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode}); final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode}, requiresAuth: false);
final list = <EventModel>[]; final list = <EventModel>[];
final events = res['events'] ?? res['data'] ?? []; final events = res['events'] ?? res['data'] ?? [];
if (events is List) { if (events is List) {
@@ -40,7 +40,7 @@ class EventsService {
/// Event details /// Event details
Future<EventModel> getEventDetails(int eventId) async { Future<EventModel> getEventDetails(int eventId) async {
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}); final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
return EventModel.fromJson(Map<String, dynamic>.from(res)); return EventModel.fromJson(Map<String, dynamic>.from(res));
} }
@@ -48,7 +48,7 @@ class EventsService {
/// Accepts month string and year int. /// Accepts month string and year int.
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts). /// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async { Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}); final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
// expected keys: dates, total_number_of_events, date_events // expected keys: dates, total_number_of_events, date_events
return res; return res;
} }

View File

@@ -0,0 +1,212 @@
// lib/features/gamification/models/gamification_models.dart
// Data models matching TechDocs v2 DB schema for the Contributor Module.
// ---------------------------------------------------------------------------
// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
// ---------------------------------------------------------------------------
enum ContributorTier { BRONZE, SILVER, GOLD, PLATINUM, DIAMOND }
/// Returns the correct tier for a given lifetime EP total.
ContributorTier tierFromEp(int lifetimeEp) {
if (lifetimeEp >= 5000) return ContributorTier.DIAMOND;
if (lifetimeEp >= 1500) return ContributorTier.PLATINUM;
if (lifetimeEp >= 500) return ContributorTier.GOLD;
if (lifetimeEp >= 100) return ContributorTier.SILVER;
return ContributorTier.BRONZE;
}
/// Human-readable label for a tier.
String tierLabel(ContributorTier tier) {
switch (tier) {
case ContributorTier.BRONZE:
return 'Bronze';
case ContributorTier.SILVER:
return 'Silver';
case ContributorTier.GOLD:
return 'Gold';
case ContributorTier.PLATINUM:
return 'Platinum';
case ContributorTier.DIAMOND:
return 'Diamond';
}
}
/// EP threshold for next tier (used for progress bar). Returns null at max tier.
int? nextTierThreshold(ContributorTier tier) {
switch (tier) {
case ContributorTier.BRONZE:
return 100;
case ContributorTier.SILVER:
return 500;
case ContributorTier.GOLD:
return 1500;
case ContributorTier.PLATINUM:
return 5000;
case ContributorTier.DIAMOND:
return null;
}
}
/// Lower EP bound for current tier (used for progress bar calculation).
int tierStartEp(ContributorTier tier) {
switch (tier) {
case ContributorTier.BRONZE:
return 0;
case ContributorTier.SILVER:
return 100;
case ContributorTier.GOLD:
return 500;
case ContributorTier.PLATINUM:
return 1500;
case ContributorTier.DIAMOND:
return 5000;
}
}
// ---------------------------------------------------------------------------
// UserGamificationProfile — mirrors the `UserGamificationProfile` DB table
// ---------------------------------------------------------------------------
class UserGamificationProfile {
final String userId;
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
final int currentEp; // Liquid EP accumulated this month.
final int currentRp; // Spendable Reward Points.
final ContributorTier tier;
const UserGamificationProfile({
required this.userId,
required this.lifetimeEp,
required this.currentEp,
required this.currentRp,
required this.tier,
});
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
final ep = (json['lifetime_ep'] as int?) ?? 0;
return UserGamificationProfile(
userId: json['user_id'] as String? ?? '',
lifetimeEp: ep,
currentEp: (json['current_ep'] as int?) ?? 0,
currentRp: (json['current_rp'] as int?) ?? 0,
tier: tierFromEp(ep),
);
}
}
// ---------------------------------------------------------------------------
// LeaderboardEntry
// ---------------------------------------------------------------------------
class LeaderboardEntry {
final int rank;
final String username;
final String? avatarUrl;
final int lifetimeEp;
final ContributorTier tier;
final int eventsCount;
final bool isCurrentUser;
const LeaderboardEntry({
required this.rank,
required this.username,
this.avatarUrl,
required this.lifetimeEp,
required this.tier,
required this.eventsCount,
this.isCurrentUser = false,
});
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
final ep = (json['lifetime_ep'] as int?) ?? 0;
return LeaderboardEntry(
rank: (json['rank'] as int?) ?? 0,
username: json['username'] as String? ?? '',
avatarUrl: json['avatar_url'] as String?,
lifetimeEp: ep,
tier: tierFromEp(ep),
eventsCount: (json['events_count'] as int?) ?? 0,
isCurrentUser: (json['is_current_user'] as bool?) ?? false,
);
}
}
// ---------------------------------------------------------------------------
// ShopItem — mirrors `RedeemShopItem` table
// ---------------------------------------------------------------------------
class ShopItem {
final String id;
final String name;
final String description;
final int rpCost;
final int stockQuantity;
final String? imageUrl;
const ShopItem({
required this.id,
required this.name,
required this.description,
required this.rpCost,
required this.stockQuantity,
this.imageUrl,
});
factory ShopItem.fromJson(Map<String, dynamic> json) {
return ShopItem(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '',
rpCost: (json['rp_cost'] as int?) ?? 0,
stockQuantity: (json['stock_quantity'] as int?) ?? 0,
imageUrl: json['image_url'] as String?,
);
}
}
// ---------------------------------------------------------------------------
// RedemptionRecord — mirrors `RedemptionHistory` table
// ---------------------------------------------------------------------------
class RedemptionRecord {
final String id;
final String itemId;
final int rpSpent;
final String voucherCode;
final DateTime timestamp;
const RedemptionRecord({
required this.id,
required this.itemId,
required this.rpSpent,
required this.voucherCode,
required this.timestamp,
});
factory RedemptionRecord.fromJson(Map<String, dynamic> json) {
return RedemptionRecord(
id: json['id'] as String? ?? '',
itemId: json['item_id'] as String? ?? '',
rpSpent: (json['rp_spent'] as int?) ?? 0,
voucherCode: json['voucher_code_issued'] as String? ?? '',
timestamp: DateTime.tryParse(json['timestamp'] as String? ?? '') ?? DateTime.now(),
);
}
}
// ---------------------------------------------------------------------------
// AchievementBadge
// ---------------------------------------------------------------------------
class AchievementBadge {
final String id;
final String title;
final String description;
final String iconName; // maps to an IconData key
final bool isUnlocked;
final double progress; // 0.0 1.0
const AchievementBadge({
required this.id,
required this.title,
required this.description,
required this.iconName,
required this.isUnlocked,
required this.progress,
});
}

View File

@@ -0,0 +1,124 @@
// lib/features/gamification/providers/gamification_provider.dart
import 'package:flutter/foundation.dart';
import '../models/gamification_models.dart';
import '../services/gamification_service.dart';
class GamificationProvider extends ChangeNotifier {
final GamificationService _service = GamificationService();
// State
UserGamificationProfile? profile;
List<LeaderboardEntry> leaderboard = [];
List<ShopItem> shopItems = [];
List<AchievementBadge> achievements = [];
// Leaderboard filters — matches web version
String leaderboardDistrict = 'Overall Kerala';
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
bool isLoading = false;
String? error;
// ---------------------------------------------------------------------------
// Load everything at once (called when ContributeScreen is mounted)
// ---------------------------------------------------------------------------
Future<void> loadAll() async {
isLoading = true;
error = null;
notifyListeners();
try {
final results = await Future.wait([
_service.getProfile(),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
_service.getShopItems(),
_service.getAchievements(),
]);
profile = results[0] as UserGamificationProfile;
leaderboard = results[1] as List<LeaderboardEntry>;
shopItems = results[2] as List<ShopItem>;
achievements = results[3] as List<AchievementBadge>;
} catch (e) {
error = e.toString();
} finally {
isLoading = false;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Change district filter
// ---------------------------------------------------------------------------
Future<void> setDistrict(String district) async {
if (leaderboardDistrict == district) return;
leaderboardDistrict = district;
notifyListeners();
try {
leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
} catch (e) {
error = e.toString();
}
notifyListeners();
}
// ---------------------------------------------------------------------------
// Change time period filter
// ---------------------------------------------------------------------------
Future<void> setTimePeriod(String period) async {
if (leaderboardTimePeriod == period) return;
leaderboardTimePeriod = period;
notifyListeners();
try {
leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
} catch (e) {
error = e.toString();
}
notifyListeners();
}
// ---------------------------------------------------------------------------
// Redeem a shop item — deducts RP locally optimistically, returns voucher code
// ---------------------------------------------------------------------------
Future<String> redeemItem(String itemId) async {
final item = shopItems.firstWhere((s) => s.id == itemId);
// Optimistically deduct RP
if (profile != null) {
profile = UserGamificationProfile(
userId: profile!.userId,
lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp,
currentRp: profile!.currentRp - item.rpCost,
tier: profile!.tier,
);
notifyListeners();
}
try {
final record = await _service.redeemItem(itemId);
return record.voucherCode;
} catch (e) {
// Rollback on failure
if (profile != null) {
profile = UserGamificationProfile(
userId: profile!.userId,
lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp,
currentRp: profile!.currentRp + item.rpCost,
tier: profile!.tier,
);
notifyListeners();
}
rethrow;
}
}
// ---------------------------------------------------------------------------
// Submit a contribution
// ---------------------------------------------------------------------------
Future<void> submitContribution(Map<String, dynamic> data) async {
await _service.submitContribution(data);
}
}

View File

@@ -0,0 +1,180 @@
// lib/features/gamification/services/gamification_service.dart
//
// Stub service using the real API contract from TechDocs v2.
// All methods currently return mock data.
// TODO: replace each mock block with a real ApiClient call once
// the backend endpoints are live on uat.eventifyplus.com.
import 'dart:math';
import '../models/gamification_models.dart';
class GamificationService {
// ---------------------------------------------------------------------------
// User Gamification Profile
// TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
// ---------------------------------------------------------------------------
Future<UserGamificationProfile> getProfile() async {
await Future.delayed(const Duration(milliseconds: 400));
return const UserGamificationProfile(
userId: 'mock-user-001',
lifetimeEp: 320,
currentEp: 70,
currentRp: 45,
tier: ContributorTier.SILVER,
);
}
// ---------------------------------------------------------------------------
// Leaderboard
// district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
// timePeriod: 'all_time' | 'this_month'
// TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
// ---------------------------------------------------------------------------
Future<List<LeaderboardEntry>> getLeaderboard({
required String district,
required String timePeriod,
}) async {
await Future.delayed(const Duration(milliseconds: 500));
// Realistic mock names per district
final names = [
'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
'Kristin Watson', 'Guy Hawkins',
];
final rng = Random(district.hashCode ^ timePeriod.hashCode);
final baseEp = timePeriod == 'this_month' ? 800 : 4500;
final entries = List.generate(10, (i) {
final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
return LeaderboardEntry(
rank: i + 1,
username: names[i],
lifetimeEp: ep,
tier: tierFromEp(ep),
eventsCount: 149 - i * 12,
isCurrentUser: i == 7, // mock: current user is rank 8
);
});
return entries;
}
// ---------------------------------------------------------------------------
// Redeem Shop Items
// TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
// ---------------------------------------------------------------------------
Future<List<ShopItem>> getShopItems() async {
await Future.delayed(const Duration(milliseconds: 400));
return const [
ShopItem(
id: 'item-001',
name: 'Amazon ₹500 Voucher',
description: 'Redeem for any purchase on Amazon India.',
rpCost: 50,
stockQuantity: 20,
),
ShopItem(
id: 'item-002',
name: 'Swiggy ₹200 Voucher',
description: 'Free food delivery credit on Swiggy.',
rpCost: 20,
stockQuantity: 35,
),
ShopItem(
id: 'item-003',
name: 'Eventify Pro — 1 Month',
description: 'Premium access to Eventify.Plus features.',
rpCost: 30,
stockQuantity: 100,
),
ShopItem(
id: 'item-004',
name: 'Zomato ₹150 Voucher',
description: 'Discount on your next Zomato order.',
rpCost: 15,
stockQuantity: 50,
),
ShopItem(
id: 'item-005',
name: 'BookMyShow ₹300 Voucher',
description: 'Movie & event ticket credit on BookMyShow.',
rpCost: 30,
stockQuantity: 15,
),
ShopItem(
id: 'item-006',
name: 'Exclusive Badge',
description: 'Rare "Pioneer" badge for your profile.',
rpCost: 5,
stockQuantity: 0, // out of stock
),
];
}
// ---------------------------------------------------------------------------
// Redeem an item
// TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
// ---------------------------------------------------------------------------
Future<RedemptionRecord> redeemItem(String itemId) async {
await Future.delayed(const Duration(milliseconds: 600));
// Generate a fake voucher code
final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
return RedemptionRecord(
id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
itemId: itemId,
rpSpent: 0, // provider will look up cost
voucherCode: code,
timestamp: DateTime.now(),
);
}
// ---------------------------------------------------------------------------
// Submit Contribution
// TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
// ---------------------------------------------------------------------------
Future<void> submitContribution(Map<String, dynamic> data) async {
await Future.delayed(const Duration(milliseconds: 800));
// Mock always succeeds
}
// ---------------------------------------------------------------------------
// Achievements
// ---------------------------------------------------------------------------
Future<List<AchievementBadge>> getAchievements() async {
await Future.delayed(const Duration(milliseconds: 300));
return const [
AchievementBadge(
id: 'badge-01', title: 'First Submission',
description: 'Submitted your first event.',
iconName: 'edit', isUnlocked: true, progress: 1.0,
),
AchievementBadge(
id: 'badge-02', title: 'Silver Streak',
description: 'Reached Silver tier.',
iconName: 'star', isUnlocked: true, progress: 1.0,
),
AchievementBadge(
id: 'badge-03', title: 'Gold Rush',
description: 'Reach Gold tier (500 EP).',
iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
),
AchievementBadge(
id: 'badge-04', title: 'Top 10',
description: 'Appear in the district leaderboard top 10.',
iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
),
AchievementBadge(
id: 'badge-05', title: 'Image Pro',
description: 'Submit 10 events with 3+ images.',
iconName: 'photo_library', isUnlocked: false, progress: 0.3,
),
AchievementBadge(
id: 'badge-06', title: 'Pioneer',
description: 'One of the first 100 contributors.',
iconName: 'verified', isUnlocked: true, progress: 1.0,
),
];
}
}

View File

@@ -13,6 +13,11 @@ import 'core/theme_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await ThemeManager.init(); // load saved theme preference await ThemeManager.init(); // load saved theme preference
// Increase image cache for smoother scrolling and faster re-renders
PaintingBinding.instance.imageCache.maximumSize = 500;
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 * 1024 * 1024; // 200 MB
runApp(const MyApp()); runApp(const MyApp());
} }

View File

@@ -1,10 +1,12 @@
// lib/screens/calendar_screen.dart // lib/screens/calendar_screen.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../features/events/services/events_service.dart'; import '../features/events/services/events_service.dart';
import '../features/events/models/event_models.dart'; import '../features/events/models/event_models.dart';
import 'learn_more_screen.dart'; import 'learn_more_screen.dart';
import '../core/app_decoration.dart'; import '../core/app_decoration.dart';
// landscape_section_header no longer needed for this screen
class CalendarScreen extends StatefulWidget { class CalendarScreen extends StatefulWidget {
const CalendarScreen({Key? key}) : super(key: key); const CalendarScreen({Key? key}) : super(key: key);
@@ -511,7 +513,18 @@ class _CalendarScreenState extends State<CalendarScreen> {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)), borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
child: imgUrl != null ? Image.network(imgUrl, height: 150, width: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: 150, color: theme.dividerColor)) : Container(height: 150, color: theme.dividerColor, child: Icon(Icons.event, size: 44, color: theme.hintColor)), child: imgUrl != null
? CachedNetworkImage(
imageUrl: imgUrl,
memCacheWidth: 400,
memCacheHeight: 300,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(height: 150, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(height: 150, color: theme.dividerColor),
)
: Container(height: 150, color: theme.dividerColor, child: Icon(Icons.event, size: 44, color: theme.hintColor)),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14), padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
@@ -537,50 +550,266 @@ class _CalendarScreenState extends State<CalendarScreen> {
); );
} }
@override // ── Landscape: event card for the right panel ───────────────────────────
Widget build(BuildContext context) { Widget _eventCardLandscape(EventModel e) {
final width = MediaQuery.of(context).size.width;
final isMobile = width < 700;
final theme = Theme.of(context); final theme = Theme.of(context);
final imgUrl = (e.thumbImg != null && e.thumbImg!.isNotEmpty)
? e.thumbImg!
: (e.images.isNotEmpty ? e.images.first.image : null);
final dateLabel = (e.startDate != null && e.endDate != null && e.startDate == e.endDate)
? '${e.startDate}'
: (e.startDate != null && e.endDate != null
? '${e.startDate} ${e.endDate}'
: (e.startDate ?? ''));
// For non-mobile, keep original split layout return GestureDetector(
if (!isMobile) { onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
return Scaffold( child: Container(
backgroundColor: theme.scaffoldBackgroundColor, margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
body: SafeArea( decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, 3))],
),
child: Row( child: Row(
children: [ children: [
Expanded(flex: 3, child: Padding(padding: const EdgeInsets.all(24.0), child: _calendarCard(context))), // Image
Expanded(flex: 1, child: _detailsPanel()), ClipRRect(
borderRadius: const BorderRadius.horizontal(left: Radius.circular(14)),
child: imgUrl != null
? CachedNetworkImage(
imageUrl: imgUrl,
memCacheWidth: 300,
memCacheHeight: 300,
width: 100,
height: 100,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 100, height: 100, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
width: 100,
height: 100,
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
)
: Container(
width: 100,
height: 100,
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
e.title ?? e.name ?? '',
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Date row with blue dot
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(child: Text(dateLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
const SizedBox(height: 6),
// Venue row with green dot
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF22C55E),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(child: Text(e.place ?? '', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
]),
],
),
),
),
], ],
), ),
), ),
); );
} }
// MOBILE layout // ── Landscape: left panel content (calendar on white bg) ─────────────────
// Stack: extended gradient panel (below appbar) that visually extends behind the calendar. Widget _landscapeLeftPanel(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 20),
// Title
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
"Event's Calendar",
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
),
const SizedBox(height: 12),
// Calendar card — reuses the mobile _calendarCard widget
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
children: [
_calendarCard(context),
if (_loadingMonth)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: LinearProgressIndicator(
color: theme.colorScheme.primary,
backgroundColor: theme.colorScheme.primary.withOpacity(0.12),
),
),
],
),
),
),
],
),
);
}
// ── Landscape: right panel (event list for selected day) ────────────────
Widget _landscapeRightPanel(BuildContext context) {
final theme = Theme.of(context);
final dayName = DateFormat('EEEE').format(selectedDate);
final dateFormatted = DateFormat('d MMMM, yyyy').format(selectedDate);
final count = _eventsOfDay.length;
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Date header matching Figma: "Monday, 16 June, 2025 — 2 Events"
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$dayName, $dateFormatted',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 4),
Text(
'$count ${count == 1 ? "Event" : "Events"}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Divider
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Divider(height: 1, color: theme.dividerColor),
),
const SizedBox(height: 12),
// Scrollable event list
Expanded(
child: _loadingDay
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
: _eventsOfDay.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 56, color: theme.hintColor),
const SizedBox(height: 12),
Text(
'No events on this date',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
],
),
)
: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.only(top: 4, bottom: 32),
itemCount: _eventsOfDay.length,
itemBuilder: (ctx, i) => _eventCardLandscape(_eventsOfDay[i]),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final isLandscape = width >= 820;
final theme = Theme.of(context);
// ── LANDSCAPE layout ──────────────────────────────────────────────────
if (isLandscape) {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: Row(
children: [
// Left: Calendar panel with WHITE background (~60%)
Flexible(
flex: 3,
child: RepaintBoundary(
child: Container(
color: theme.cardColor,
child: _landscapeLeftPanel(context),
),
),
),
// Vertical divider between panels
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
// Right: Events panel (~40%)
Flexible(
flex: 2,
child: RepaintBoundary(
child: _landscapeRightPanel(context),
),
),
],
),
);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
// (unchanged from original)
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
body: Stack( body: Stack(
children: [ children: [
// Extended blue gradient panel behind calendar (matches reference) // TOP APP BAR stays fixed (title + bell icon)
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 260, // controls how much gradient shows behind calendar
decoration: AppDecoration.blueGradient.copyWith(
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)),
boxShadow: [const BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
),
// leave child empty — title and bell are placed above
child: const SizedBox.shrink(),
),
),
// TOP APP BAR (title centered + notification at top-right) - unchanged placement
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@@ -627,32 +856,73 @@ class _CalendarScreenState extends State<CalendarScreen> {
), ),
), ),
// CONTENT: calendar card overlapped on gradient, then summary and list // CONTENT: gradient + calendar card scroll together as one unit
Column( CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// Gradient + calendar card in one scrollable Stack
// Gradient scrolls away with content; app bar remains fixed above
SliverToBoxAdapter(
child: Stack(
children: [ children: [
const SizedBox(height: 110), // leave space for appbar + some gradient top // Blue gradient banner scrolls with content
_calendarCard(context), // calendar card sits visually on top of the gradient Container(
_selectedDateSummary(context), height: 260,
Expanded( decoration: AppDecoration.blueGradient.copyWith(
child: _loadingDay borderRadius: const BorderRadius.only(
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary)) bottomLeft: Radius.circular(30),
: _eventsOfDay.isEmpty bottomRight: Radius.circular(30),
? Center( ),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
),
),
// Calendar card starts at y=110 (after app bar), overlapping gradient
Padding(
padding: const EdgeInsets.only(top: 110),
child: _calendarCard(context),
),
],
),
),
// Selected date summary
SliverToBoxAdapter(child: _selectedDateSummary(context)),
// Events area — loading / empty / list
if (_loadingDay)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: CircularProgressIndicator(color: theme.colorScheme.primary),
),
)
else if (_eventsOfDay.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.event_available, size: 48, color: theme.hintColor), Icon(Icons.event_available, size: 48, color: theme.hintColor),
const SizedBox(height: 10), const SizedBox(height: 10),
Text('No events scheduled for this date', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)), Text(
'No events scheduled for this date',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
], ],
), ),
),
) )
: ListView.builder( else
padding: const EdgeInsets.only(top: 6, bottom: 32), SliverList(
itemCount: _eventsOfDay.length, delegate: SliverChildBuilderDelegate(
itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx]), (context, idx) => _eventCardMobile(_eventsOfDay[idx]),
childCount: _eventsOfDay.length,
), ),
), ),
// Bottom padding
const SliverToBoxAdapter(child: SizedBox(height: 32)),
], ],
), ),
], ],
@@ -660,44 +930,4 @@ class _CalendarScreenState extends State<CalendarScreen> {
); );
} }
Widget _detailsPanel() {
final theme = Theme.of(context);
final shortDate = DateFormat('EEE, d MMM').format(selectedDate);
final eventsCount = _eventsOfDay.length;
Widget _buildHeaderCompact() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: AppDecoration.blueGradientRounded(10),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('${selectedDate.day}', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Text(monthNames[selectedDate.month].substring(0, 3), style: const TextStyle(color: Colors.white70, fontSize: 10))]),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(shortDate, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('$eventsCount ${eventsCount == 1 ? "Event" : "Events"}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor))]),
const Spacer(),
IconButton(onPressed: () {}, icon: Icon(Icons.more_horiz, color: Theme.of(context).iconTheme.color)),
],
),
);
}
return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildHeaderCompact(),
Divider(height: 1, color: theme.dividerColor),
Expanded(
child: _loadingDay
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
: _eventsOfDay.isEmpty
? const SizedBox.shrink()
: ListView.builder(padding: const EdgeInsets.only(top: 6, bottom: 24), itemCount: _eventsOfDay.length, itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx])),
)
]),
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../features/auth/services/auth_service.dart'; import '../features/auth/services/auth_service.dart';
import '../core/auth/auth_guard.dart';
import 'home_desktop_screen.dart'; import 'home_desktop_screen.dart';
import '../core/app_decoration.dart'; import '../core/app_decoration.dart';
@@ -241,7 +242,17 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
alignment: WrapAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
children: [ children: [
TextButton(onPressed: _openRegister, 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: () {}, child: const Text('Contact support')),
TextButton(
onPressed: () {
AuthGuard.setGuest(true);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
(route) => false,
);
},
child: const Text('Continue as Guest'),
),
], ],
) )
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,9 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../core/auth/auth_guard.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import '../features/events/models/event_models.dart'; import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart'; import '../features/events/services/events_service.dart';
@@ -12,7 +15,10 @@ import 'contribute_screen.dart';
import 'learn_more_screen.dart'; import 'learn_more_screen.dart';
import 'search_screen.dart'; import 'search_screen.dart';
import '../core/app_decoration.dart'; import '../core/app_decoration.dart';
import 'package:geocoding/geocoding.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
import '../features/gamification/providers/gamification_provider.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key); const HomeScreen({Key? key}) : super(key: key);
@@ -37,13 +43,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
bool _loading = true; bool _loading = true;
// Hero carousel // Hero carousel
final PageController _heroPageController = PageController(); final PageController _heroPageController = PageController(viewportFraction: 0.88);
int _heroCurrentPage = 0; late final ValueNotifier<int> _heroPageNotifier;
Timer? _autoScrollTimer; Timer? _autoScrollTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_heroPageNotifier = ValueNotifier(0);
_loadUserDataAndEvents(); _loadUserDataAndEvents();
_startAutoScroll(); _startAutoScroll();
} }
@@ -52,18 +59,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
void dispose() { void dispose() {
_autoScrollTimer?.cancel(); _autoScrollTimer?.cancel();
_heroPageController.dispose(); _heroPageController.dispose();
_heroPageNotifier.dispose();
super.dispose(); super.dispose();
} }
void _startAutoScroll() { void _startAutoScroll({Duration delay = const Duration(seconds: 5)}) {
_autoScrollTimer?.cancel(); _autoScrollTimer?.cancel();
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) { _autoScrollTimer = Timer.periodic(delay, (timer) {
if (_heroEvents.isEmpty) return; if (_heroEvents.isEmpty) return;
final nextPage = (_heroCurrentPage + 1) % _heroEvents.length; final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
if (_heroPageController.hasClients) { if (_heroPageController.hasClients) {
_heroPageController.animateToPage( _heroPageController.animateToPage(
nextPage, nextPage,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
} }
@@ -74,17 +82,37 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
setState(() => _loading = true); setState(() => _loading = true);
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_username = prefs.getString('display_name') ?? prefs.getString('username') ?? ''; _username = prefs.getString('display_name') ?? prefs.getString('username') ?? '';
_location = prefs.getString('location') ?? 'Whitefield, Bengaluru'; 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) {
_location = 'Current Location';
setState(() {});
// Reverse geocode in background to get actual place name
_reverseGeocodeAndSave(
double.parse(coordMatch.group(1)!),
double.parse(coordMatch.group(2)!),
prefs,
);
} else {
_location = storedLocation;
}
_pincode = prefs.getString('pincode') ?? 'all'; _pincode = prefs.getString('pincode') ?? 'all';
try { try {
final types = await _events_service_getEventTypesSafe(); // Fetch types and events in parallel for faster loading
final events = await _events_service_getEventsSafe(_pincode); final results = await Future.wait([
_events_service_getEventTypesSafe(),
_events_service_getEventsSafe(_pincode),
]);
final types = results[0] as List<EventTypeModel>;
final events = results[1] as List<EventModel>;
if (mounted) { if (mounted) {
setState(() { setState(() {
_types = types; _types = types;
_events = events; _events = events;
_selectedTypeId = -1; _selectedTypeId = -1;
_cachedFilteredEvents = null; // invalidate cache
}); });
} }
} catch (e) { } catch (e) {
@@ -96,6 +124,24 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
} }
} }
Future<void> _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async {
try {
final placemarks = await placemarkFromCoordinates(lat, lng);
if (placemarks.isNotEmpty) {
final p = placemarks.first;
final parts = <String>[];
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
await prefs.setString('location', label);
if (mounted) setState(() => _location = label);
return;
}
} catch (_) {}
await prefs.setString('location', 'Current Location');
}
Future<List<EventTypeModel>> _events_service_getEventTypesSafe() async { Future<List<EventTypeModel>> _events_service_getEventTypesSafe() async {
try { try {
return await _eventsService.getEventTypes(); return await _eventsService.getEventTypes();
@@ -157,10 +203,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: imageUrl != null && imageUrl.isNotEmpty child: imageUrl != null && imageUrl.isNotEmpty
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.network( child: CachedNetworkImage(
imageUrl, imageUrl: imageUrl,
memCacheWidth: 112,
memCacheHeight: 112,
fit: BoxFit.contain, fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon( placeholder: (_, __) => Icon(
icon ?? Icons.category,
size: 36,
color: selected ? Colors.white.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.3),
),
errorWidget: (_, __, ___) => Icon(
icon ?? Icons.category, icon ?? Icons.category,
size: 36, size: 36,
color: selected ? Colors.white : theme.colorScheme.primary, color: selected ? Colors.white : theme.colorScheme.primary,
@@ -310,9 +363,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))), child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
) )
else else
ListView.separated( ConstrainedBox(
shrinkWrap: true, constraints: const BoxConstraints(maxHeight: 400),
physics: const NeverScrollableScrollPhysics(), child: ListView.separated(
shrinkWrap: false,
physics: const ClampingScrollPhysics(),
itemBuilder: (ctx, idx) { itemBuilder: (ctx, idx) {
final ev = results[idx]; final ev = results[idx];
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null); final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
@@ -321,7 +376,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
leading: img != null && img.isNotEmpty leading: img != null && img.isNotEmpty
? ClipRRect(borderRadius: BorderRadius.circular(8), child: Image.network(img, width: 56, height: 56, fit: BoxFit.cover)) ? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: img,
memCacheWidth: 112,
memCacheHeight: 112,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 56, height: 56, color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(width: 56, height: 56, color: theme.dividerColor, child: Icon(Icons.event, color: theme.hintColor)),
),
)
: Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)), : Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)),
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge), title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge),
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)), subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
@@ -335,7 +402,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
}, },
separatorBuilder: (_, __) => Divider(color: theme.dividerColor), separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
itemCount: results.length, itemCount: results.length,
), )), // ConstrainedBox
], ],
), ),
), ),
@@ -357,13 +424,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
body: Stack( body: Stack(
children: [ children: [
// IndexedStack keeps each tab alive and preserves state. // IndexedStack keeps each tab alive and preserves state.
// RepaintBoundary isolates each tab so inactive tabs don't trigger repaints.
IndexedStack( IndexedStack(
index: _selectedIndex, index: _selectedIndex,
children: [ children: [
_buildHomeContent(), // index 0 RepaintBoundary(child: _buildHomeContent()), // index 0
const CalendarScreen(), // index 1 const RepaintBoundary(child: CalendarScreen()), // index 1
const ContributeScreen(), // index 2 (full page, scrollable) RepaintBoundary(
const ProfileScreen(), // index 3 child: ChangeNotifierProvider(
create: (_) => GamificationProvider(),
child: const ContributeScreen(),
),
), // index 2 (full page, scrollable)
const RepaintBoundary(child: ProfileScreen()), // index 3
], ],
), ),
@@ -410,7 +483,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
final active = _selectedIndex == index; final active = _selectedIndex == index;
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _selectedIndex = index), onTap: () {
if (index == 2 && !AuthGuard.requireLogin(context, reason: 'Sign in to contribute events and earn rewards.')) return;
if (index == 3 && !AuthGuard.requireLogin(context, reason: 'Sign in to view your profile.')) return;
setState(() => _selectedIndex = index);
},
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -439,17 +516,46 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// Get hero events (first 4 events for the carousel) // Get hero events (first 4 events for the carousel)
List<EventModel> get _heroEvents => _events.take(4).toList(); List<EventModel> get _heroEvents => _events.take(6).toList();
String _formatDate(String dateStr) {
try {
final dt = DateTime.parse(dateStr);
return DateFormat('d MMM yyyy').format(dt);
} catch (_) {
return dateStr;
}
}
String _getEventTypeName(EventModel event) {
if (event.eventTypeId != null && event.eventTypeId! > 0) {
final match = _types.where((t) => t.id == event.eventTypeId);
if (match.isNotEmpty && match.first.name.isNotEmpty) {
return match.first.name.toUpperCase();
}
}
return 'EVENT';
}
// Date filter state // Date filter state
String _selectedDateFilter = ''; String _selectedDateFilter = '';
DateTime? _selectedCustomDate; DateTime? _selectedCustomDate;
// Cached filtered events to avoid repeated DateTime.parse() calls
List<EventModel>? _cachedFilteredEvents;
String _cachedFilterKey = '';
/// Returns the subset of [_events] that match the active date-filter chip. /// Returns the subset of [_events] that match the active date-filter chip.
/// If no chip is selected the full list is returned. /// Uses caching to avoid re-parsing dates on every access.
List<EventModel> get _filteredEvents { List<EventModel> get _filteredEvents {
if (_selectedDateFilter.isEmpty) return _events; if (_selectedDateFilter.isEmpty) return _events;
// Build a cache key from filter state
final cacheKey = '$_selectedDateFilter|$_selectedCustomDate|${_events.length}';
if (_cachedFilteredEvents != null && _cachedFilterKey == cacheKey) {
return _cachedFilteredEvents!;
}
final now = DateTime.now(); final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day); final today = DateTime(now.year, now.month, now.day);
@@ -481,7 +587,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return _events; return _events;
} }
return _events.where((e) { _cachedFilteredEvents = _events.where((e) {
try { try {
final eStart = DateTime.parse(e.startDate); final eStart = DateTime.parse(e.startDate);
final eEnd = DateTime.parse(e.endDate); final eEnd = DateTime.parse(e.endDate);
@@ -491,6 +597,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return false; return false;
} }
}).toList(); }).toList();
_cachedFilterKey = cacheKey;
return _cachedFilteredEvents!;
} }
Future<void> _onDateChipTap(String label) async { Future<void> _onDateChipTap(String label) async {
@@ -501,6 +609,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
setState(() { setState(() {
_selectedCustomDate = picked; _selectedCustomDate = picked;
_selectedDateFilter = 'Date'; _selectedDateFilter = 'Date';
_cachedFilteredEvents = null; // invalidate cache
}); });
_showFilteredEventsSheet( _showFilteredEventsSheet(
'${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}', '${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}',
@@ -509,12 +618,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
setState(() { setState(() {
_selectedDateFilter = ''; _selectedDateFilter = '';
_selectedCustomDate = null; _selectedCustomDate = null;
_cachedFilteredEvents = null;
}); });
} }
} else { } else {
setState(() { setState(() {
_selectedDateFilter = label; _selectedDateFilter = label;
_selectedCustomDate = null; _selectedCustomDate = null;
_cachedFilteredEvents = null; // invalidate cache
}); });
_showFilteredEventsSheet(label); _showFilteredEventsSheet(label);
} }
@@ -663,12 +774,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) { if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) {
imageWidget = ClipRRect( imageWidget = ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.network( child: CachedNetworkImage(
imageUrl, imageUrl: imageUrl,
memCacheWidth: 160,
memCacheHeight: 160,
width: 80, width: 80,
height: 80, height: 80,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( placeholder: (_, __) => Container(
width: 80, height: 80,
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: 80, height: 80, width: 80, height: 80,
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.image, color: Colors.grey.shade400), child: Icon(Icons.image, color: Colors.grey.shade400),
@@ -1066,23 +1184,51 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
// Featured carousel // Featured carousel
_heroEvents.isEmpty _heroEvents.isEmpty
? SizedBox( ? _loading
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
height: 320,
child: _HeroShimmer(),
),
)
: const SizedBox(
height: 280, height: 280,
child: Center( child: Center(
child: _loading child: Text('No events available',
? const CircularProgressIndicator(color: Colors.white) style: TextStyle(color: Colors.white70)),
: const Text('No events available', style: TextStyle(color: Colors.white70)),
), ),
) )
: Column( : Column(
children: [ children: [
SizedBox( RepaintBoundary(
height: 300, child: SizedBox(
height: 320,
child: PageView.builder( child: PageView.builder(
controller: _heroPageController, controller: _heroPageController,
onPageChanged: (page) => setState(() => _heroCurrentPage = page), onPageChanged: (page) {
_heroPageNotifier.value = page;
// 8s delay after manual swipe for full read time
_startAutoScroll(delay: const Duration(seconds: 8));
},
itemCount: _heroEvents.length, itemCount: _heroEvents.length,
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]), itemBuilder: (context, index) {
// Scale animation: active card = 1.0, adjacent = 0.94
return AnimatedBuilder(
animation: _heroPageController,
builder: (context, child) {
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
if (_heroPageController.position.haveDimensions) {
scale = (1.0 -
(_heroPageController.page! - index).abs() * 0.06)
.clamp(0.94, 1.0);
}
return Transform.scale(scale: scale, child: child);
},
child: _buildHeroEventImage(_heroEvents[index]),
);
},
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -1097,14 +1243,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
} }
Widget _buildCarouselDots() { Widget _buildCarouselDots() {
return ValueListenableBuilder<int>(
valueListenable: _heroPageNotifier,
builder: (context, currentPage, _) {
return SizedBox( return SizedBox(
height: 12, height: 44,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate( children: List.generate(
_heroEvents.isEmpty ? 5 : _heroEvents.length, _heroEvents.isEmpty ? 5 : _heroEvents.length,
(i) { (i) {
final isActive = i == _heroCurrentPage; final isActive = i == currentPage;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (_heroPageController.hasClients) { if (_heroPageController.hasClients) {
@@ -1112,20 +1261,30 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut); duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
} }
}, },
child: Container( child: SizedBox(
margin: const EdgeInsets.symmetric(horizontal: 4), width: 44,
height: 44,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: isActive ? 24 : 8, width: isActive ? 24 : 8,
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.4), color: isActive
? Colors.white
: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
),
),
); );
}, },
), ),
), ),
); );
},
);
} }
/// Build a hero image card with the image only (rounded), /// Build a hero image card with the image only (rounded),
@@ -1138,58 +1297,150 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
img = event.images.first.image; img = event.images.first.image;
} }
final radius = 24.0; const double radius = 24.0;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (event.id != null) { if (event.id != null) {
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id))); Navigator.push(context,
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
} }
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Column(
children: [
// Image only (no text overlay)
Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(radius), borderRadius: BorderRadius.circular(radius),
child: SizedBox( child: Stack(
width: double.infinity, fit: StackFit.expand,
child: img != null && img.isNotEmpty children: [
? Image.network( // ── Layer 0: Event image (full-bleed) ──
img, img != null && img.isNotEmpty
? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 700,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => placeholder: (_, __) => const _HeroShimmer(radius: radius),
errorWidget: (_, __, ___) =>
Container(decoration: AppDecoration.blueGradientRounded(radius)), Container(decoration: AppDecoration.blueGradientRounded(radius)),
) )
: Container( : Container(decoration: AppDecoration.blueGradientRounded(radius)),
decoration: AppDecoration.blueGradientRounded(radius),
// ── Layer 1: Bottom gradient overlay (text readability) ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.35, 1.0],
colors: [
Colors.transparent,
Colors.black.withOpacity(0.78),
],
), ),
), ),
), ),
), ),
// Title text outside the image // ── Layer 2: Event type glassmorphism badge (top-left) ──
const SizedBox(height: 12), Positioned(
top: 14,
left: 14,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.star_rounded, color: Colors.amber, size: 13),
const SizedBox(width: 4),
Text(
_getEventTypeName(event),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.6,
),
),
],
),
),
),
),
),
// ── Layer 3: Title + metadata (bottom overlay) ──
Positioned(
bottom: 18,
left: 16,
right: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text( Text(
event.title ?? event.name ?? '', event.title ?? event.name ?? '',
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 22, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w800,
height: 1.2, height: 1.25,
shadows: [ shadows: [
Shadow(color: Colors.black38, blurRadius: 6, offset: Offset(0, 2)), Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
],
),
),
const SizedBox(height: 8),
Row(
children: [
if (event.startDate != null) ...[
const Icon(Icons.calendar_today_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Text(
_formatDate(event.startDate!),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
const SizedBox(width: 10),
],
if (event.place != null && event.place!.isNotEmpty) ...[
const Icon(Icons.location_on_rounded,
color: Colors.white70, size: 12),
const SizedBox(width: 4),
Flexible(
child: Text(
event.place!,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
],
],
),
], ],
), ),
), ),
], ],
), ),
), ),
),
); );
} }
@@ -1426,10 +1677,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
children: [ children: [
// Background image // Background image
img != null && img.isNotEmpty img != null && img.isNotEmpty
? Image.network( ? CachedNetworkImage(
img, imageUrl: img,
memCacheWidth: 300,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( width: double.infinity,
height: double.infinity,
placeholder: (_, __) => Container(
color: const Color(0xFF374151),
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white38))),
),
errorWidget: (_, __, ___) => Container(
color: const Color(0xFF374151), color: const Color(0xFF374151),
child: const Icon(Icons.image, color: Colors.white38, size: 40), child: const Icon(Icons.image, color: Colors.white38, size: 40),
), ),
@@ -1613,7 +1871,15 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: img != null && img.isNotEmpty child: img != null && img.isNotEmpty
? Image.network(img, width: 96, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(width: 96, color: theme.dividerColor)) ? CachedNetworkImage(
imageUrl: img,
memCacheWidth: 192,
width: 96,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(width: 96, color: theme.dividerColor, child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
errorWidget: (_, __, ___) => Container(width: 96, color: theme.dividerColor),
)
: Container(width: 96, height: double.infinity, color: theme.dividerColor), : Container(width: 96, height: double.infinity, color: theme.dividerColor),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
@@ -1671,12 +1937,23 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: img != null && img.isNotEmpty child: img != null && img.isNotEmpty
? Image.network( ? CachedNetworkImage(
img, imageUrl: img,
memCacheWidth: 440,
memCacheHeight: 360,
width: 220, width: 220,
height: 180, height: 180,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( placeholder: (_, __) => Container(
width: 220,
height: 180,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: BorderRadius.circular(18),
),
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: 220, width: 220,
height: 180, height: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1833,12 +2110,23 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
ClipRRect( ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
child: img != null && img.isNotEmpty child: img != null && img.isNotEmpty
? Image.network( ? CachedNetworkImage(
img, imageUrl: img,
memCacheWidth: 800,
memCacheHeight: 400,
width: double.infinity, width: double.infinity,
height: 200, height: 200,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( placeholder: (_, __) => Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: theme.dividerColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: const Center(child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2))),
),
errorWidget: (_, __, ___) => Container(
width: double.infinity, width: double.infinity,
height: 200, height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1961,7 +2249,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
try { try {
final all = await _eventsService.getEventsByPincode(_pincode); final all = await _eventsService.getEventsByPincode(_pincode);
final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList(); final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList();
if (mounted) setState(() => _events = filtered); if (mounted) setState(() { _events = filtered; _cachedFilteredEvents = null; });
} catch (e) { } catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
} }
@@ -1975,3 +2263,57 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
return 'You'; return 'You';
} }
} }
/// Animated shimmer placeholder shown while a hero card image is loading.
/// Renders a blue-toned scan-line effect matching the app's colour palette.
class _HeroShimmer extends StatefulWidget {
final double radius;
const _HeroShimmer({this.radius = 24.0});
@override
State<_HeroShimmer> createState() => _HeroShimmerState();
}
class _HeroShimmerState extends State<_HeroShimmer>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _ctrl,
builder: (_, __) {
final x = -1.5 + _ctrl.value * 3.0;
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.radius),
gradient: LinearGradient(
begin: Alignment(x - 1.0, 0),
end: Alignment(x, 0),
colors: const [
Color(0xFF1A2A4A),
Color(0xFF2D4580),
Color(0xFF1A2A4A),
],
),
),
);
},
);
}
}

View File

@@ -6,9 +6,12 @@ import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../features/events/models/event_models.dart'; import '../features/events/models/event_models.dart';
import '../features/events/services/events_service.dart'; import '../features/events/services/events_service.dart';
import '../core/auth/auth_guard.dart';
import '../core/constants.dart';
class LearnMoreScreen extends StatefulWidget { class LearnMoreScreen extends StatefulWidget {
final int eventId; final int eventId;
@@ -27,7 +30,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// Carousel // Carousel
final PageController _pageController = PageController(); final PageController _pageController = PageController();
int _currentPage = 0; late final ValueNotifier<int> _pageNotifier;
Timer? _autoScrollTimer; Timer? _autoScrollTimer;
// About section // About section
@@ -44,6 +47,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pageNotifier = ValueNotifier(0);
_loadEvent(); _loadEvent();
} }
@@ -51,6 +55,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
void dispose() { void dispose() {
_autoScrollTimer?.cancel(); _autoScrollTimer?.cancel();
_pageController.dispose(); _pageController.dispose();
_pageNotifier.dispose();
_mapController?.dispose(); _mapController?.dispose();
super.dispose(); super.dispose();
} }
@@ -98,7 +103,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
if (count <= 1) return; if (count <= 1) return;
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (_) { _autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
if (!_pageController.hasClients) return; if (!_pageController.hasClients) return;
final next = (_currentPage + 1) % count; final next = (_pageNotifier.value + 1) % count;
_pageController.animateToPage(next, _pageController.animateToPage(next,
duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
}); });
@@ -222,10 +227,280 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
); );
} }
final screenHeight = MediaQuery.of(context).size.height; final mediaQuery = MediaQuery.of(context);
final screenWidth = mediaQuery.size.width;
final screenHeight = mediaQuery.size.height;
final imageHeight = screenHeight * 0.45; final imageHeight = screenHeight * 0.45;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = mediaQuery.padding.top;
// ── DESKTOP layout ──────────────────────────────────────────────────
if (screenWidth >= AppConstants.desktopBreakpoint) {
final images = _imageUrls;
final heroImage = images.isNotEmpty ? images[0] : null;
final venueLabel = _event!.locationName ?? _event!.venueName ?? _event!.place ?? '';
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Hero image with gradient overlay ──
SizedBox(
width: double.infinity,
height: 300,
child: Stack(
fit: StackFit.expand,
children: [
// Background image
if (heroImage != null)
CachedNetworkImage(
imageUrl: heroImage,
fit: BoxFit.cover,
placeholder: (_, __) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
errorWidget: (_, __, ___) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
)
else
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withOpacity(0.3),
Colors.black.withOpacity(0.65),
],
),
),
),
// Top bar: back + share + wishlist
Positioned(
top: topPadding + 10,
left: 16,
right: 16,
child: Row(
children: [
_squareIconButton(icon: Icons.arrow_back, onTap: () => Navigator.pop(context)),
const SizedBox(width: 8),
_squareIconButton(icon: Icons.ios_share_outlined, onTap: _shareEvent),
const SizedBox(width: 8),
_squareIconButton(
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
onTap: () {
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
setState(() => _wishlisted = !_wishlisted);
},
),
],
),
),
// Title + date + venue overlaid at bottom-left
Positioned(
left: 32,
bottom: 28,
right: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
_event!.title ?? _event!.name,
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 28,
height: 1.2,
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.calendar_today_outlined, size: 16, color: Colors.white70),
const SizedBox(width: 6),
Text(
_formattedDateRange(),
style: const TextStyle(color: Colors.white70, fontSize: 15),
),
if (venueLabel.isNotEmpty) ...[
const SizedBox(width: 16),
const Icon(Icons.location_on_outlined, size: 16, color: Colors.white70),
const SizedBox(width: 4),
Flexible(
child: Text(
venueLabel,
style: const TextStyle(color: Colors.white70, fontSize: 15),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
],
),
),
// "Book Your Spot" CTA on the right
Positioned(
right: 32,
bottom: 36,
child: ElevatedButton(
onPressed: () {
// TODO: implement booking action
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
),
child: const Text(
'Book Your Spot',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
],
),
),
const SizedBox(height: 28),
// ── Two-column: About (left 60%) + Venue/Map (right 40%) ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left column — About the Event
Expanded(
flex: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutSection(theme),
if (_event!.importantInfo.isNotEmpty)
_buildImportantInfoSection(theme),
if (_event!.importantInfo.isEmpty &&
(_event!.importantInformation ?? '').isNotEmpty)
_buildImportantInfoFallback(theme),
],
),
),
const SizedBox(width: 32),
// Right column — Venue / map
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_event!.latitude != null && _event!.longitude != null) ...[
_buildVenueSection(theme),
const SizedBox(height: 12),
_buildGetDirectionsButton(theme),
],
],
),
),
],
),
),
// ── Gallery: horizontal scrollable image strip ──
if (images.length > 1) ...[
const SizedBox(height: 32),
Padding(
padding: const EdgeInsets.only(left: 32),
child: Text(
'Gallery',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 20,
),
),
),
const SizedBox(height: 14),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 32),
itemCount: images.length > 6 ? 6 : images.length,
itemBuilder: (context, i) {
// Show overflow count badge on last visible item
final isLast = i == 5 && images.length > 6;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: SizedBox(
width: 220,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: images[i],
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.broken_image, color: theme.hintColor),
),
),
if (isLast)
Container(
color: Colors.black.withOpacity(0.55),
alignment: Alignment.center,
child: Text(
'+${images.length - 6}',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
);
},
),
),
],
const SizedBox(height: 80),
],
),
),
);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
body: Stack( body: Stack(
@@ -310,10 +585,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// Pill-shaped page indicators (centered) // Pill-shaped page indicators (centered)
Expanded( Expanded(
child: _imageUrls.length > 1 child: _imageUrls.length > 1
? Row( ? ValueListenableBuilder<int>(
valueListenable: _pageNotifier,
builder: (context, currentPage, _) => Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_imageUrls.length, (i) { children: List.generate(_imageUrls.length, (i) {
final active = i == _currentPage; final active = i == currentPage;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 3), margin: const EdgeInsets.symmetric(horizontal: 3),
@@ -327,6 +604,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
), ),
); );
}), }),
),
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
@@ -338,7 +616,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
_squareIconButton( _squareIconButton(
icon: _wishlisted ? Icons.favorite : Icons.favorite_border, icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
iconColor: _wishlisted ? Colors.redAccent : Colors.white, iconColor: _wishlisted ? Colors.redAccent : Colors.white,
onTap: () => setState(() => _wishlisted = !_wishlisted), onTap: () {
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
setState(() => _wishlisted = !_wishlisted);
},
), ),
], ],
), ),
@@ -354,6 +635,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Widget _buildLoadingShimmer(ThemeData theme) { Widget _buildLoadingShimmer(ThemeData theme) {
final shimmerHeight = MediaQuery.of(context).size.height;
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
@@ -362,7 +644,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
children: [ children: [
// Placeholder image // Placeholder image
Container( Container(
height: MediaQuery.of(context).size.height * 0.42, height: shimmerHeight * 0.42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.dividerColor.withOpacity(0.3), color: theme.dividerColor.withOpacity(0.3),
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -430,10 +712,14 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Image.network( ValueListenableBuilder<int>(
images[_currentPage], valueListenable: _pageNotifier,
builder: (context, currentPage, _) => CachedNetworkImage(
imageUrl: images[currentPage],
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( width: double.infinity,
height: double.infinity,
placeholder: (_, __) => Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
@@ -442,6 +728,16 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
), ),
), ),
), ),
errorWidget: (_, __, ___) => Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
),
),
),
),
), ),
BackdropFilter( BackdropFilter(
filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
@@ -474,13 +770,17 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: PageView.builder( child: PageView.builder(
controller: _pageController, controller: _pageController,
onPageChanged: (i) => setState(() => _currentPage = i), onPageChanged: (i) => _pageNotifier.value = i,
itemCount: images.length, itemCount: images.length,
itemBuilder: (_, i) => Image.network( itemBuilder: (_, i) => CachedNetworkImage(
images[i], imageUrl: images[i],
fit: BoxFit.cover, fit: BoxFit.cover,
width: double.infinity, width: double.infinity,
errorBuilder: (_, __, ___) => Container( placeholder: (_, __) => Container(
color: theme.dividerColor,
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor, color: theme.dividerColor,
child: Icon(Icons.broken_image, size: 48, color: theme.hintColor), child: Icon(Icons.broken_image, size: 48, color: theme.hintColor),
), ),
@@ -672,10 +972,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Image.network( child: CachedNetworkImage(
'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=', imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( errorWidget: (_, __, ___) => Container(
color: const Color(0xFFE8EAF6), color: const Color(0xFFE8EAF6),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../features/auth/services/auth_service.dart'; import '../features/auth/services/auth_service.dart';
import '../core/auth/auth_guard.dart';
import 'home_screen.dart'; import 'home_screen.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -47,9 +48,7 @@ class _LoginScreenState extends State<LoginScreen> {
} }
Future<void> _initVideo() async { Future<void> _initVideo() async {
_videoController = VideoPlayerController.networkUrl( _videoController = VideoPlayerController.asset('assets/login-bg.mp4');
Uri.parse('assets/login-bg.mp4'),
);
await _videoController.initialize(); await _videoController.initialize();
_videoController.setLooping(true); _videoController.setLooping(true);
_videoController.setVolume(0); _videoController.setVolume(0);
@@ -511,6 +510,34 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
), ),
), ),
const SizedBox(height: 16),
// Continue as Guest
Center(
child: TextButton(
onPressed: () {
AuthGuard.setGuest(true);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const HomeScreen()),
(route) => false,
);
},
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
child: const Text(
'Continue as Guest',
style: TextStyle(
color: Colors.white70,
fontSize: 15,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: Colors.white70,
),
),
),
),
], ],
), ),
), ),

View File

@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -11,6 +12,7 @@ import 'learn_more_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import '../core/app_decoration.dart'; import '../core/app_decoration.dart';
import '../core/constants.dart'; import '../core/constants.dart';
import '../widgets/landscape_section_header.dart';
class ProfileScreen extends StatefulWidget { class ProfileScreen extends StatefulWidget {
const ProfileScreen({Key? key}) : super(key: key); const ProfileScreen({Key? key}) : super(key: key);
@@ -89,24 +91,17 @@ class _ProfileScreenState extends State<ProfileScreen>
parent: _animController, parent: _animController,
curve: const Interval(0.0, 0.65, curve: Curves.easeOut), curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
); );
// Update fields without setState — AnimatedBuilder handles the rebuilds
expAnim.addListener(() { expAnim.addListener(() {
if (mounted) {
setState(() {
_expProgress = expTween.evaluate(expAnim); _expProgress = expTween.evaluate(expAnim);
}); });
}
});
// Animate stat counters: 0 → target over full 2s
_animController.addListener(() { _animController.addListener(() {
if (!mounted) return;
final t = _animController.value; final t = _animController.value;
setState(() {
_animatedLikes = (t * _targetLikes).round(); _animatedLikes = (t * _targetLikes).round();
_animatedPosts = (t * _targetPosts).round(); _animatedPosts = (t * _targetPosts).round();
_animatedViews = (t * _targetViews).round(); _animatedViews = (t * _targetViews).round();
}); });
});
_animController.forward(); _animController.forward();
}); });
@@ -273,6 +268,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final result = await showDialog<String?>( final result = await showDialog<String?>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
// Note: ctl is disposed after dialog closes below
final theme = Theme.of(ctx); final theme = Theme.of(ctx);
return AlertDialog( return AlertDialog(
title: const Text('Enter image path or URL'), title: const Text('Enter image path or URL'),
@@ -305,6 +301,7 @@ class _ProfileScreenState extends State<ProfileScreen>
}, },
); );
ctl.dispose();
if (result == null || result.isEmpty) return; if (result == null || result.isEmpty) return;
await _saveProfile(_username, _email, result); await _saveProfile(_username, _email, result);
} }
@@ -318,6 +315,7 @@ class _ProfileScreenState extends State<ProfileScreen>
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (ctx) { builder: (ctx) {
// nameCtl and emailCtl are disposed via .then() below
final theme = Theme.of(ctx); final theme = Theme.of(ctx);
return DraggableScrollableSheet( return DraggableScrollableSheet(
expand: false, expand: false,
@@ -419,7 +417,10 @@ class _ProfileScreenState extends State<ProfileScreen>
}, },
); );
}, },
); ).then((_) {
nameCtl.dispose();
emailCtl.dispose();
});
} }
// ───────── Avatar builder (reused, with size param) ───────── // ───────── Avatar builder (reused, with size param) ─────────
@@ -428,11 +429,16 @@ class _ProfileScreenState extends State<ProfileScreen>
final path = _profileImage.trim(); final path = _profileImage.trim();
if (path.startsWith('http')) { if (path.startsWith('http')) {
return ClipOval( return ClipOval(
child: Image.network(path, child: CachedNetworkImage(
imageUrl: path,
memCacheWidth: (size * 2).toInt(),
memCacheHeight: (size * 2).toInt(),
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => placeholder: (_, __) =>
Container(width: size, height: size, color: const Color(0xFFE5E7EB)),
errorWidget: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey))); Icon(Icons.person, size: size / 2, color: Colors.grey)));
} }
if (kIsWeb) { if (kIsWeb) {
@@ -497,11 +503,18 @@ class _ProfileScreenState extends State<ProfileScreen>
if (imageUrl.startsWith('http')) { if (imageUrl.startsWith('http')) {
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Image.network(imageUrl, child: CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 120,
memCacheHeight: 120,
width: 60, width: 60,
height: 60, height: 60,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container( placeholder: (_, __) => Container(
width: 60,
height: 60,
color: const Color(0xFFE5E7EB)),
errorWidget: (_, __, ___) => Container(
width: 60, width: 60,
height: 60, height: 60,
color: theme.dividerColor, color: theme.dividerColor,
@@ -736,7 +749,9 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: LayoutBuilder( child: AnimatedBuilder(
animation: _animController,
builder: (context, _) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final fullWidth = constraints.maxWidth; final fullWidth = constraints.maxWidth;
final filledWidth = fullWidth * _expProgress; final filledWidth = fullWidth * _expProgress;
@@ -744,7 +759,7 @@ class _ProfileScreenState extends State<ProfileScreen>
height: 8, height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
color: Colors.grey.shade200, // gray track color: Colors.grey.shade200,
), ),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@@ -761,6 +776,7 @@ class _ProfileScreenState extends State<ProfileScreen>
}, },
), ),
), ),
),
], ],
), ),
); );
@@ -805,7 +821,9 @@ class _ProfileScreenState extends State<ProfileScreen>
bottom: BorderSide(color: Colors.grey.shade200, width: 1), bottom: BorderSide(color: Colors.grey.shade200, width: 1),
), ),
), ),
child: IntrinsicHeight( child: AnimatedBuilder(
animation: _animController,
builder: (context, _) => IntrinsicHeight(
child: Row( child: Row(
children: [ children: [
statColumn(_formatNumber(_animatedLikes), 'Likes'), statColumn(_formatNumber(_animatedLikes), 'Likes'),
@@ -816,6 +834,7 @@ class _ProfileScreenState extends State<ProfileScreen>
], ],
), ),
), ),
),
); );
} }
@@ -995,6 +1014,534 @@ class _ProfileScreenState extends State<ProfileScreen>
); );
} }
// ═══════════════════════════════════════════════
// LANDSCAPE LAYOUT
// ═══════════════════════════════════════════════
Widget _buildLandscapeLeftPanel(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Top bar row — title + settings
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
child: Row(
children: [
const Text(
'Profile',
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700),
),
const Spacer(),
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SettingsScreen())),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.settings, color: Colors.white),
),
),
],
),
),
const SizedBox(height: 20),
// Avatar + name section
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 8, offset: const Offset(0, 2))],
),
child: _buildProfileAvatar(size: 64),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_username.isNotEmpty ? _username : 'Guest User',
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_email,
style: const TextStyle(color: Colors.white70, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
const SizedBox(height: 20),
// EXP Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _buildExpBar(),
),
const SizedBox(height: 20),
// Stats row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 16),
child: _buildLandscapeStats(context, textColor: Colors.white),
),
),
const SizedBox(height: 20),
// Edit profile button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: OutlinedButton.icon(
onPressed: _openEditDialog,
icon: const Icon(Icons.edit, size: 16, color: Colors.white),
label: const Text('Edit Profile', style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white38),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(height: 24),
],
),
),
);
}
Widget _buildLandscapeStats(BuildContext context, {Color? textColor}) {
final color = textColor ?? Theme.of(context).textTheme.bodyLarge?.color;
final hintColor = textColor?.withOpacity(0.6) ?? Theme.of(context).hintColor;
String fmt(int v) => v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}K' : '$v';
return AnimatedBuilder(
animation: _animController,
builder: (_, __) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_landscapeStatItem(fmt(_animatedLikes), 'Likes', color, hintColor),
_landscapeStatDivider(),
_landscapeStatItem(fmt(_animatedPosts), 'Posts', color, hintColor),
_landscapeStatDivider(),
_landscapeStatItem(fmt(_animatedViews), 'Views', color, hintColor),
],
),
);
}
Widget _landscapeStatItem(String value, String label, Color? valueColor, Color? labelColor) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: valueColor)),
const SizedBox(height: 2),
Text(label, style: TextStyle(fontSize: 12, color: labelColor, fontWeight: FontWeight.w400)),
],
);
}
Widget _landscapeStatDivider() => Container(width: 1, height: 36, color: Colors.white24);
Widget _buildLandscapeRightPanel(BuildContext context) {
final theme = Theme.of(context);
Widget _eventList(List<EventModel> events, {bool faded = false}) {
if (_loadingEvents) {
return const Center(child: CircularProgressIndicator());
}
if (events.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Text('No events', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
),
);
}
return ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 32),
itemCount: events.length,
itemBuilder: (ctx, i) => _eventListTileFromModel(events[i], faded: faded),
);
}
return SafeArea(
child: DefaultTabController(
length: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const LandscapeSectionHeader(title: 'My Events'),
// Tab bar
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Container(
decoration: BoxDecoration(
color: theme.dividerColor.withOpacity(0.5),
borderRadius: BorderRadius.circular(10),
),
child: TabBar(
labelColor: Colors.white,
unselectedLabelColor: theme.hintColor,
indicator: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(10),
),
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
tabs: const [
Tab(text: 'Ongoing'),
Tab(text: 'Upcoming'),
Tab(text: 'Past'),
],
),
),
),
Expanded(
child: TabBarView(
children: [
_eventList(_ongoingEvents),
_eventList(_upcomingEvents),
_eventList(_pastEvents, faded: true),
],
),
),
],
),
),
);
}
// ═══════════════════════════════════════════════
// DESKTOP LAYOUT (Figma: full-width banner + 3-col grids)
// ═══════════════════════════════════════════════
Widget _buildDesktopLayout(BuildContext context, ThemeData theme) {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full-width profile header + card (reuse existing widgets)
Stack(
children: [
_buildGradientHeader(context, 200),
Padding(
padding: const EdgeInsets.only(top: 130),
child: _buildProfileCard(context),
),
],
),
const SizedBox(height: 24),
// Ongoing Events (only if non-empty)
if (_ongoingEvents.isNotEmpty)
_buildDesktopEventSection(
context,
title: 'Ongoing Events',
events: _ongoingEvents,
faded: false,
),
// Upcoming Events
_buildDesktopEventSection(
context,
title: 'Upcoming Events',
events: _upcomingEvents,
faded: false,
emptyMessage: 'No upcoming events',
),
// Past Events
_buildDesktopEventSection(
context,
title: 'Past Events',
events: _pastEvents,
faded: true,
emptyMessage: 'No past events',
),
const SizedBox(height: 32),
],
),
),
);
}
/// Section heading row ("Title" + "View All >") followed by a 3-column grid.
Widget _buildDesktopEventSection(
BuildContext context, {
required String title,
required List<EventModel> events,
bool faded = false,
String? emptyMessage,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Heading row
Row(
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
fontSize: 20,
),
),
const Spacer(),
if (events.isNotEmpty)
TextButton(
onPressed: () {
// View all — no-op for now; could navigate to a full list
},
child: Text(
'View All >',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
const SizedBox(height: 12),
// Content
if (_loadingEvents)
const Center(child: CircularProgressIndicator())
else if (events.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
emptyMessage ?? 'No events',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.82,
),
itemCount: events.length,
itemBuilder: (ctx, i) =>
_buildDesktopEventGridCard(events[i], faded: faded),
),
const SizedBox(height: 24),
],
),
);
}
/// A single event card for the desktop grid: image on top, title, date (blue dot), venue (green dot).
Widget _buildDesktopEventGridCard(EventModel ev, {bool faded = false}) {
final theme = Theme.of(context);
final title = ev.title ?? ev.name ?? '';
final dateLabel =
(ev.startDate != null && ev.endDate != null && ev.startDate == ev.endDate)
? ev.startDate!
: ((ev.startDate != null && ev.endDate != null)
? '${ev.startDate} - ${ev.endDate}'
: (ev.startDate ?? ''));
final location = ev.place ?? '';
final imageUrl = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty)
? ev.thumbImg!
: (ev.images.isNotEmpty ? ev.images.first.image : null);
final titleColor = faded ? theme.hintColor : (theme.textTheme.bodyLarge?.color);
final subtitleColor = faded
? theme.hintColor.withValues(alpha: 0.7)
: theme.hintColor;
return GestureDetector(
onTap: () {
if (ev.id != null) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id)),
);
}
},
child: Container(
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
Expanded(
flex: 3,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
child: _buildCardImage(imageUrl, theme),
),
),
// Text content
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
color: titleColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
// Date row with blue dot
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF3B82F6),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
dateLabel,
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 4),
// Venue row with green dot
Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFF22C55E),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
location,
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
),
],
),
),
);
}
/// Helper to build the image widget for a desktop grid card.
Widget _buildCardImage(String? imageUrl, ThemeData theme) {
if (imageUrl != null && imageUrl.trim().isNotEmpty) {
if (imageUrl.startsWith('http')) {
return CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 400,
memCacheHeight: 400,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
placeholder: (_, __) => Container(color: theme.dividerColor),
errorWidget: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
if (!kIsWeb) {
final path = imageUrl;
if (path.startsWith('/') || path.contains(Platform.pathSeparator)) {
final file = File(path);
if (file.existsSync()) {
return Image.file(
file,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
}
}
return Image.asset(
imageUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
),
);
}
return Container(
color: theme.dividerColor,
child: Icon(Icons.event, size: 32, color: theme.hintColor),
);
}
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
// BUILD // BUILD
// ═══════════════════════════════════════════════ // ═══════════════════════════════════════════════
@@ -1003,15 +1550,33 @@ class _ProfileScreenState extends State<ProfileScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
const double headerHeight = 200.0; const double headerHeight = 200.0;
const double cardTopOffset = 130.0; // card starts overlapping into header const double cardTopOffset = 130.0;
final width = MediaQuery.of(context).size.width;
Widget sectionTitle(String text) => Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
child: Text(
text,
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600, fontSize: 18),
),
);
// ── DESKTOP / LANDSCAPE layout ─────────────────────────────────────────
if (width >= AppConstants.desktopBreakpoint) {
return _buildDesktopLayout(context, theme);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold( return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor, backgroundColor: theme.scaffoldBackgroundColor,
body: SingleChildScrollView( // CustomScrollView: only visible event cards are built — no full-tree Column renders
child: Column( body: CustomScrollView(
children: [ physics: const BouncingScrollPhysics(),
// Header + Profile Card overlap using Stack slivers: [
Stack( // Header gradient + Profile card overlap (same visual as before)
SliverToBoxAdapter(
child: Stack(
children: [ children: [
_buildGradientHeader(context, headerHeight), _buildGradientHeader(context, headerHeight),
Padding( Padding(
@@ -1020,13 +1585,74 @@ class _ProfileScreenState extends State<ProfileScreen>
), ),
], ],
), ),
// Event sections
_buildEventSections(context),
const SizedBox(height: 32),
],
), ),
// ── Ongoing Events ──
if (_ongoingEvents.isNotEmpty) ...[
SliverToBoxAdapter(child: sectionTitle('Ongoing Events')),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_ongoingEvents[i]),
childCount: _ongoingEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
// ── Upcoming Events ──
SliverToBoxAdapter(child: sectionTitle('Upcoming Events')),
if (_loadingEvents)
const SliverToBoxAdapter(child: SizedBox.shrink())
else if (_upcomingEvents.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Text('No upcoming events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_upcomingEvents[i]),
childCount: _upcomingEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
// ── Past Events ──
SliverToBoxAdapter(child: sectionTitle('Past Events')),
if (_loadingEvents)
const SliverToBoxAdapter(child: SizedBox.shrink())
else if (_pastEvents.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
child: Text('No past events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_pastEvents[i], faded: true),
childCount: _pastEvents.length,
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
), ),
); );
} }

View File

@@ -149,7 +149,7 @@ class _SearchScreenState extends State<SearchScreen> {
} }
} catch (_) {} } catch (_) {}
if (mounted) Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}'); if (mounted) Navigator.of(context).pop('Current Location');
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e'))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
@@ -169,10 +169,12 @@ class _SearchScreenState extends State<SearchScreen> {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Stack( child: Stack(
children: [ children: [
BackdropFilter( RepaintBoundary(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0), filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(color: Colors.black.withOpacity(0.16)), child: Container(color: Colors.black.withOpacity(0.16)),
), ),
),
Align( Align(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
child: GestureDetector( child: GestureDetector(
@@ -305,9 +307,11 @@ class _SearchScreenState extends State<SearchScreen> {
child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))), child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))),
) )
else else
ListView.separated( ConstrainedBox(
shrinkWrap: true, constraints: const BoxConstraints(maxHeight: 320),
physics: const NeverScrollableScrollPhysics(), child: ListView.separated(
shrinkWrap: false,
physics: const ClampingScrollPhysics(),
itemCount: _searchResults.length, itemCount: _searchResults.length,
separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1), separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1),
itemBuilder: (ctx, idx) { itemBuilder: (ctx, idx) {
@@ -326,6 +330,7 @@ class _SearchScreenState extends State<SearchScreen> {
); );
}, },
), ),
),
] else ...[ ] else ...[
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))), const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -3,7 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'login_screen.dart'; import 'login_screen.dart';
import 'desktop_login_screen.dart'; import 'desktop_login_screen.dart';
import '../core/theme_manager.dart'; import '../core/theme_manager.dart';
import 'privacy_policy_screen.dart'; // new import import 'privacy_policy_screen.dart';
import '../core/app_decoration.dart'; import '../core/app_decoration.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
@@ -15,7 +15,8 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true; bool _notifications = true;
String _appVersion = '1.2(p)'; String _appVersion = '1.6(p)';
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
@override @override
void initState() { void initState() {
@@ -100,16 +101,209 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
// ── Settings content sections ────────────────────────────────────────────
Widget _buildPreferencesSection() {
const primary = Color(0xFF0B63D6);
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: _notifications,
onChanged: (v) => _saveNotifications(v),
title: const Text('Reminders'),
secondary: const Icon(Icons.notifications, color: primary),
),
),
const SizedBox(height: 8),
Container(
margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
child: ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) {
return SwitchListTile(
tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: mode == ThemeMode.dark,
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
title: const Text('Dark Mode'),
secondary: const Icon(Icons.dark_mode, color: primary),
);
},
),
),
],
),
);
}
Widget _buildAccountSection() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTile(
icon: Icons.person,
title: 'Edit Profile',
subtitle: 'Change username, email or photo',
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab'))),
),
const SizedBox(height: 24),
Center(
child: ElevatedButton(
onPressed: _confirmLogout,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade600,
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
child: const Text('Logout', style: TextStyle(color: Colors.white)),
),
),
],
),
);
}
Widget _buildAboutSection() {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
const SizedBox(height: 12),
_buildTile(
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
),
],
),
);
}
Widget _buildActiveSection() {
switch (_selectedSection) {
case 1: return _buildAccountSection();
case 2: return _buildAboutSection();
default: return _buildPreferencesSection();
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const primary = Color(0xFF0B63D6); const primary = Color(0xFF0B63D6);
final width = MediaQuery.of(context).size.width;
final isLandscape = width >= 820;
// ── LANDSCAPE layout ──────────────────────────────────────────────────
if (isLandscape) {
const navIcons = [Icons.tune, Icons.person_outline, Icons.info_outline];
const navLabels = ['Preferences', 'Account', 'About'];
return Row(
children: [
// Left: settings nav on gradient
Flexible(
flex: 1,
child: RepaintBoundary(
child: Container(
decoration: AppDecoration.blueGradient,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 24, 20, 20),
child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700)),
),
...List.generate(navLabels.length, (i) {
final isActive = _selectedSection == i;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => setState(() => _selectedSection = i),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isActive ? Colors.white : Colors.white.withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(children: [
Icon(navIcons[i], size: 20, color: isActive ? primary : Colors.white70),
const SizedBox(width: 12),
Text(navLabels[i], style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: isActive ? primary : Colors.white)),
]),
),
),
);
}),
const Spacer(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: OutlinedButton.icon(
onPressed: _confirmLogout,
icon: const Icon(Icons.logout, color: Colors.white70, size: 18),
label: const Text('Logout', style: TextStyle(color: Colors.white70)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
),
),
),
),
// Right: settings content
Flexible(
flex: 2,
child: RepaintBoundary(
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
child: Text(
navLabels[_selectedSection],
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
Expanded(child: _buildActiveSection()),
],
),
),
),
),
],
);
}
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
// Header
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18), padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
@@ -131,41 +325,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
// Content
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Account
const Text('Account', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('Account', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildTile( _buildTile(
icon: Icons.person, icon: Icons.person,
title: 'Edit Profile', title: 'Edit Profile',
subtitle: 'Change username, email or photo', subtitle: 'Change username, email or photo',
onTap: () { onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)')));
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Preferences
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
// Reminders switch wrapped in card-like container
Container( Container(
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
),
child: SwitchListTile( child: SwitchListTile(
tileColor: Theme.of(context).cardColor, tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -175,54 +355,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
secondary: const Icon(Icons.notifications, color: primary), secondary: const Icon(Icons.notifications, color: primary),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Dark Mode switch wrapped in card-like container and hooked to ThemeManager
Container( Container(
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
),
child: ValueListenableBuilder<ThemeMode>( child: ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeManager.themeMode, valueListenable: ThemeManager.themeMode,
builder: (context, mode, _) { builder: (context, mode, _) => SwitchListTile(
final isDark = mode == ThemeMode.dark;
return SwitchListTile(
tileColor: Theme.of(context).cardColor, tileColor: Theme.of(context).cardColor,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
value: isDark, value: mode == ThemeMode.dark,
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light), onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
title: const Text('Dark Mode'), title: const Text('Dark Mode'),
secondary: const Icon(Icons.dark_mode, color: primary), secondary: const Icon(Icons.dark_mode, color: primary),
);
},
), ),
), ),
),
const SizedBox(height: 18), const SizedBox(height: 18),
// About
const Text('About', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('About', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}), _buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
const SizedBox(height: 12), const SizedBox(height: 12),
// Privacy Policy tile now navigates to PrivacyPolicyScreen
_buildTile( _buildTile(
icon: Icons.privacy_tip_outlined, icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy', title: 'Privacy Policy',
subtitle: 'Demo app', subtitle: 'Demo app',
onTap: () { onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen()));
},
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Logout area
Center( Center(
child: Column( child: Column(
children: [ children: [
@@ -240,7 +400,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
), ),

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../core/app_decoration.dart';
import '../core/constants.dart';
class DesktopSidebar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onIndexChanged;
const DesktopSidebar({
Key? key,
required this.selectedIndex,
required this.onIndexChanged,
}) : super(key: key);
static const _navItems = <_NavDef>[
_NavDef(Icons.home_outlined, Icons.home, 'Home', 0),
_NavDef(Icons.calendar_today_outlined, Icons.calendar_today, 'Calendar', 1),
_NavDef(Icons.person_outline, Icons.person, 'Profile', 2),
];
static const _bottomItems = <_NavDef>[
_NavDef(Icons.settings_outlined, Icons.settings, 'Settings', 5),
_NavDef(Icons.help_outline, Icons.help, 'Help', -1),
];
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Container(
width: AppConstants.sidebarExpandedWidth,
decoration: AppDecoration.blueGradient,
child: SafeArea(
child: Column(
children: [
// Logo
Padding(
padding: const EdgeInsets.only(left: 24, top: 20, right: 24),
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.auto_awesome,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
'EVENTIFY',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
],
),
),
),
const SizedBox(height: 24),
// Main nav items
Column(
children: _navItems
.map((item) => _buildNavItem(item))
.toList(),
),
const Spacer(),
// Bottom nav items
Column(
children: _bottomItems
.map((item) => _buildNavItem(item))
.toList(),
),
const SizedBox(height: 20),
],
),
),
),
);
}
Widget _buildNavItem(_NavDef item) {
final selected = selectedIndex == item.index;
final icon = selected ? item.activeIcon : item.icon;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: () => onIndexChanged(item.index),
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 48,
margin: const EdgeInsets.symmetric(vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
icon,
size: 22,
color: selected
? const Color(0xFF0F45CF)
: Colors.white.withValues(alpha: 0.85),
),
const SizedBox(width: 12),
Text(
item.label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: selected
? const Color(0xFF0F45CF)
: Colors.white.withValues(alpha: 0.85),
),
),
],
),
),
),
),
);
}
}
class _NavDef {
final IconData icon;
final IconData activeIcon;
final String label;
final int index;
const _NavDef(this.icon, this.activeIcon, this.label, this.index);
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
class DesktopTopBar extends StatelessWidget {
final String username;
final String? profileImage;
final VoidCallback? onSearchTap;
final VoidCallback? onNotificationTap;
final VoidCallback? onAvatarTap;
const DesktopTopBar({
Key? key,
required this.username,
this.profileImage,
this.onSearchTap,
this.onNotificationTap,
this.onAvatarTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.3),
),
),
),
child: Row(
children: [
// Left: search bar
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: SizedBox(
height: 44,
child: TextField(
onTap: onSearchTap,
readOnly: onSearchTap != null,
decoration: InputDecoration(
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.4),
prefixIcon: Icon(Icons.search, color: theme.hintColor),
hintText: 'Search',
hintStyle: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
style: theme.textTheme.bodyLarge,
),
),
),
),
const SizedBox(width: 16),
// Right: notification bell + avatar
Stack(
children: [
IconButton(
onPressed: onNotificationTap,
icon: Icon(
Icons.notifications_none,
color: theme.iconTheme.color,
),
),
Positioned(
right: 6,
top: 6,
child: CircleAvatar(
radius: 8,
backgroundColor: Colors.red,
child: Text(
'2',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
fontSize: 10,
),
),
),
),
],
),
const SizedBox(width: 8),
GestureDetector(
onTap: onAvatarTap,
child: _buildAvatar(),
),
],
),
);
}
Widget _buildAvatar() {
if (profileImage != null && profileImage!.trim().isNotEmpty) {
final url = profileImage!.trim();
if (url.startsWith('http')) {
return CircleAvatar(
radius: 20,
backgroundColor: Colors.grey.shade200,
backgroundImage: NetworkImage(url),
onBackgroundImageError: (_, __) {},
);
}
}
final name = username.trim();
String initials = 'U';
if (name.isNotEmpty) {
if (name.contains('@')) {
initials = name[0].toUpperCase();
} else {
final parts = name.split(' ').where((p) => p.isNotEmpty).toList();
initials = parts.isEmpty
? 'U'
: parts.take(2).map((p) => p[0].toUpperCase()).join();
}
}
return CircleAvatar(
radius: 20,
backgroundColor: Colors.blue.shade600,
child: Text(
initials,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
// lib/widgets/landscape_section_header.dart
//
// Consistent section header for the right panel of landscape layouts.
// Shows a title, optional subtitle, and optional trailing action widget.
import 'package:flutter/material.dart';
class LandscapeSectionHeader extends StatelessWidget {
final String title;
final String? subtitle;
final Widget? trailing;
final EdgeInsetsGeometry padding;
const LandscapeSectionHeader({
Key? key,
required this.title,
this.subtitle,
this.trailing,
this.padding = const EdgeInsets.fromLTRB(24, 24, 24, 12),
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.textTheme.bodySmall?.color?.withOpacity(0.6),
),
),
],
],
),
),
if (trailing != null) trailing!,
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
// lib/widgets/landscape_shell.dart
//
// Reusable two-panel landscape scaffold for all desktop/wide screens.
// Left panel uses the brand dark-blue gradient; right panel is the content area.
//
// Usage:
// LandscapeShell(
// leftPanel: MyLeftContent(),
// rightPanel: MyRightContent(),
// )
import 'package:flutter/material.dart';
import '../core/app_decoration.dart';
class LandscapeShell extends StatelessWidget {
final Widget leftPanel;
final Widget rightPanel;
/// Flex weight for left panel (default 2 → ~40% of width)
final int leftFlex;
/// Flex weight for right panel (default 3 → ~60% of width)
final int rightFlex;
/// Optional background color for right panel (defaults to scaffold background)
final Color? rightBackground;
const LandscapeShell({
Key? key,
required this.leftPanel,
required this.rightPanel,
this.leftFlex = 2,
this.rightFlex = 3,
this.rightBackground,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final bg = rightBackground ?? Theme.of(context).scaffoldBackgroundColor;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Left panel — dark blue gradient ──────────────────────────────
Flexible(
flex: leftFlex,
child: RepaintBoundary(
child: Container(
decoration: AppDecoration.blueGradient,
child: leftPanel,
),
),
),
// ── Right panel — content area ────────────────────────────────────
Flexible(
flex: rightFlex,
child: RepaintBoundary(
child: ColoredBox(
color: bg,
child: rightPanel,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/constants.dart';
import 'desktop_sidebar.dart';
import 'desktop_topbar.dart';
class ResponsiveShell extends StatefulWidget {
final int currentIndex;
final ValueChanged<int> onIndexChanged;
final Widget child;
final bool showTopBar;
const ResponsiveShell({
Key? key,
required this.currentIndex,
required this.onIndexChanged,
required this.child,
this.showTopBar = true,
}) : super(key: key);
@override
State<ResponsiveShell> createState() => _ResponsiveShellState();
}
class _ResponsiveShellState extends State<ResponsiveShell> {
String _username = 'Guest';
String? _profileImage;
@override
void initState() {
super.initState();
_loadPreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_username = prefs.getString('display_name') ??
prefs.getString('username') ??
'Guest';
_profileImage = prefs.getString('profileImage');
});
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final width = constraints.maxWidth;
// Mobile — no shell
if (width < AppConstants.desktopBreakpoint) {
return widget.child;
}
return Scaffold(
body: Row(
children: [
DesktopSidebar(
selectedIndex: widget.currentIndex,
onIndexChanged: widget.onIndexChanged,
),
Expanded(
child: Column(
children: [
if (widget.showTopBar)
DesktopTopBar(
username: _username,
profileImage: _profileImage,
onAvatarTap: () => widget.onIndexChanged(2),
),
Expanded(
child: RepaintBoundary(child: widget.child),
),
],
),
),
],
),
);
});
}
}

View File

@@ -10,6 +10,7 @@ import geolocator_apple
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos import url_launcher_macos
import video_player_avfoundation import video_player_avfoundation
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
} }

View File

@@ -41,6 +41,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -182,6 +206,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_launcher_icons: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -520,6 +552,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.6" version: "1.0.6"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@@ -616,6 +664,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.3" version: "6.0.3"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sanitize_html: sanitize_html:
dependency: transitive dependency: transitive
description: description:
@@ -717,6 +781,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -749,6 +853,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
table_calendar: table_calendar:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -1,7 +1,7 @@
name: figma name: figma
description: A Flutter event app description: A Flutter event app
publish_to: 'none' publish_to: 'none'
version: 1.0.0+1 version: 1.6.1+17
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
@@ -19,7 +19,9 @@ dependencies:
google_maps_flutter: ^2.5.0 google_maps_flutter: ^2.5.0
url_launcher: ^6.2.1 url_launcher: ^6.2.1
share_plus: ^7.2.1 share_plus: ^7.2.1
provider: ^6.1.2
video_player: ^2.8.1 video_player: ^2.8.1
cached_network_image: ^3.3.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -32,6 +34,7 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/icon/hand_stop.svg - assets/icon/hand_stop.svg
- assets/login-bg.mp4
fonts: fonts:
- family: Gilroy - family: Gilroy
fonts: fonts:

3
run_web.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
cd /Users/bshtechnologies/Documents/Eventify-frontend
exec flutter run -d web-server --web-hostname 0.0.0.0 --web-port "${PORT:-8080}"