Compare commits
40 Commits
main
...
1bb25b026c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb25b026c | |||
| 924c80da00 | |||
| d275324205 | |||
| 7b9873a80a | |||
| 195509abb6 | |||
| 6c2efbccc6 | |||
| 34d6586afa | |||
| 692f96bfce | |||
| ac95a44a07 | |||
| 9676ede50b | |||
| c6121d7754 | |||
| b4c4c4bd53 | |||
| 64e7323213 | |||
| 632754415d | |||
| fe8af7cfe6 | |||
| 29e326b8fc | |||
| 847577c09d | |||
| e63e9daa0c | |||
| 605c9277a5 | |||
| cd7864f4fb | |||
| 760fc920fc | |||
| 7df6bb6c56 | |||
| 2366d25478 | |||
| d2b49d4eb5 | |||
| b55f02e057 | |||
| 87cc56dc64 | |||
| 6503d9bc1b | |||
| dd7268cd98 | |||
| 04af387945 | |||
| cac2671fd6 | |||
| cf21e0a58c | |||
| a26b7544f5 | |||
| 9dcd5bae16 | |||
| 48f143399d | |||
| 378d054dc4 | |||
| f98e0fe617 | |||
| e8e2e7ac28 | |||
| ee0151efe5 | |||
| 5b373e8694 | |||
| 97245e01c4 |
16
.gitignore
vendored
16
.gitignore
vendored
@@ -46,3 +46,19 @@ 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
|
||||||
|
|
||||||
|
# Claude Code / MCP / vibe-coding tool artifacts — keep local only
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
CLAUDE.md
|
||||||
|
_notes/
|
||||||
|
.obsidian/
|
||||||
|
hero_section_improvements.csv
|
||||||
|
security_audit_report.csv
|
||||||
|
feature_gap_analysis.csv
|
||||||
|
|||||||
152
CHANGELOG.md
Normal file
152
CHANGELOG.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 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.6.1] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 4 — animation polish and final feature gaps. Flutter app reaches full feature parity with Consumer Web App v1.4.9.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **BouncingLoader widget** (`lib/widgets/bouncing_loader.dart`): 3-dot bouncing animation with staggered 200 ms delays using `Curves.bounceOut`. Replaces `CircularProgressIndicator` in home, contribute, and review screens. Accepts `color`, `dotSize`, and `spacing` parameters.
|
||||||
|
- **DiceBear Notionists avatars on review cards** (REV-001): `CachedNetworkImage` fetches `api.dicebear.com/9.x/notionists/svg?seed={username}`. Falls back to coloured initial letter `CircleAvatar` on error or while loading.
|
||||||
|
- **Server-side event search** (HOME-007): Search modal now sends `q` param to `EventsByPincodeView`; client-side filter stays for instant `onChanged` feedback while server results load on submit. Cache is bypassed for search queries. Django backend updated with `Q(title__icontains=q) | Q(description__icontains=q)` OR filter.
|
||||||
|
- **`flutter_staggered_animations: ^1.1.1`** added to pubspec.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Review list stagger animation** (REV-003): `AnimationLimiter` + `AnimationConfiguration.toStaggeredList` wraps review cards with 375 ms slide-up + fade-in per item.
|
||||||
|
- **Review submit success spring animation** (REV-004): Checkmark icon now animates with `ScaleTransition` driven by `Curves.elasticOut` (600 ms) instead of a static icon swap.
|
||||||
|
- **Hero transitions on event cards** (UX-005): `Hero(tag: 'event-hero-{id}')` wraps event images in home screen and matching destination in learn more screen — enabling shared-element transitions.
|
||||||
|
- **FadeTransition on learn more screen** (UX-005): Screen body fades in with `Curves.easeIn` (350 ms) after event data loads.
|
||||||
|
- **AnimatedList stagger on leaderboard** (UX-005): `SliverList` entries animate with `AnimationConfiguration.staggeredList` — 375 ms slide-up + fade-in per row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 3 — 26 medium-priority gaps. Profile editing, contributor profiles, share cards, booking promo codes, and UX system components.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Eventify ID badge** (AUTH-003): Verified badge displayed on profile and contributor cards for accounts with confirmed identity.
|
||||||
|
- **DiceBear TierAvatarRing** (`lib/widgets/tier_avatar_ring.dart`): Tier-coloured ring around profile avatars using DiceBear seed — Bronze/Silver/Gold/Platinum/Diamond colours.
|
||||||
|
- **Profile photo upload to server** (AUTH-006 / PROF-002): `PATCH /api/user/update-profile/` multipart endpoint; photo picker + crop flow integrated.
|
||||||
|
- **District picker** (PROF-004): 14 Kerala districts selectable from a bottom sheet; stored against user profile.
|
||||||
|
- **183-day profile cooldown lock** (AUTH-005): Username and display name locked for 183 days after last change; countdown shown in edit form.
|
||||||
|
- **Kerala pincodes JSON** (`assets/data/kerala_pincodes.json`) (LOC-003): Full offline pincode dataset covering all 14 districts; powers location-aware event discovery without API round trips.
|
||||||
|
- **Promo code input on booking** (BOOK-003): `POST /bookings/apply-promo/` endpoint; inline validation with success/error state in booking bar.
|
||||||
|
- **Contributor profile screen** (`lib/screens/contributor_profile_screen.dart`) (CTR-004/005): Public view of any contributor's stats, tier, events submitted, and achievements.
|
||||||
|
- **Share rank card** (`lib/features/share/share_rank_card.dart`) (SHARE-001/002): Generates a shareable tier/EP card image; `share_plus` used for native share sheet.
|
||||||
|
- **Share status button on contributor dashboard** (CTR-003): `OutlinedButton.icon` with `Share.share()` near tier/EP display.
|
||||||
|
- **GlassCard widget** (`lib/widgets/glass_card.dart`) (UX-003): Reusable frosted-glass surface used across gamification and profile screens.
|
||||||
|
- **EventifyBottomSheet** (`lib/widgets/eventify_bottom_sheet.dart`) (UX-004): Standardised bottom sheet with drag handle, rounded corners, and safe-area inset.
|
||||||
|
- **Featured events carousel** (HOME-004): Auto-scrolling hero carousel for featured/sponsored events on home screen.
|
||||||
|
- **Event image gallery** (EVT-001): Full-screen `PageView` carousel inside learn more screen with dot indicator.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Real gamification data** (GAM-003/004/006): EP/RP transaction history, tier progression, and leaderboard all wired to live Node.js API — mock data removed.
|
||||||
|
- **Leaderboard card district display** (LDR-003): District badge shown per rank row; district filter pill row added above leaderboard.
|
||||||
|
- **Achievement badge display + unlock animation** (ACH-002/003): Badges rendered from API; confetti-style animation plays on first unlock.
|
||||||
|
- **Review responses** (REV-002): Organiser reply thread displayed below each review card.
|
||||||
|
- **Profile bio and social links** (PROF-001): Edit form includes bio textarea and links for Instagram, Twitter, LinkedIn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 2 — 11 high-priority gaps. Authentication hardening, location services, real gamification data hookup, skeleton loading, and booking flow fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Email OTP verification** (AUTH-004): 6-digit OTP sent on registration; verification screen blocks app access until confirmed.
|
||||||
|
- **Password reset flow** (AUTH-002): Forgot-password → OTP → new password screens; Django `POST /accounts/password-reset/` endpoint integrated.
|
||||||
|
- **Location permission + haversine distance sorting** (LOC-001/002): `geolocator` requests permission at startup; events sorted by straight-line distance from user's GPS coordinate.
|
||||||
|
- **Skeleton loading with shimmer** (UX-001): `shimmer: ^3.0.0` added; event feed, leaderboard, and profile screens show shimmer placeholders while data loads.
|
||||||
|
- **Contributor stats real API** (CTR-001): EP/RP balance and tier fetched from Node.js gamification endpoint on dashboard load.
|
||||||
|
- **Achievement progress tracking** (ACH-001): Progress bars and completion state fetched from API; local mock removed.
|
||||||
|
- **Profile stats row** (PROF-003): Likes / Posts / Views counts fetched from user profile API and displayed in profile header.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Search modal with server pincode detection** (HOME-001/002/003): Search bottom sheet auto-detects user pincode; category filter chips filter from API results.
|
||||||
|
- **Real tier and EP display** (GAM-002/005): Contributor dashboard shows live tier and EP from Node.js API; tier badge in profile header updated to match.
|
||||||
|
- **District filter on leaderboard** (LDR-002): Leaderboard district pills populated from API; selecting filters rank table in real time.
|
||||||
|
- **Booking bar fixes** (EVT-003/BOOK-004): Fixed ticket-count stepper; booking confirmation screen correctly shows booking reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 1 — critical gaps. Live backend integration replacing all mock/stub data, payment checkout, and OAuth.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Gamification API integration**: EP, RP, leaderboard, and achievements wired to live Node.js endpoints at `app.eventifyplus.com/api/v1/` — all mock `GamificationService` data replaced.
|
||||||
|
- **Razorpay checkout** (BOOK-001): Native Razorpay SDK integrated; `POST /bookings/create/` → order creation → Razorpay payment sheet → `POST /bookings/verify/` webhook.
|
||||||
|
- **Google OAuth login** (AUTH-001): `google_sign_in` flow; tokens exchanged with Django `POST /accounts/google-auth/` endpoint.
|
||||||
|
- **Notification panel** (NOTIF-002/003/004): `DraggableScrollableSheet` notification drawer with 4 colour-coded notification types (booking, event, system, promo); mark-individual-read and mark-all-read actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **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
|
||||||
13
README.md
13
README.md
@@ -17,6 +17,7 @@
|
|||||||
[](https://flutter.dev/)
|
[](https://flutter.dev/)
|
||||||
[](https://dart.dev/)
|
[](https://dart.dev/)
|
||||||
[](#)
|
[](#)
|
||||||
|
[](#)
|
||||||
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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
28
android/app/proguard-rules.pro
vendored
Normal 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
|
||||||
3243
assets/data/kerala_pincodes.json
Normal file
3243
assets/data/kerala_pincodes.json
Normal file
File diff suppressed because it is too large
Load Diff
15
docs/hero_slider_changes.csv
Normal file
15
docs/hero_slider_changes.csv
Normal 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()
|
||||||
|
27
docs/landscape_changes.csv
Normal file
27
docs/landscape_changes.csv
Normal 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"
|
||||||
|
17
docs/landscape_rebuild_changes.csv
Normal file
17
docs/landscape_rebuild_changes.csv
Normal 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
|
||||||
|
94
lib/core/analytics/posthog_service.dart
Normal file
94
lib/core/analytics/posthog_service.dart
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// lib/core/analytics/posthog_service.dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Lightweight PostHog analytics client using the HTTP API.
|
||||||
|
/// Works with Dart 2.x (no posthog_flutter SDK needed).
|
||||||
|
class PostHogService {
|
||||||
|
static const String _apiKey = 'phc_xXxn0COAwWRj3AU7fspsTuesCIK0aBGXb3zaIIJRgZA';
|
||||||
|
static const String _host = 'https://eu.i.posthog.com';
|
||||||
|
static const String _distinctIdKey = 'posthog_distinct_id';
|
||||||
|
|
||||||
|
static PostHogService? _instance;
|
||||||
|
String? _distinctId;
|
||||||
|
|
||||||
|
PostHogService._();
|
||||||
|
|
||||||
|
static PostHogService get instance {
|
||||||
|
_instance ??= PostHogService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize and load or generate a distinct ID.
|
||||||
|
Future<void> init() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_distinctId = prefs.getString(_distinctIdKey);
|
||||||
|
if (_distinctId == null) {
|
||||||
|
_distinctId = DateTime.now().millisecondsSinceEpoch.toRadixString(36) +
|
||||||
|
UniqueKey().toString().hashCode.toRadixString(36);
|
||||||
|
await prefs.setString(_distinctIdKey, _distinctId!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identify a user (call after login).
|
||||||
|
void identify(String userId, {Map<String, dynamic>? properties}) {
|
||||||
|
_distinctId = userId;
|
||||||
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
|
prefs.setString(_distinctIdKey, userId);
|
||||||
|
});
|
||||||
|
_send('identify', {
|
||||||
|
'distinct_id': userId,
|
||||||
|
if (properties != null) '\$set': properties,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a custom event.
|
||||||
|
void capture(String event, {Map<String, dynamic>? properties}) {
|
||||||
|
_send('capture', {
|
||||||
|
'event': event,
|
||||||
|
'distinct_id': _distinctId ?? 'anonymous',
|
||||||
|
'properties': {
|
||||||
|
...?properties,
|
||||||
|
'\$lib': 'flutter',
|
||||||
|
'\$lib_version': '1.0.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture a screen view.
|
||||||
|
void screen(String screenName, {Map<String, dynamic>? properties}) {
|
||||||
|
capture('\$screen', properties: {
|
||||||
|
'\$screen_name': screenName,
|
||||||
|
...?properties,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset identity (call on logout).
|
||||||
|
void reset() {
|
||||||
|
_distinctId = null;
|
||||||
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
|
prefs.remove(_distinctIdKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send event to PostHog API (fire-and-forget).
|
||||||
|
void _send(String endpoint, Map<String, dynamic> body) {
|
||||||
|
final payload = {
|
||||||
|
'api_key': _apiKey,
|
||||||
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
...body,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fire and forget — don't block the UI
|
||||||
|
http.post(
|
||||||
|
Uri.parse('$_host/$endpoint/'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
).catchError((e) {
|
||||||
|
if (kDebugMode) debugPrint('PostHog error: $e');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import 'package:http/http.dart' as http;
|
|||||||
import '../storage/token_storage.dart';
|
import '../storage/token_storage.dart';
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
static const Duration _timeout = Duration(seconds: 30);
|
static const Duration _timeout = Duration(seconds: 10);
|
||||||
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
|
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
|
||||||
static const bool _developmentMode = true;
|
static const bool _developmentMode = false;
|
||||||
|
|
||||||
/// POST request
|
/// POST request
|
||||||
///
|
///
|
||||||
@@ -57,6 +57,39 @@ class ApiClient {
|
|||||||
'email': email,
|
'email': email,
|
||||||
'phone_number': finalBody['phone_number'] ?? '+1234567890',
|
'phone_number': finalBody['phone_number'] ?? '+1234567890',
|
||||||
};
|
};
|
||||||
|
} else if (url.contains('/events/type-list/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock event types');
|
||||||
|
return {
|
||||||
|
'event_types': [
|
||||||
|
{'id': 1, 'event_type': 'Concert', 'event_type_icon': null},
|
||||||
|
{'id': 2, 'event_type': 'Workshop', 'event_type_icon': null},
|
||||||
|
{'id': 3, 'event_type': 'Festival', 'event_type_icon': null},
|
||||||
|
{'id': 4, 'event_type': 'Sports', 'event_type_icon': null},
|
||||||
|
{'id': 5, 'event_type': 'Conference', 'event_type_icon': null},
|
||||||
|
{'id': 6, 'event_type': 'Exhibition', 'event_type_icon': null},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else if (url.contains('/events/pincode-events/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock events');
|
||||||
|
return {'events': _mockEvents};
|
||||||
|
} else if (url.contains('/events/event-details/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock event detail');
|
||||||
|
final eventId = finalBody['event_id'] ?? 1;
|
||||||
|
final match = _mockEvents.where((e) => e['id'] == eventId);
|
||||||
|
return match.isNotEmpty
|
||||||
|
? Map<String, dynamic>.from(match.first)
|
||||||
|
: Map<String, dynamic>.from(_mockEvents.first);
|
||||||
|
} else if (url.contains('/events/events-by-month-year/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock calendar');
|
||||||
|
return {
|
||||||
|
'total_number_of_events': 3,
|
||||||
|
'dates': ['2026-04-05', '2026-04-12', '2026-04-20'],
|
||||||
|
'date_events': [
|
||||||
|
{'date': '2026-04-05', 'count': 1},
|
||||||
|
{'date': '2026-04-12', 'count': 2},
|
||||||
|
{'date': '2026-04-20', 'count': 1},
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +114,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 +136,154 @@ class ApiClient {
|
|||||||
return _handleResponse(url, response, finalParams);
|
return _handleResponse(url, response, finalParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build request body and attach token + username if required
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock event data for development / offline mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static final List<Map<String, dynamic>> _mockEvents = [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'name': 'Tech Innovation Summit 2026',
|
||||||
|
'title': 'Tech Innovation Summit',
|
||||||
|
'description':
|
||||||
|
'Join industry leaders for a two-day summit exploring the latest breakthroughs in AI, cloud computing, and sustainable technology. Featuring keynote speakers, hands-on workshops, and networking sessions.',
|
||||||
|
'start_date': '2026-04-15',
|
||||||
|
'end_date': '2026-04-16',
|
||||||
|
'start_time': '09:00',
|
||||||
|
'end_time': '18:00',
|
||||||
|
'pincode': '560001',
|
||||||
|
'place': 'Bengaluru International Exhibition Centre',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 5,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event1/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event1a/800/500'},
|
||||||
|
{'is_primary': false, 'image': 'https://picsum.photos/seed/event1b/800/500'},
|
||||||
|
],
|
||||||
|
'important_information': 'Please carry a valid photo ID for entry.',
|
||||||
|
'venue_name': 'BIEC Hall 2',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 13.0147,
|
||||||
|
'longitude': 77.5636,
|
||||||
|
'location_name': 'Bengaluru',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Entry', 'value': 'Free with registration'},
|
||||||
|
{'title': 'Parking', 'value': 'Available on-site'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 2,
|
||||||
|
'name': 'Sunset Music Festival',
|
||||||
|
'title': 'Sunset Music Festival',
|
||||||
|
'description':
|
||||||
|
'An open-air music festival featuring live performances from top artists across genres. Enjoy food stalls, art installations, and an unforgettable sunset experience.',
|
||||||
|
'start_date': '2026-04-20',
|
||||||
|
'end_date': '2026-04-20',
|
||||||
|
'start_time': '16:00',
|
||||||
|
'end_time': '23:00',
|
||||||
|
'pincode': '400001',
|
||||||
|
'place': 'Marine Drive Amphitheatre',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 1,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event2/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event2a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Marine Drive Amphitheatre',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 18.9432,
|
||||||
|
'longitude': 72.8235,
|
||||||
|
'location_name': 'Mumbai',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Age Limit', 'value': '16+'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 3,
|
||||||
|
'name': 'Creative Design Workshop',
|
||||||
|
'title': 'Hands-on Design Workshop',
|
||||||
|
'description':
|
||||||
|
'A full-day workshop on UI/UX design principles, prototyping in Figma, and building design systems. Perfect for beginners and intermediate designers.',
|
||||||
|
'start_date': '2026-05-03',
|
||||||
|
'end_date': '2026-05-03',
|
||||||
|
'start_time': '10:00',
|
||||||
|
'end_time': '17:00',
|
||||||
|
'pincode': '110001',
|
||||||
|
'place': 'Design Hub Co-working',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 2,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event3/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event3a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Design Hub',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 28.6139,
|
||||||
|
'longitude': 77.2090,
|
||||||
|
'location_name': 'New Delhi',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Bring', 'value': 'Laptop with Figma installed'},
|
||||||
|
{'title': 'Seats', 'value': '30 max'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 4,
|
||||||
|
'name': 'Marathon for a Cause',
|
||||||
|
'title': 'City Marathon 2026',
|
||||||
|
'description':
|
||||||
|
'Run for fitness, run for charity! Choose from 5K, 10K, or full marathon routes through the city. All proceeds support local education initiatives.',
|
||||||
|
'start_date': '2026-04-12',
|
||||||
|
'end_date': '2026-04-12',
|
||||||
|
'start_time': '05:30',
|
||||||
|
'end_time': '12:00',
|
||||||
|
'pincode': '600001',
|
||||||
|
'place': 'Marina Beach Road',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 4,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event4/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event4a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Marina Beach',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 13.0500,
|
||||||
|
'longitude': 80.2824,
|
||||||
|
'location_name': 'Chennai',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Registration', 'value': 'Closes April 10'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 5,
|
||||||
|
'name': 'Art & Culture Exhibition',
|
||||||
|
'title': 'Contemporary Art Exhibition',
|
||||||
|
'description':
|
||||||
|
'Explore contemporary artworks from emerging and established artists. The exhibition features paintings, sculptures, and digital art installations.',
|
||||||
|
'start_date': '2026-05-10',
|
||||||
|
'end_date': '2026-05-15',
|
||||||
|
'start_time': '11:00',
|
||||||
|
'end_time': '20:00',
|
||||||
|
'pincode': '500001',
|
||||||
|
'place': 'Salar Jung Museum Grounds',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 6,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event5/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event5a/800/500'},
|
||||||
|
{'is_primary': false, 'image': 'https://picsum.photos/seed/event5b/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Salar Jung Museum',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 17.3713,
|
||||||
|
'longitude': 78.4804,
|
||||||
|
'location_name': 'Hyderabad',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Entry Fee', 'value': '₹200'},
|
||||||
|
{'title': 'Photography', 'value': 'Allowed without flash'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build request body and attach token + username if available
|
||||||
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
||||||
final Map<String, dynamic> finalBody = {};
|
final Map<String, dynamic> finalBody = {};
|
||||||
|
|
||||||
@@ -111,13 +291,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);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ class ApiEndpoints {
|
|||||||
// Change this to your desired backend base URL (local or UAT)
|
// Change this to your desired backend base URL (local or UAT)
|
||||||
// For local Django dev use: "http://127.0.0.1:8000/api"
|
// For local Django dev use: "http://127.0.0.1:8000/api"
|
||||||
// For UAT: "https://uat.eventifyplus.com/api"
|
// For UAT: "https://uat.eventifyplus.com/api"
|
||||||
static const String baseUrl = "https://uat.eventifyplus.com/api";
|
static const String baseUrl = "https://em.eventifyplus.com/api";
|
||||||
|
|
||||||
|
/// Base URL for media files (images, icons uploaded via Django admin).
|
||||||
|
/// Relative paths like `/media/...` are resolved against this.
|
||||||
|
static const String mediaBaseUrl = "https://em.eventifyplus.com";
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
static const String register = "$baseUrl/user/register/";
|
static const String register = "$baseUrl/user/register/";
|
||||||
@@ -23,4 +27,36 @@ 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/";
|
||||||
|
|
||||||
|
// Reviews (served by Node.js backend via app.eventifyplus.com)
|
||||||
|
static const String _reviewBase = "https://app.eventifyplus.com/api/reviews";
|
||||||
|
static const String reviewSubmit = "$_reviewBase/submit";
|
||||||
|
static const String reviewList = "$_reviewBase/list";
|
||||||
|
static const String reviewHelpful = "$_reviewBase/helpful";
|
||||||
|
static const String reviewFlag = "$_reviewBase/flag";
|
||||||
|
|
||||||
|
// Node.js gamification server (same host as reviews)
|
||||||
|
static const String _nodeBase = "https://app.eventifyplus.com/api";
|
||||||
|
|
||||||
|
// Gamification / Contributor Module
|
||||||
|
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||||
|
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||||
|
static const String shopItems = "$_nodeBase/v1/shop/items";
|
||||||
|
static const String shopRedeem = "$_nodeBase/v1/shop/redeem";
|
||||||
|
static const String contributeSubmit = "$_nodeBase/v1/gamification/submit-event";
|
||||||
|
static const String gradeContribution = "$_nodeBase/v1/admin/contributions/"; // append {id}/grade/
|
||||||
|
|
||||||
|
// Bookings
|
||||||
|
static const String ticketMetaList = "$baseUrl/bookings/ticket-meta/list/";
|
||||||
|
static const String cartAdd = "$baseUrl/bookings/cart/add/";
|
||||||
|
static const String checkout = "$baseUrl/bookings/checkout/";
|
||||||
|
static const String checkIn = "$baseUrl/bookings/check-in/";
|
||||||
|
|
||||||
|
// Auth - Google OAuth
|
||||||
|
static const String googleLogin = "$baseUrl/user/google-login/";
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
static const String notificationList = "$baseUrl/notifications/list/";
|
||||||
|
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
|
||||||
|
static const String notificationCount = "$baseUrl/notifications/count/";
|
||||||
}
|
}
|
||||||
|
|||||||
71
lib/core/auth/auth_guard.dart
Normal file
71
lib/core/auth/auth_guard.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
73
lib/core/utils/error_utils.dart
Normal file
73
lib/core/utils/error_utils.dart
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// lib/core/utils/error_utils.dart
|
||||||
|
|
||||||
|
/// Converts raw exceptions into user-friendly messages.
|
||||||
|
/// Strips technical details (hostnames, ports, stack traces, exception chains)
|
||||||
|
/// and returns a clean message safe to display in the UI.
|
||||||
|
String userFriendlyError(Object e) {
|
||||||
|
final raw = e.toString();
|
||||||
|
|
||||||
|
// Network / connectivity issues
|
||||||
|
if (raw.contains('SocketException') ||
|
||||||
|
raw.contains('Connection refused') ||
|
||||||
|
raw.contains('Connection reset') ||
|
||||||
|
raw.contains('Network is unreachable') ||
|
||||||
|
raw.contains('No address associated') ||
|
||||||
|
raw.contains('Failed to fetch') ||
|
||||||
|
raw.contains('HandshakeException') ||
|
||||||
|
raw.contains('ClientException')) {
|
||||||
|
return 'Unable to connect. Please check your internet connection and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
if (raw.contains('TimeoutException') || raw.contains('timed out')) {
|
||||||
|
return 'The request took too long. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limited
|
||||||
|
if (raw.contains('status 429') || raw.contains('throttled') || raw.contains('Too Many Requests')) {
|
||||||
|
return 'Too many requests. Please wait a moment and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth expired / forbidden
|
||||||
|
if (raw.contains('status 401') || raw.contains('Unauthorized')) {
|
||||||
|
return 'Session expired. Please log in again.';
|
||||||
|
}
|
||||||
|
if (raw.contains('status 403') || raw.contains('Forbidden')) {
|
||||||
|
return 'You do not have permission to perform this action.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server error
|
||||||
|
if (RegExp(r'status 5\d\d').hasMatch(raw)) {
|
||||||
|
return 'Something went wrong on our end. Please try again later.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
if (raw.contains('status 404') || raw.contains('Not Found')) {
|
||||||
|
return 'The requested resource was not found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip Exception wrappers and nested chains for validation messages
|
||||||
|
var cleaned = raw
|
||||||
|
.replaceAll(RegExp(r'Exception:\s*'), '')
|
||||||
|
.replaceAll(RegExp(r'Failed to \w+ \w+:\s*'), '')
|
||||||
|
.replaceAll(RegExp(r'Network error:\s*'), '')
|
||||||
|
.replaceAll(RegExp(r'Request failed \(status \d+\)\s*'), '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// If the cleaned message is empty or still looks technical, use a generic fallback
|
||||||
|
if (cleaned.isEmpty ||
|
||||||
|
cleaned.contains('errno') ||
|
||||||
|
cleaned.contains('address =') ||
|
||||||
|
cleaned.contains('port =') ||
|
||||||
|
cleaned.startsWith('{') ||
|
||||||
|
cleaned.startsWith('[')) {
|
||||||
|
return 'Something went wrong. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter
|
||||||
|
if (cleaned.isNotEmpty) {
|
||||||
|
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
@@ -59,6 +59,19 @@ class AuthProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Google OAuth login.
|
||||||
|
Future<void> googleLogin() async {
|
||||||
|
_loading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final user = await _authService.googleLogin();
|
||||||
|
_user = user;
|
||||||
|
} finally {
|
||||||
|
_loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
await _authService.logout();
|
await _authService.logout();
|
||||||
_user = null;
|
_user = null;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// lib/features/auth/services/auth_service.dart
|
// lib/features/auth/services/auth_service.dart
|
||||||
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:google_sign_in/google_sign_in.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_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 '../../../core/analytics/posthog_service.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
@@ -33,6 +36,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);
|
||||||
|
|
||||||
@@ -54,6 +60,12 @@ class AuthService {
|
|||||||
// Save phone if provided (optional)
|
// Save phone if provided (optional)
|
||||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||||
|
|
||||||
|
PostHogService.instance.identify(savedEmail, properties: {
|
||||||
|
'username': displayCandidate,
|
||||||
|
'login_method': 'email',
|
||||||
|
});
|
||||||
|
PostHogService.instance.capture('user_logged_in');
|
||||||
|
|
||||||
return UserModel.fromJson(res);
|
return UserModel.fromJson(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
||||||
@@ -66,15 +78,20 @@ class AuthService {
|
|||||||
required String email,
|
required String email,
|
||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
required String password,
|
required String password,
|
||||||
|
String? district,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final res = await _api.post(
|
final body = <String, dynamic>{
|
||||||
ApiEndpoints.register,
|
|
||||||
body: {
|
|
||||||
"email": email,
|
"email": email,
|
||||||
"phone_number": phoneNumber,
|
"phone_number": phoneNumber,
|
||||||
"password": password,
|
"password": password,
|
||||||
},
|
};
|
||||||
|
if (district != null && district.isNotEmpty) {
|
||||||
|
body["district"] = district;
|
||||||
|
}
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.register,
|
||||||
|
body: body,
|
||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -90,6 +107,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);
|
||||||
|
|
||||||
@@ -123,6 +143,54 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GOOGLE OAUTH LOGIN → returns UserModel
|
||||||
|
Future<UserModel> googleLogin() async {
|
||||||
|
try {
|
||||||
|
final googleSignIn = GoogleSignIn(scopes: ['email']);
|
||||||
|
final account = await googleSignIn.signIn();
|
||||||
|
if (account == null) throw Exception('Google sign-in cancelled');
|
||||||
|
|
||||||
|
final auth = await account.authentication;
|
||||||
|
final idToken = auth.idToken;
|
||||||
|
if (idToken == null) throw Exception('Failed to get Google ID token');
|
||||||
|
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.googleLogin,
|
||||||
|
body: {'id_token': idToken},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = res['token'];
|
||||||
|
if (token == null) throw Exception('Token missing from response');
|
||||||
|
|
||||||
|
final serverEmail = (res['email'] as String?) ?? account.email;
|
||||||
|
final displayName = (res['username'] as String?) ?? account.displayName ?? serverEmail;
|
||||||
|
|
||||||
|
AuthGuard.setGuest(false);
|
||||||
|
await TokenStorage.saveToken(token.toString(), serverEmail);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('current_email', serverEmail);
|
||||||
|
await prefs.setString('email', serverEmail);
|
||||||
|
final perKey = 'display_name_$serverEmail';
|
||||||
|
if ((prefs.getString(perKey) ?? '').isEmpty) {
|
||||||
|
await prefs.setString(perKey, displayName);
|
||||||
|
}
|
||||||
|
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||||
|
|
||||||
|
PostHogService.instance.identify(serverEmail, properties: {
|
||||||
|
'username': displayName,
|
||||||
|
'login_method': 'google',
|
||||||
|
});
|
||||||
|
PostHogService.instance.capture('user_logged_in');
|
||||||
|
|
||||||
|
return UserModel.fromJson(res);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('AuthService.googleLogin error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Logout – clear auth token and current_email (keep per-account display_name entries so they persist)
|
/// Logout – clear auth token and current_email (keep per-account display_name entries so they persist)
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
try {
|
try {
|
||||||
@@ -134,6 +202,8 @@ class AuthService {
|
|||||||
// Also remove canonical 'email' pointing to current user
|
// Also remove canonical 'email' pointing to current user
|
||||||
await prefs.remove('email');
|
await prefs.remove('email');
|
||||||
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
|
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
|
||||||
|
PostHogService.instance.capture('user_logged_out');
|
||||||
|
PostHogService.instance.reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
|
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
87
lib/features/booking/models/booking_models.dart
Normal file
87
lib/features/booking/models/booking_models.dart
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// lib/features/booking/models/booking_models.dart
|
||||||
|
|
||||||
|
class TicketMetaModel {
|
||||||
|
final int id;
|
||||||
|
final int eventId;
|
||||||
|
final String ticketType;
|
||||||
|
final double price;
|
||||||
|
final int availableQuantity;
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
const TicketMetaModel({
|
||||||
|
required this.id,
|
||||||
|
required this.eventId,
|
||||||
|
required this.ticketType,
|
||||||
|
required this.price,
|
||||||
|
this.availableQuantity = 0,
|
||||||
|
this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TicketMetaModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TicketMetaModel(
|
||||||
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
|
eventId: (json['event_id'] as num?)?.toInt() ?? (json['event'] as num?)?.toInt() ?? 0,
|
||||||
|
ticketType: json['ticket_type'] as String? ?? json['name'] as String? ?? '',
|
||||||
|
price: (json['price'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
availableQuantity: (json['available_quantity'] as num?)?.toInt() ?? 0,
|
||||||
|
description: json['description'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CartItemModel {
|
||||||
|
final TicketMetaModel ticket;
|
||||||
|
int quantity;
|
||||||
|
|
||||||
|
CartItemModel({required this.ticket, this.quantity = 1});
|
||||||
|
|
||||||
|
double get subtotal => ticket.price * quantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShippingDetails {
|
||||||
|
final String name;
|
||||||
|
final String email;
|
||||||
|
final String phone;
|
||||||
|
final String? address;
|
||||||
|
final String? city;
|
||||||
|
final String? state;
|
||||||
|
final String? zipCode;
|
||||||
|
|
||||||
|
const ShippingDetails({
|
||||||
|
required this.name,
|
||||||
|
required this.email,
|
||||||
|
required this.phone,
|
||||||
|
this.address,
|
||||||
|
this.city,
|
||||||
|
this.state,
|
||||||
|
this.zipCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'name': name,
|
||||||
|
'email': email,
|
||||||
|
'phone': phone,
|
||||||
|
if (address != null) 'address': address,
|
||||||
|
if (city != null) 'city': city,
|
||||||
|
if (state != null) 'state': state,
|
||||||
|
if (zipCode != null) 'zip_code': zipCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderSummary {
|
||||||
|
final List<CartItemModel> items;
|
||||||
|
final double subtotal;
|
||||||
|
final double discount;
|
||||||
|
final double tax;
|
||||||
|
final double total;
|
||||||
|
final String? couponCode;
|
||||||
|
|
||||||
|
const OrderSummary({
|
||||||
|
required this.items,
|
||||||
|
required this.subtotal,
|
||||||
|
this.discount = 0,
|
||||||
|
this.tax = 0,
|
||||||
|
required this.total,
|
||||||
|
this.couponCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
216
lib/features/booking/providers/checkout_provider.dart
Normal file
216
lib/features/booking/providers/checkout_provider.dart
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
// lib/features/booking/providers/checkout_provider.dart
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/booking_models.dart';
|
||||||
|
import '../services/booking_service.dart';
|
||||||
|
|
||||||
|
enum CheckoutStep { tickets, details, payment, confirmation }
|
||||||
|
|
||||||
|
class CheckoutProvider extends ChangeNotifier {
|
||||||
|
final BookingService _service = BookingService();
|
||||||
|
|
||||||
|
// Event being booked
|
||||||
|
int? eventId;
|
||||||
|
String eventName = '';
|
||||||
|
|
||||||
|
// Step tracking
|
||||||
|
CheckoutStep currentStep = CheckoutStep.tickets;
|
||||||
|
|
||||||
|
// Ticket selection
|
||||||
|
List<TicketMetaModel> availableTickets = [];
|
||||||
|
List<CartItemModel> cart = [];
|
||||||
|
|
||||||
|
// Shipping
|
||||||
|
ShippingDetails? shippingDetails;
|
||||||
|
|
||||||
|
// Coupon / promo
|
||||||
|
String? couponCode;
|
||||||
|
double discountAmount = 0.0;
|
||||||
|
String? promoMessage;
|
||||||
|
bool promoApplied = false;
|
||||||
|
|
||||||
|
// Status
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
String? paymentId;
|
||||||
|
|
||||||
|
/// Initialize checkout for an event.
|
||||||
|
Future<void> initForEvent(int eventId, String eventName) async {
|
||||||
|
this.eventId = eventId;
|
||||||
|
this.eventName = eventName;
|
||||||
|
currentStep = CheckoutStep.tickets;
|
||||||
|
cart = [];
|
||||||
|
shippingDetails = null;
|
||||||
|
couponCode = null;
|
||||||
|
discountAmount = 0.0;
|
||||||
|
promoMessage = null;
|
||||||
|
promoApplied = false;
|
||||||
|
paymentId = null;
|
||||||
|
error = null;
|
||||||
|
loading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
availableTickets = await _service.getTicketMeta(eventId);
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add or update cart item.
|
||||||
|
void setTicketQuantity(TicketMetaModel ticket, int qty) {
|
||||||
|
cart.removeWhere((c) => c.ticket.id == ticket.id);
|
||||||
|
if (qty > 0) {
|
||||||
|
cart.add(CartItemModel(ticket: ticket, quantity: qty));
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
double get subtotal => cart.fold(0, (sum, item) => sum + item.subtotal);
|
||||||
|
double get total => subtotal - discountAmount;
|
||||||
|
|
||||||
|
bool get hasItems => cart.isNotEmpty;
|
||||||
|
|
||||||
|
/// Move to next step.
|
||||||
|
void nextStep() {
|
||||||
|
if (currentStep == CheckoutStep.tickets && hasItems) {
|
||||||
|
currentStep = CheckoutStep.details;
|
||||||
|
} else if (currentStep == CheckoutStep.details && shippingDetails != null) {
|
||||||
|
currentStep = CheckoutStep.payment;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move to previous step.
|
||||||
|
void previousStep() {
|
||||||
|
if (currentStep == CheckoutStep.payment) {
|
||||||
|
currentStep = CheckoutStep.details;
|
||||||
|
} else if (currentStep == CheckoutStep.details) {
|
||||||
|
currentStep = CheckoutStep.tickets;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set shipping details from form.
|
||||||
|
void setShipping(ShippingDetails details) {
|
||||||
|
shippingDetails = details;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a promo code against the backend.
|
||||||
|
Future<bool> applyPromo(String code) async {
|
||||||
|
if (code.trim().isEmpty) return false;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString('access_token') ?? '';
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiEndpoints.baseUrl}/bookings/apply-promo/'),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'code': code.trim(), 'event_id': eventId}),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
if (data['valid'] == true) {
|
||||||
|
discountAmount = (data['discount_amount'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
couponCode = code.trim();
|
||||||
|
promoMessage = data['message'] as String? ?? 'Promo applied!';
|
||||||
|
promoApplied = true;
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
promoMessage = data['message'] as String? ?? 'Invalid promo code';
|
||||||
|
promoApplied = false;
|
||||||
|
discountAmount = 0.0;
|
||||||
|
couponCode = null;
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
promoMessage = 'Could not apply promo code';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
promoMessage = 'Could not apply promo code';
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove applied promo code.
|
||||||
|
void resetPromo() {
|
||||||
|
discountAmount = 0.0;
|
||||||
|
couponCode = null;
|
||||||
|
promoMessage = null;
|
||||||
|
promoApplied = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process checkout on backend.
|
||||||
|
Future<Map<String, dynamic>> processCheckout() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tickets = cart.map((c) => {
|
||||||
|
'ticket_meta_id': c.ticket.id,
|
||||||
|
'quantity': c.quantity,
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final res = await _service.processCheckout(
|
||||||
|
eventId: eventId!,
|
||||||
|
tickets: tickets,
|
||||||
|
shippingDetails: shippingDetails?.toJson() ?? {},
|
||||||
|
couponCode: couponCode,
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark payment as complete.
|
||||||
|
void markPaymentSuccess(String id) {
|
||||||
|
paymentId = id;
|
||||||
|
currentStep = CheckoutStep.confirmation;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset checkout state.
|
||||||
|
void reset() {
|
||||||
|
eventId = null;
|
||||||
|
eventName = '';
|
||||||
|
currentStep = CheckoutStep.tickets;
|
||||||
|
availableTickets = [];
|
||||||
|
cart = [];
|
||||||
|
shippingDetails = null;
|
||||||
|
couponCode = null;
|
||||||
|
discountAmount = 0.0;
|
||||||
|
promoMessage = null;
|
||||||
|
promoApplied = false;
|
||||||
|
paymentId = null;
|
||||||
|
error = null;
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/features/booking/services/booking_service.dart
Normal file
53
lib/features/booking/services/booking_service.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// lib/features/booking/services/booking_service.dart
|
||||||
|
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../models/booking_models.dart';
|
||||||
|
|
||||||
|
class BookingService {
|
||||||
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Fetch available ticket types for an event.
|
||||||
|
Future<List<TicketMetaModel>> getTicketMeta(int eventId) async {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.ticketMetaList,
|
||||||
|
body: {'event_id': eventId},
|
||||||
|
);
|
||||||
|
final rawList = res['ticket_metas'] ?? res['tickets'] ?? res['data'] ?? [];
|
||||||
|
if (rawList is List) {
|
||||||
|
return rawList
|
||||||
|
.map((e) => TicketMetaModel.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add item to cart.
|
||||||
|
Future<Map<String, dynamic>> addToCart({
|
||||||
|
required int ticketMetaId,
|
||||||
|
required int quantity,
|
||||||
|
}) async {
|
||||||
|
return await _api.post(
|
||||||
|
ApiEndpoints.cartAdd,
|
||||||
|
body: {'ticket_meta_id': ticketMetaId, 'quantity': quantity},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process checkout — creates booking + returns order ID for payment.
|
||||||
|
Future<Map<String, dynamic>> processCheckout({
|
||||||
|
required int eventId,
|
||||||
|
required List<Map<String, dynamic>> tickets,
|
||||||
|
required Map<String, dynamic> shippingDetails,
|
||||||
|
String? couponCode,
|
||||||
|
}) async {
|
||||||
|
return await _api.post(
|
||||||
|
ApiEndpoints.checkout,
|
||||||
|
body: {
|
||||||
|
'event_id': eventId,
|
||||||
|
'tickets': tickets,
|
||||||
|
'shipping': shippingDetails,
|
||||||
|
if (couponCode != null) 'coupon_code': couponCode,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/features/booking/services/payment_service.dart
Normal file
67
lib/features/booking/services/payment_service.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// lib/features/booking/services/payment_service.dart
|
||||||
|
|
||||||
|
import 'package:razorpay_flutter/razorpay_flutter.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
typedef PaymentSuccessCallback = void Function(PaymentSuccessResponse response);
|
||||||
|
typedef PaymentErrorCallback = void Function(PaymentFailureResponse response);
|
||||||
|
typedef ExternalWalletCallback = void Function(ExternalWalletResponse response);
|
||||||
|
|
||||||
|
class PaymentService {
|
||||||
|
late Razorpay _razorpay;
|
||||||
|
|
||||||
|
// Razorpay test key — matches web app
|
||||||
|
static const String _testKey = 'rzp_test_S49PVZmqAVoWSH';
|
||||||
|
|
||||||
|
PaymentSuccessCallback? onSuccess;
|
||||||
|
PaymentErrorCallback? onError;
|
||||||
|
ExternalWalletCallback? onExternalWallet;
|
||||||
|
|
||||||
|
void initialize({
|
||||||
|
required PaymentSuccessCallback onSuccess,
|
||||||
|
required PaymentErrorCallback onError,
|
||||||
|
ExternalWalletCallback? onExternalWallet,
|
||||||
|
}) {
|
||||||
|
_razorpay = Razorpay();
|
||||||
|
this.onSuccess = onSuccess;
|
||||||
|
this.onError = onError;
|
||||||
|
this.onExternalWallet = onExternalWallet;
|
||||||
|
|
||||||
|
_razorpay.on(Razorpay.EVENT_PAYMENT_SUCCESS, _handleSuccess);
|
||||||
|
_razorpay.on(Razorpay.EVENT_PAYMENT_ERROR, _handleError);
|
||||||
|
_razorpay.on(Razorpay.EVENT_EXTERNAL_WALLET, _handleExternalWallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
void openPayment({
|
||||||
|
required double amount,
|
||||||
|
required String email,
|
||||||
|
required String phone,
|
||||||
|
required String eventName,
|
||||||
|
String? orderId,
|
||||||
|
}) {
|
||||||
|
final options = <String, dynamic>{
|
||||||
|
'key': _testKey,
|
||||||
|
'amount': (amount * 100).toInt(), // paise
|
||||||
|
'currency': 'INR',
|
||||||
|
'name': 'Eventify',
|
||||||
|
'description': 'Ticket: $eventName',
|
||||||
|
'prefill': {
|
||||||
|
'email': email,
|
||||||
|
'contact': phone,
|
||||||
|
},
|
||||||
|
'theme': {'color': '#0B63D6'},
|
||||||
|
};
|
||||||
|
if (orderId != null) options['order_id'] = orderId;
|
||||||
|
|
||||||
|
if (kDebugMode) debugPrint('PaymentService: opening Razorpay with amount=${amount * 100} paise');
|
||||||
|
_razorpay.open(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSuccess(PaymentSuccessResponse res) => onSuccess?.call(res);
|
||||||
|
void _handleError(PaymentFailureResponse res) => onError?.call(res);
|
||||||
|
void _handleExternalWallet(ExternalWalletResponse res) => onExternalWallet?.call(res);
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_razorpay.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// lib/features/events/models/event_models.dart
|
// lib/features/events/models/event_models.dart
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
|
||||||
class EventTypeModel {
|
class EventTypeModel {
|
||||||
final int id;
|
final int id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -6,11 +8,18 @@ class EventTypeModel {
|
|||||||
|
|
||||||
EventTypeModel({required this.id, required this.name, this.iconUrl});
|
EventTypeModel({required this.id, required this.name, this.iconUrl});
|
||||||
|
|
||||||
|
/// Resolve a relative media path (e.g. `/media/...`) to a full URL.
|
||||||
|
static String? _resolveMediaUrl(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
if (raw.startsWith('http://') || raw.startsWith('https://')) return raw;
|
||||||
|
return '${ApiEndpoints.mediaBaseUrl}$raw';
|
||||||
|
}
|
||||||
|
|
||||||
factory EventTypeModel.fromJson(Map<String, dynamic> j) {
|
factory EventTypeModel.fromJson(Map<String, dynamic> j) {
|
||||||
return EventTypeModel(
|
return EventTypeModel(
|
||||||
id: j['id'] as int,
|
id: j['id'] as int,
|
||||||
name: (j['event_type'] ?? j['name'] ?? '') as String,
|
name: (j['event_type'] ?? j['name'] ?? '') as String,
|
||||||
iconUrl: (j['event_type_icon'] ?? j['icon_url']) as String?,
|
iconUrl: _resolveMediaUrl((j['event_type_icon'] ?? j['icon_url']) as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +33,7 @@ class EventImageModel {
|
|||||||
factory EventImageModel.fromJson(Map<String, dynamic> j) {
|
factory EventImageModel.fromJson(Map<String, dynamic> j) {
|
||||||
return EventImageModel(
|
return EventImageModel(
|
||||||
isPrimary: j['is_primary'] == true,
|
isPrimary: j['is_primary'] == true,
|
||||||
image: (j['image'] ?? '') as String,
|
image: EventTypeModel._resolveMediaUrl(j['image'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,6 +68,15 @@ class EventModel {
|
|||||||
// Structured important info list [{title, value}, ...]
|
// Structured important info list [{title, value}, ...]
|
||||||
final List<Map<String, String>> importantInfo;
|
final List<Map<String, String>> importantInfo;
|
||||||
|
|
||||||
|
// Review stats (populated when backend includes them)
|
||||||
|
final double? averageRating;
|
||||||
|
final int? reviewCount;
|
||||||
|
|
||||||
|
// Contributor fields (EVT-001)
|
||||||
|
final String? contributorId;
|
||||||
|
final String? contributorName;
|
||||||
|
final String? contributorTier;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -82,6 +100,11 @@ class EventModel {
|
|||||||
this.longitude,
|
this.longitude,
|
||||||
this.locationName,
|
this.locationName,
|
||||||
this.importantInfo = const [],
|
this.importantInfo = const [],
|
||||||
|
this.averageRating,
|
||||||
|
this.reviewCount,
|
||||||
|
this.contributorId,
|
||||||
|
this.contributorName,
|
||||||
|
this.contributorTier,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Safely parse a double from backend (may arrive as String or num)
|
/// Safely parse a double from backend (may arrive as String or num)
|
||||||
@@ -129,7 +152,7 @@ class EventModel {
|
|||||||
place: (j['place'] ?? j['venue_name']) as String?,
|
place: (j['place'] ?? j['venue_name']) as String?,
|
||||||
isBookable: j['is_bookable'] == null ? true : (j['is_bookable'] == true || j['is_bookable'].toString().toLowerCase() == 'true'),
|
isBookable: j['is_bookable'] == null ? true : (j['is_bookable'] == true || j['is_bookable'].toString().toLowerCase() == 'true'),
|
||||||
eventTypeId: j['event_type'] is int ? j['event_type'] as int : (j['event_type'] != null ? int.tryParse(j['event_type'].toString()) : null),
|
eventTypeId: j['event_type'] is int ? j['event_type'] as int : (j['event_type'] != null ? int.tryParse(j['event_type'].toString()) : null),
|
||||||
thumbImg: j['thumb_img'] as String?,
|
thumbImg: EventTypeModel._resolveMediaUrl(j['thumb_img'] as String?),
|
||||||
images: imgs,
|
images: imgs,
|
||||||
importantInformation: j['important_information'] as String?,
|
importantInformation: j['important_information'] as String?,
|
||||||
venueName: j['venue_name'] as String?,
|
venueName: j['venue_name'] as String?,
|
||||||
@@ -139,6 +162,11 @@ class EventModel {
|
|||||||
longitude: _parseDouble(j['longitude']),
|
longitude: _parseDouble(j['longitude']),
|
||||||
locationName: j['location_name'] as String?,
|
locationName: j['location_name'] as String?,
|
||||||
importantInfo: _parseImportantInfo(j['important_info']),
|
importantInfo: _parseImportantInfo(j['important_info']),
|
||||||
|
averageRating: (j['average_rating'] as num?)?.toDouble(),
|
||||||
|
reviewCount: (j['review_count'] as num?)?.toInt(),
|
||||||
|
contributorId: j['contributor_id']?.toString(),
|
||||||
|
contributorName: j['contributor_name'] as String?,
|
||||||
|
contributorTier: j['contributor_tier'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// lib/features/events/services/events_service.dart
|
// lib/features/events/services/events_service.dart
|
||||||
import 'package:intl/intl.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 '../models/event_models.dart';
|
import '../models/event_models.dart';
|
||||||
@@ -7,27 +6,65 @@ import '../models/event_models.dart';
|
|||||||
class EventsService {
|
class EventsService {
|
||||||
final ApiClient _api = ApiClient();
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// In-memory caches with TTL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
static List<EventTypeModel>? _cachedTypes;
|
||||||
|
static DateTime? _typesCacheTime;
|
||||||
|
static const _typesCacheTTL = Duration(minutes: 30);
|
||||||
|
|
||||||
|
static List<EventModel>? _cachedAllEvents;
|
||||||
|
static DateTime? _eventsCacheTime;
|
||||||
|
static const _eventsCacheTTL = Duration(minutes: 5);
|
||||||
|
|
||||||
/// Get event types (POST to /events/type-list/)
|
/// Get event types (POST to /events/type-list/)
|
||||||
|
/// Cached for 30 minutes since event types rarely change.
|
||||||
Future<List<EventTypeModel>> getEventTypes() async {
|
Future<List<EventTypeModel>> getEventTypes() async {
|
||||||
final res = await _api.post(ApiEndpoints.eventTypes);
|
if (_cachedTypes != null &&
|
||||||
|
_typesCacheTime != null &&
|
||||||
|
DateTime.now().difference(_typesCacheTime!) < _typesCacheTTL) {
|
||||||
|
return _cachedTypes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final res = await _api.post(ApiEndpoints.eventTypes, requiresAuth: false);
|
||||||
final list = <EventTypeModel>[];
|
final list = <EventTypeModel>[];
|
||||||
final data = res['event_types'] ?? res['event_types'] ?? res;
|
final data = res['event_types'] ?? res;
|
||||||
if (data is List) {
|
if (data is List) {
|
||||||
for (final e in data) {
|
for (final e in data) {
|
||||||
if (e is Map<String, dynamic>) list.add(EventTypeModel.fromJson(e));
|
if (e is Map<String, dynamic>) list.add(EventTypeModel.fromJson(e));
|
||||||
}
|
}
|
||||||
} else if (res['event_types'] is List) {
|
|
||||||
for (final e in res['event_types']) {
|
|
||||||
list.add(EventTypeModel.fromJson(Map<String, dynamic>.from(e)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_cachedTypes = list;
|
||||||
|
_typesCacheTime = DateTime.now();
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get events filtered by pincode (POST to /events/pincode-events/)
|
/// Get events filtered by pincode with pagination.
|
||||||
/// Use pincode='all' to fetch all events.
|
/// [page] starts at 1. [pageSize] defaults to 50.
|
||||||
Future<List<EventModel>> getEventsByPincode(String pincode) async {
|
/// Returns a list of events for the requested page.
|
||||||
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode});
|
Future<List<EventModel>> getEventsByPincode(String pincode, {int page = 1, int pageSize = 50, int perType = 5, String q = ''}) async {
|
||||||
|
// Use cache for 'all' pincode queries (first page only, no active search)
|
||||||
|
if (pincode == 'all' &&
|
||||||
|
page == 1 &&
|
||||||
|
q.isEmpty &&
|
||||||
|
_cachedAllEvents != null &&
|
||||||
|
_eventsCacheTime != null &&
|
||||||
|
DateTime.now().difference(_eventsCacheTime!) < _eventsCacheTTL) {
|
||||||
|
return _cachedAllEvents!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body = {'pincode': pincode, 'page': page, 'page_size': pageSize};
|
||||||
|
// Diverse mode: fetch a few events per type so all categories are represented
|
||||||
|
if (perType > 0 && page == 1) body['per_type'] = perType;
|
||||||
|
// Server-side search filter
|
||||||
|
if (q.isNotEmpty) body['q'] = q;
|
||||||
|
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.eventsByPincode,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
final list = <EventModel>[];
|
final list = <EventModel>[];
|
||||||
final events = res['events'] ?? res['data'] ?? [];
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
if (events is List) {
|
if (events is List) {
|
||||||
@@ -35,33 +72,85 @@ class EventsService {
|
|||||||
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
|
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pincode == 'all' && page == 1) {
|
||||||
|
_cachedAllEvents = list;
|
||||||
|
_eventsCacheTime = DateTime.now();
|
||||||
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event details
|
/// Event details — requiresAuth: false so guests can fetch full 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Related events by event_type_id (EVT-002).
|
||||||
|
/// Fetches events with the same category, silently returns [] on failure.
|
||||||
|
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
|
||||||
|
try {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.eventsByCategory,
|
||||||
|
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
|
||||||
|
if (results is List) {
|
||||||
|
return results
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => EventModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// silently fail — related events are non-critical
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get events by GPS coordinates using haversine distance filtering.
|
||||||
|
/// Automatically expands radius (10 → 25 → 50 → 100 km) until ≥ 6 events found.
|
||||||
|
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double initialRadiusKm = 10}) async {
|
||||||
|
const radii = [10.0, 25.0, 50.0, 100.0];
|
||||||
|
for (final radius in radii) {
|
||||||
|
if (radius < initialRadiusKm) continue;
|
||||||
|
final body = {
|
||||||
|
'latitude': lat,
|
||||||
|
'longitude': lng,
|
||||||
|
'radius_km': radius,
|
||||||
|
'page': 1,
|
||||||
|
'page_size': 50,
|
||||||
|
'per_type': 5,
|
||||||
|
};
|
||||||
|
final res = await _api.post(ApiEndpoints.eventsByPincode, body: body, requiresAuth: false);
|
||||||
|
final list = <EventModel>[];
|
||||||
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
|
if (events is List) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list.length >= 6 || radius >= 100) return list;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
||||||
/// Accepts month string and year int.
|
|
||||||
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
|
|
||||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
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
|
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience: get events for a specific date (YYYY-MM-DD)
|
/// Convenience: get events for a specific date (YYYY-MM-DD).
|
||||||
|
/// Uses the cached events list when available to avoid redundant API calls.
|
||||||
Future<List<EventModel>> getEventsForDate(String date) async {
|
Future<List<EventModel>> getEventsForDate(String date) async {
|
||||||
// Simplest approach: hit pincode-events with filter or hit events-by-month-year and then
|
|
||||||
// query event-details for events of that date. Assuming backend doesn't provide direct endpoint,
|
|
||||||
// we'll call eventsByPincode('all') and filter locally by date — acceptable for demo/small datasets.
|
|
||||||
final all = await getEventsByPincode('all');
|
final all = await getEventsByPincode('all');
|
||||||
return all.where((e) {
|
return all.where((e) {
|
||||||
try {
|
try {
|
||||||
return e.startDate == date || e.endDate == date || (DateTime.parse(e.startDate).isBefore(DateTime.parse(date)) && DateTime.parse(e.endDate).isAfter(DateTime.parse(date)));
|
return e.startDate == date ||
|
||||||
|
e.endDate == date ||
|
||||||
|
(DateTime.parse(e.startDate).isBefore(DateTime.parse(date)) &&
|
||||||
|
DateTime.parse(e.endDate).isAfter(DateTime.parse(date)));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
346
lib/features/gamification/models/gamification_models.dart
Normal file
346
lib/features/gamification/models/gamification_models.dart
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
// 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 — maps from Node.js API response fields
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class LeaderboardEntry {
|
||||||
|
final int rank;
|
||||||
|
final String username;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final int lifetimeEp;
|
||||||
|
final int monthlyPoints;
|
||||||
|
final ContributorTier tier;
|
||||||
|
final int eventsCount;
|
||||||
|
final bool isCurrentUser;
|
||||||
|
final String? district;
|
||||||
|
|
||||||
|
const LeaderboardEntry({
|
||||||
|
required this.rank,
|
||||||
|
required this.username,
|
||||||
|
this.avatarUrl,
|
||||||
|
required this.lifetimeEp,
|
||||||
|
this.monthlyPoints = 0,
|
||||||
|
required this.tier,
|
||||||
|
required this.eventsCount,
|
||||||
|
this.isCurrentUser = false,
|
||||||
|
this.district,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
// Node.js API returns 'points' for lifetime EP and 'name' for username
|
||||||
|
final ep = (json['points'] as num?)?.toInt() ?? (json['lifetime_ep'] as num?)?.toInt() ?? 0;
|
||||||
|
final tierStr = json['level'] as String? ?? json['tier'] as String?;
|
||||||
|
return LeaderboardEntry(
|
||||||
|
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||||
|
username: json['name'] as String? ?? json['username'] as String? ?? '',
|
||||||
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
|
lifetimeEp: ep,
|
||||||
|
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
|
||||||
|
tier: tierStr != null ? _tierFromString(tierStr) : tierFromEp(ep),
|
||||||
|
eventsCount: (json['eventsAdded'] as num?)?.toInt() ?? (json['events_count'] as num?)?.toInt() ?? 0,
|
||||||
|
isCurrentUser: (json['is_current_user'] as bool?) ?? false,
|
||||||
|
district: json['district'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse tier string from API (e.g. "Gold") to enum.
|
||||||
|
ContributorTier _tierFromString(String s) {
|
||||||
|
switch (s.toLowerCase()) {
|
||||||
|
case 'diamond': return ContributorTier.DIAMOND;
|
||||||
|
case 'platinum': return ContributorTier.PLATINUM;
|
||||||
|
case 'gold': return ContributorTier.GOLD;
|
||||||
|
case 'silver': return ContributorTier.SILVER;
|
||||||
|
default: return ContributorTier.BRONZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CurrentUserStats — from leaderboard API's currentUser field
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class CurrentUserStats {
|
||||||
|
final int rank;
|
||||||
|
final int points;
|
||||||
|
final int monthlyPoints;
|
||||||
|
final String level;
|
||||||
|
final int rewardCycleDays;
|
||||||
|
final int eventsAdded;
|
||||||
|
final String? district;
|
||||||
|
|
||||||
|
const CurrentUserStats({
|
||||||
|
required this.rank,
|
||||||
|
required this.points,
|
||||||
|
this.monthlyPoints = 0,
|
||||||
|
required this.level,
|
||||||
|
this.rewardCycleDays = 0,
|
||||||
|
this.eventsAdded = 0,
|
||||||
|
this.district,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CurrentUserStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CurrentUserStats(
|
||||||
|
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||||
|
points: (json['points'] as num?)?.toInt() ?? 0,
|
||||||
|
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
|
||||||
|
level: json['level'] as String? ?? 'Bronze',
|
||||||
|
rewardCycleDays: (json['rewardCycleDays'] as num?)?.toInt() ?? 0,
|
||||||
|
eventsAdded: (json['eventsAdded'] as num?)?.toInt() ?? 0,
|
||||||
|
district: json['district'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LeaderboardResponse — wraps the full leaderboard API response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class LeaderboardResponse {
|
||||||
|
final List<LeaderboardEntry> entries;
|
||||||
|
final CurrentUserStats? currentUser;
|
||||||
|
final int totalParticipants;
|
||||||
|
|
||||||
|
const LeaderboardResponse({
|
||||||
|
required this.entries,
|
||||||
|
this.currentUser,
|
||||||
|
this.totalParticipants = 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SubmissionModel — event submissions from dashboard API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class SubmissionModel {
|
||||||
|
final String id;
|
||||||
|
final String eventName;
|
||||||
|
final String category;
|
||||||
|
final String status; // PENDING, APPROVED, REJECTED
|
||||||
|
final String? district;
|
||||||
|
final int epAwarded;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final List<String> images;
|
||||||
|
|
||||||
|
const SubmissionModel({
|
||||||
|
required this.id,
|
||||||
|
required this.eventName,
|
||||||
|
this.category = '',
|
||||||
|
required this.status,
|
||||||
|
this.district,
|
||||||
|
this.epAwarded = 0,
|
||||||
|
required this.createdAt,
|
||||||
|
this.images = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SubmissionModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawImages = json['images'] as List? ?? [];
|
||||||
|
return SubmissionModel(
|
||||||
|
id: (json['id'] ?? json['submission_id'] ?? '').toString(),
|
||||||
|
eventName: json['event_name'] as String? ?? '',
|
||||||
|
category: json['category'] as String? ?? '',
|
||||||
|
status: json['status'] as String? ?? 'PENDING',
|
||||||
|
district: json['district'] as String?,
|
||||||
|
epAwarded: (json['total_ep_awarded'] as num?)?.toInt() ?? (json['ep_awarded'] as num?)?.toInt() ?? 0,
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
images: rawImages.map((e) => e.toString()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DashboardResponse — wraps the full dashboard API response
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class DashboardResponse {
|
||||||
|
final UserGamificationProfile profile;
|
||||||
|
final List<SubmissionModel> submissions;
|
||||||
|
final List<AchievementBadge> achievements;
|
||||||
|
|
||||||
|
const DashboardResponse({
|
||||||
|
required this.profile,
|
||||||
|
this.submissions = const [],
|
||||||
|
this.achievements = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ShopItem — mirrors `RedeemShopItem` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AchievementBadge.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AchievementBadge(
|
||||||
|
id: (json['id'] ?? json['badge_id'] ?? '').toString(),
|
||||||
|
title: (json['title'] ?? json['name'] ?? '').toString(),
|
||||||
|
description: (json['description'] ?? '').toString(),
|
||||||
|
iconName: (json['icon_name'] ?? json['icon'] ?? 'star').toString(),
|
||||||
|
isUnlocked: json['is_unlocked'] == true || json['unlocked'] == true,
|
||||||
|
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
lib/features/gamification/providers/gamification_provider.dart
Normal file
156
lib/features/gamification/providers/gamification_provider.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// lib/features/gamification/providers/gamification_provider.dart
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/gamification_models.dart';
|
||||||
|
import '../services/gamification_service.dart';
|
||||||
|
|
||||||
|
class GamificationProvider extends ChangeNotifier {
|
||||||
|
final GamificationService _service = GamificationService();
|
||||||
|
|
||||||
|
// State
|
||||||
|
UserGamificationProfile? profile;
|
||||||
|
List<LeaderboardEntry> leaderboard = [];
|
||||||
|
List<ShopItem> shopItems = [];
|
||||||
|
List<AchievementBadge> achievements = [];
|
||||||
|
List<SubmissionModel> submissions = [];
|
||||||
|
CurrentUserStats? currentUserStats;
|
||||||
|
int totalParticipants = 0;
|
||||||
|
|
||||||
|
// Leaderboard filters — matches web version
|
||||||
|
String leaderboardDistrict = 'Overall Kerala';
|
||||||
|
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
|
||||||
|
|
||||||
|
bool isLoading = false;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
// TTL guard — prevents redundant API calls from multiple screens
|
||||||
|
DateTime? _lastLoadTime;
|
||||||
|
static const _loadTtl = Duration(minutes: 2);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> loadAll({bool force = false}) async {
|
||||||
|
// Skip if recently loaded (within 2 minutes) unless forced
|
||||||
|
if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_service.getDashboard(),
|
||||||
|
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
||||||
|
_service.getShopItems(),
|
||||||
|
_service.getAchievements(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final dashboard = results[0] as DashboardResponse;
|
||||||
|
profile = dashboard.profile;
|
||||||
|
submissions = dashboard.submissions;
|
||||||
|
|
||||||
|
final lbResponse = results[1] as LeaderboardResponse;
|
||||||
|
leaderboard = lbResponse.entries;
|
||||||
|
currentUserStats = lbResponse.currentUser;
|
||||||
|
totalParticipants = lbResponse.totalParticipants;
|
||||||
|
|
||||||
|
shopItems = results[2] as List<ShopItem>;
|
||||||
|
|
||||||
|
// Prefer achievements from dashboard API; fall back to getAchievements()
|
||||||
|
final dashAchievements = dashboard.achievements;
|
||||||
|
final fetchedAchievements = results[3] as List<AchievementBadge>;
|
||||||
|
achievements = dashAchievements.isNotEmpty ? dashAchievements : fetchedAchievements;
|
||||||
|
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change district filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setDistrict(String district) async {
|
||||||
|
if (leaderboardDistrict == district) return;
|
||||||
|
leaderboardDistrict = district;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||||
|
leaderboard = response.entries;
|
||||||
|
currentUserStats = response.currentUser;
|
||||||
|
totalParticipants = response.totalParticipants;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change time period filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setTimePeriod(String period) async {
|
||||||
|
if (leaderboardTimePeriod == period) return;
|
||||||
|
leaderboardTimePeriod = period;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
|
||||||
|
leaderboard = response.entries;
|
||||||
|
currentUserStats = response.currentUser;
|
||||||
|
totalParticipants = response.totalParticipants;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
lib/features/gamification/services/gamification_service.dart
Normal file
194
lib/features/gamification/services/gamification_service.dart
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
// lib/features/gamification/services/gamification_service.dart
|
||||||
|
//
|
||||||
|
// Real API service for the Contributor / Gamification module.
|
||||||
|
// Calls the Node.js gamification server at app.eventifyplus.com.
|
||||||
|
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../models/gamification_models.dart';
|
||||||
|
|
||||||
|
class GamificationService {
|
||||||
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Helper: get current user's email for API calls.
|
||||||
|
Future<String> _getUserEmail() async {
|
||||||
|
final email = await TokenStorage.getUsername();
|
||||||
|
return email ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dashboard (profile + submissions)
|
||||||
|
// GET /v1/gamification/dashboard?user_id={email}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<DashboardResponse> getDashboard() async {
|
||||||
|
final email = await _getUserEmail();
|
||||||
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$email';
|
||||||
|
final res = await _api.get(url, requiresAuth: false);
|
||||||
|
|
||||||
|
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||||
|
final rawSubs = res['submissions'] as List? ?? [];
|
||||||
|
final rawAchievements = res['achievements'] as List? ?? [];
|
||||||
|
|
||||||
|
final submissions = rawSubs
|
||||||
|
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final achievements = rawAchievements
|
||||||
|
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
profile: UserGamificationProfile.fromJson(profileJson),
|
||||||
|
submissions: submissions,
|
||||||
|
achievements: achievements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public contributor profile (any user by userId / email)
|
||||||
|
// GET /v1/gamification/dashboard?user_id={userId}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<DashboardResponse> getDashboardForUser(String userId) async {
|
||||||
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$userId';
|
||||||
|
final res = await _api.get(url, requiresAuth: false);
|
||||||
|
|
||||||
|
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||||
|
final rawSubs = res['submissions'] as List? ?? [];
|
||||||
|
final rawAchievements = res['achievements'] as List? ?? [];
|
||||||
|
|
||||||
|
final submissions = rawSubs
|
||||||
|
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final achievements = rawAchievements
|
||||||
|
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
profile: UserGamificationProfile.fromJson(profileJson),
|
||||||
|
submissions: submissions,
|
||||||
|
achievements: achievements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience — returns just the profile (backward-compatible with provider).
|
||||||
|
Future<UserGamificationProfile> getProfile() async {
|
||||||
|
final dashboard = await getDashboard();
|
||||||
|
return dashboard.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaderboard
|
||||||
|
// GET /v1/gamification/leaderboard?period=all|month&district=X&user_id=Y&limit=50
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<LeaderboardResponse> getLeaderboard({
|
||||||
|
required String district,
|
||||||
|
required String timePeriod,
|
||||||
|
}) async {
|
||||||
|
final email = await _getUserEmail();
|
||||||
|
|
||||||
|
// Map Flutter filter values to API params
|
||||||
|
final period = timePeriod == 'this_month' ? 'month' : 'all';
|
||||||
|
|
||||||
|
final params = <String, String>{
|
||||||
|
'period': period,
|
||||||
|
'user_id': email,
|
||||||
|
'limit': '50',
|
||||||
|
};
|
||||||
|
if (district != 'Overall Kerala') {
|
||||||
|
params['district'] = district;
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = Uri(queryParameters: params).query;
|
||||||
|
final url = '${ApiEndpoints.leaderboard}?$query';
|
||||||
|
final res = await _api.get(url, requiresAuth: false);
|
||||||
|
|
||||||
|
final rawList = res['leaderboard'] as List? ?? [];
|
||||||
|
final entries = rawList
|
||||||
|
.map((e) => LeaderboardEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
CurrentUserStats? currentUser;
|
||||||
|
if (res['currentUser'] != null && res['currentUser'] is Map) {
|
||||||
|
currentUser = CurrentUserStats.fromJson(
|
||||||
|
Map<String, dynamic>.from(res['currentUser'] as Map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LeaderboardResponse(
|
||||||
|
entries: entries,
|
||||||
|
currentUser: currentUser,
|
||||||
|
totalParticipants: (res['totalParticipants'] as num?)?.toInt() ?? entries.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shop Items
|
||||||
|
// GET /v1/shop/items
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<ShopItem>> getShopItems() async {
|
||||||
|
final res = await _api.get(ApiEndpoints.shopItems, requiresAuth: false);
|
||||||
|
final rawItems = res['items'] as List? ?? [];
|
||||||
|
return rawItems
|
||||||
|
.map((e) => ShopItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redeem Item
|
||||||
|
// POST /v1/shop/redeem body: { user_id, item_id }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<RedemptionRecord> redeemItem(String itemId) async {
|
||||||
|
final email = await _getUserEmail();
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.shopRedeem,
|
||||||
|
body: {'user_id': email, 'item_id': itemId},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
final voucher = res['voucher'] as Map<String, dynamic>? ?? res;
|
||||||
|
return RedemptionRecord.fromJson(Map<String, dynamic>.from(voucher));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Submit Contribution
|
||||||
|
// POST /v1/gamification/submit-event body: event data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
|
final email = await _getUserEmail();
|
||||||
|
final body = <String, dynamic>{'user_id': email, ...data};
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.contributeSubmit,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Achievements — sourced from dashboard API `achievements` array.
|
||||||
|
// Falls back to default badges if API doesn't return achievements yet.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<AchievementBadge>> getAchievements() async {
|
||||||
|
try {
|
||||||
|
final dashboard = await getDashboard();
|
||||||
|
if (dashboard.achievements.isNotEmpty) return dashboard.achievements;
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to defaults
|
||||||
|
}
|
||||||
|
return _defaultBadges;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _defaultBadges = [
|
||||||
|
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-07', title: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-08', title: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
|
||||||
|
];
|
||||||
|
}
|
||||||
33
lib/features/notifications/models/notification_model.dart
Normal file
33
lib/features/notifications/models/notification_model.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// lib/features/notifications/models/notification_model.dart
|
||||||
|
|
||||||
|
class NotificationModel {
|
||||||
|
final int id;
|
||||||
|
final String title;
|
||||||
|
final String message;
|
||||||
|
final String type; // event, promo, system, booking
|
||||||
|
bool isRead;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final String? actionUrl;
|
||||||
|
|
||||||
|
NotificationModel({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.message,
|
||||||
|
this.type = 'system',
|
||||||
|
this.isRead = false,
|
||||||
|
required this.createdAt,
|
||||||
|
this.actionUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory NotificationModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return NotificationModel(
|
||||||
|
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||||
|
title: json['title'] as String? ?? '',
|
||||||
|
message: json['message'] as String? ?? '',
|
||||||
|
type: json['notification_type'] as String? ?? json['type'] as String? ?? 'system',
|
||||||
|
isRead: (json['is_read'] as bool?) ?? false,
|
||||||
|
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
actionUrl: json['action_url'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// lib/features/notifications/providers/notification_provider.dart
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/notification_model.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
|
|
||||||
|
class NotificationProvider extends ChangeNotifier {
|
||||||
|
final NotificationService _service = NotificationService();
|
||||||
|
|
||||||
|
List<NotificationModel> notifications = [];
|
||||||
|
int unreadCount = 0;
|
||||||
|
bool loading = false;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
/// Load full notification list.
|
||||||
|
Future<void> loadNotifications() async {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
notifications = await _service.getNotifications();
|
||||||
|
unreadCount = notifications.where((n) => !n.isRead).length;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight count refresh (no full list fetch).
|
||||||
|
Future<void> refreshUnreadCount() async {
|
||||||
|
try {
|
||||||
|
unreadCount = await _service.getUnreadCount();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (_) {
|
||||||
|
// Silently fail — badge just won't update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark single notification as read.
|
||||||
|
Future<void> markAsRead(int id) async {
|
||||||
|
try {
|
||||||
|
await _service.markAsRead(notificationId: id);
|
||||||
|
final idx = notifications.indexWhere((n) => n.id == id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
notifications[idx].isRead = true;
|
||||||
|
unreadCount = notifications.where((n) => !n.isRead).length;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark all as read.
|
||||||
|
Future<void> markAllAsRead() async {
|
||||||
|
try {
|
||||||
|
await _service.markAsRead(); // null = mark all
|
||||||
|
for (final n in notifications) {
|
||||||
|
n.isRead = true;
|
||||||
|
}
|
||||||
|
unreadCount = 0;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// lib/features/notifications/services/notification_service.dart
|
||||||
|
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../models/notification_model.dart';
|
||||||
|
|
||||||
|
class NotificationService {
|
||||||
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Fetch notifications for current user (paginated).
|
||||||
|
Future<List<NotificationModel>> getNotifications({int page = 1, int pageSize = 20}) async {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.notificationList,
|
||||||
|
body: {'page': page, 'page_size': pageSize},
|
||||||
|
);
|
||||||
|
final rawList = res['notifications'] ?? res['data'] ?? [];
|
||||||
|
if (rawList is List) {
|
||||||
|
return rawList
|
||||||
|
.map((e) => NotificationModel.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a single notification as read, or all if [notificationId] is null.
|
||||||
|
Future<void> markAsRead({int? notificationId}) async {
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (notificationId != null) {
|
||||||
|
body['notification_id'] = notificationId;
|
||||||
|
} else {
|
||||||
|
body['mark_all'] = true;
|
||||||
|
}
|
||||||
|
await _api.post(ApiEndpoints.notificationMarkRead, body: body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get unread notification count (lightweight).
|
||||||
|
Future<int> getUnreadCount() async {
|
||||||
|
final res = await _api.post(ApiEndpoints.notificationCount);
|
||||||
|
return (res['unread_count'] as num?)?.toInt() ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/features/notifications/widgets/notification_bell.dart
Normal file
61
lib/features/notifications/widgets/notification_bell.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// lib/features/notifications/widgets/notification_bell.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/notification_provider.dart';
|
||||||
|
import 'notification_panel.dart';
|
||||||
|
|
||||||
|
class NotificationBell extends StatelessWidget {
|
||||||
|
const NotificationBell({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<NotificationProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _showPanel(context),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: Icon(Icons.notifications_outlined, size: 26, color: Colors.black87),
|
||||||
|
),
|
||||||
|
if (provider.unreadCount > 0)
|
||||||
|
Positioned(
|
||||||
|
right: 4,
|
||||||
|
top: 4,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||||
|
child: Text(
|
||||||
|
provider.unreadCount > 99 ? '99+' : '${provider.unreadCount}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w600),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPanel(BuildContext context) {
|
||||||
|
context.read<NotificationProvider>().loadNotifications();
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ChangeNotifierProvider.value(
|
||||||
|
value: context.read<NotificationProvider>(),
|
||||||
|
child: const NotificationPanel(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
lib/features/notifications/widgets/notification_panel.dart
Normal file
101
lib/features/notifications/widgets/notification_panel.dart
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
// lib/features/notifications/widgets/notification_panel.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/notification_provider.dart';
|
||||||
|
import 'notification_tile.dart';
|
||||||
|
|
||||||
|
class NotificationPanel extends StatelessWidget {
|
||||||
|
const NotificationPanel({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
expand: false,
|
||||||
|
initialChildSize: 0.6,
|
||||||
|
minChildSize: 0.35,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Handle bar
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
width: 48,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Notifications', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
|
Consumer<NotificationProvider>(
|
||||||
|
builder: (_, provider, __) {
|
||||||
|
if (provider.unreadCount == 0) return const SizedBox.shrink();
|
||||||
|
return TextButton(
|
||||||
|
onPressed: provider.markAllAsRead,
|
||||||
|
child: const Text('Mark all read', style: TextStyle(fontSize: 13)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
// List
|
||||||
|
Expanded(
|
||||||
|
child: Consumer<NotificationProvider>(
|
||||||
|
builder: (_, provider, __) {
|
||||||
|
if (provider.loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (provider.notifications.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.notifications_none, size: 56, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text('No notifications yet', style: TextStyle(color: Colors.grey.shade500, fontSize: 15)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: provider.notifications.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final notif = provider.notifications[idx];
|
||||||
|
return NotificationTile(
|
||||||
|
notification: notif,
|
||||||
|
onTap: () {
|
||||||
|
if (!notif.isRead) provider.markAsRead(notif.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
lib/features/notifications/widgets/notification_tile.dart
Normal file
94
lib/features/notifications/widgets/notification_tile.dart
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
// lib/features/notifications/widgets/notification_tile.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/notification_model.dart';
|
||||||
|
|
||||||
|
class NotificationTile extends StatelessWidget {
|
||||||
|
final NotificationModel notification;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const NotificationTile({Key? key, required this.notification, this.onTap}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
color: notification.isRead ? Colors.transparent : const Color(0xFFF0F4FF),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildIcon(),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
notification.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: notification.isRead ? FontWeight.w400 : FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
notification.message,
|
||||||
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
_timeAgo(notification.createdAt),
|
||||||
|
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildIcon() {
|
||||||
|
final config = _typeConfig(notification.type);
|
||||||
|
return Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: config.color.withOpacity(0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(config.icon, color: config.color, size: 20),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static _TypeConfig _typeConfig(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'event': return _TypeConfig(Colors.blue, Icons.event);
|
||||||
|
case 'promo': return _TypeConfig(Colors.green, Icons.local_offer);
|
||||||
|
case 'booking': return _TypeConfig(Colors.orange, Icons.confirmation_number);
|
||||||
|
default: return _TypeConfig(Colors.grey, Icons.info_outline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _timeAgo(DateTime dt) {
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||||
|
return '${dt.day}/${dt.month}/${dt.year}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TypeConfig {
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
const _TypeConfig(this.color, this.icon);
|
||||||
|
}
|
||||||
113
lib/features/reviews/models/review_models.dart
Normal file
113
lib/features/reviews/models/review_models.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// lib/features/reviews/models/review_models.dart
|
||||||
|
|
||||||
|
class ReviewModel {
|
||||||
|
final int id;
|
||||||
|
final int eventId;
|
||||||
|
final String username;
|
||||||
|
final int rating;
|
||||||
|
final String? comment;
|
||||||
|
final String status;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final bool isVerified;
|
||||||
|
final int helpfulCount;
|
||||||
|
final int flagCount;
|
||||||
|
final bool userMarkedHelpful;
|
||||||
|
final bool userFlagged;
|
||||||
|
|
||||||
|
ReviewModel({
|
||||||
|
required this.id,
|
||||||
|
required this.eventId,
|
||||||
|
required this.username,
|
||||||
|
required this.rating,
|
||||||
|
this.comment,
|
||||||
|
this.status = 'PUBLISHED',
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.isVerified = false,
|
||||||
|
this.helpfulCount = 0,
|
||||||
|
this.flagCount = 0,
|
||||||
|
this.userMarkedHelpful = false,
|
||||||
|
this.userFlagged = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReviewModel.fromJson(Map<String, dynamic> j, {Map<String, bool>? interactions}) {
|
||||||
|
return ReviewModel(
|
||||||
|
id: j['id'] as int,
|
||||||
|
eventId: j['event_id'] as int,
|
||||||
|
username: (j['username'] ?? j['display_name'] ?? 'Anonymous') as String,
|
||||||
|
rating: j['rating'] as int,
|
||||||
|
comment: j['comment'] as String?,
|
||||||
|
status: (j['status'] ?? 'PUBLISHED') as String,
|
||||||
|
createdAt: DateTime.tryParse(j['created_at'] ?? '') ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.tryParse(j['updated_at'] ?? '') ?? DateTime.now(),
|
||||||
|
isVerified: j['is_verified'] == true,
|
||||||
|
helpfulCount: (j['helpful_count'] ?? 0) as int,
|
||||||
|
flagCount: (j['flag_count'] ?? 0) as int,
|
||||||
|
userMarkedHelpful: interactions?['helpful'] ?? false,
|
||||||
|
userFlagged: interactions?['flag'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReviewModel copyWith({int? helpfulCount, bool? userMarkedHelpful, bool? userFlagged}) {
|
||||||
|
return ReviewModel(
|
||||||
|
id: id, eventId: eventId, username: username, rating: rating,
|
||||||
|
comment: comment, status: status, createdAt: createdAt, updatedAt: updatedAt,
|
||||||
|
isVerified: isVerified,
|
||||||
|
helpfulCount: helpfulCount ?? this.helpfulCount,
|
||||||
|
flagCount: flagCount,
|
||||||
|
userMarkedHelpful: userMarkedHelpful ?? this.userMarkedHelpful,
|
||||||
|
userFlagged: userFlagged ?? this.userFlagged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReviewStatsModel {
|
||||||
|
final double averageRating;
|
||||||
|
final int reviewCount;
|
||||||
|
final Map<int, int> distribution;
|
||||||
|
|
||||||
|
ReviewStatsModel({
|
||||||
|
required this.averageRating,
|
||||||
|
required this.reviewCount,
|
||||||
|
required this.distribution,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ReviewStatsModel.fromJson(Map<String, dynamic> j) {
|
||||||
|
final dist = <int, int>{1: 0, 2: 0, 3: 0, 4: 0, 5: 0};
|
||||||
|
final rawDist = j['distribution'];
|
||||||
|
if (rawDist is Map) {
|
||||||
|
rawDist.forEach((k, v) {
|
||||||
|
final key = int.tryParse(k.toString());
|
||||||
|
if (key != null && key >= 1 && key <= 5) dist[key] = (v as num).toInt();
|
||||||
|
});
|
||||||
|
} else if (rawDist is List) {
|
||||||
|
for (int i = 0; i < rawDist.length && i < 5; i++) {
|
||||||
|
dist[i + 1] = (rawDist[i] as num).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ReviewStatsModel(
|
||||||
|
averageRating: (j['average_rating'] as num?)?.toDouble() ?? 0.0,
|
||||||
|
reviewCount: (j['review_count'] as num?)?.toInt() ?? 0,
|
||||||
|
distribution: dist,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReviewListResponse {
|
||||||
|
final List<ReviewModel> reviews;
|
||||||
|
final ReviewStatsModel stats;
|
||||||
|
final ReviewModel? userReview;
|
||||||
|
final int total;
|
||||||
|
final int page;
|
||||||
|
final int pageSize;
|
||||||
|
|
||||||
|
ReviewListResponse({
|
||||||
|
required this.reviews,
|
||||||
|
required this.stats,
|
||||||
|
this.userReview,
|
||||||
|
required this.total,
|
||||||
|
required this.page,
|
||||||
|
required this.pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
104
lib/features/reviews/services/review_service.dart
Normal file
104
lib/features/reviews/services/review_service.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// lib/features/reviews/services/review_service.dart
|
||||||
|
import '../../../core/api/api_client.dart';
|
||||||
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
|
||||||
|
class ReviewService {
|
||||||
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Fetch paginated reviews + stats for an event.
|
||||||
|
Future<ReviewListResponse> getReviews(int eventId, {int page = 1, int pageSize = 10}) async {
|
||||||
|
try {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.reviewList,
|
||||||
|
body: {'event_id': eventId, 'page': page, 'page_size': pageSize},
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse interactions map: { "review_id": { "helpful": bool, "flag": bool } }
|
||||||
|
final rawInteractions = res['interactions'] as Map<String, dynamic>? ?? {};
|
||||||
|
final interactionsMap = <int, Map<String, bool>>{};
|
||||||
|
rawInteractions.forEach((key, value) {
|
||||||
|
final id = int.tryParse(key);
|
||||||
|
if (id != null && value is Map) {
|
||||||
|
interactionsMap[id] = {
|
||||||
|
'helpful': value['helpful'] == true,
|
||||||
|
'flag': value['flag'] == true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse reviews
|
||||||
|
final rawReviews = res['reviews'] as List? ?? [];
|
||||||
|
final reviews = rawReviews.map((r) {
|
||||||
|
final review = Map<String, dynamic>.from(r as Map);
|
||||||
|
return ReviewModel.fromJson(review, interactions: interactionsMap[review['id']]);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Parse stats
|
||||||
|
final stats = ReviewStatsModel.fromJson(res);
|
||||||
|
|
||||||
|
// Parse user's own review
|
||||||
|
ReviewModel? userReview;
|
||||||
|
if (res['user_review'] != null && res['user_review'] is Map) {
|
||||||
|
final ur = Map<String, dynamic>.from(res['user_review'] as Map);
|
||||||
|
userReview = ReviewModel.fromJson(ur, interactions: interactionsMap[ur['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReviewListResponse(
|
||||||
|
reviews: reviews,
|
||||||
|
stats: stats,
|
||||||
|
userReview: userReview,
|
||||||
|
total: (res['total'] as num?)?.toInt() ?? reviews.length,
|
||||||
|
page: (res['page'] as num?)?.toInt() ?? page,
|
||||||
|
pageSize: (res['page_size'] as num?)?.toInt() ?? pageSize,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Submit or update a review.
|
||||||
|
Future<void> submitReview(int eventId, int rating, String? comment) async {
|
||||||
|
try {
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.reviewSubmit,
|
||||||
|
body: {
|
||||||
|
'event_id': eventId,
|
||||||
|
'rating': rating,
|
||||||
|
if (comment != null && comment.trim().isNotEmpty) 'comment': comment.trim(),
|
||||||
|
},
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle helpful vote on a review. Returns new helpful count.
|
||||||
|
Future<int> markHelpful(int reviewId) async {
|
||||||
|
try {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.reviewHelpful,
|
||||||
|
body: {'review_id': reviewId},
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
return (res['helpful_count'] as num?)?.toInt() ?? 0;
|
||||||
|
} catch (_) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flag a review for moderation.
|
||||||
|
Future<void> flagReview(int reviewId) async {
|
||||||
|
try {
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.reviewFlag,
|
||||||
|
body: {'review_id': reviewId},
|
||||||
|
requiresAuth: true,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
lib/features/reviews/widgets/review_card.dart
Normal file
237
lib/features/reviews/widgets/review_card.dart
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
// lib/features/reviews/widgets/review_card.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
import 'star_display.dart';
|
||||||
|
|
||||||
|
class ReviewCard extends StatefulWidget {
|
||||||
|
final ReviewModel review;
|
||||||
|
final String? currentUsername;
|
||||||
|
final Future<int> Function(int reviewId) onHelpful;
|
||||||
|
final Future<void> Function(int reviewId) onFlag;
|
||||||
|
|
||||||
|
const ReviewCard({
|
||||||
|
Key? key,
|
||||||
|
required this.review,
|
||||||
|
this.currentUsername,
|
||||||
|
required this.onHelpful,
|
||||||
|
required this.onFlag,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReviewCard> createState() => _ReviewCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReviewCardState extends State<ReviewCard> {
|
||||||
|
late ReviewModel _review;
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_review = widget.review;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ReviewCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.review.id != widget.review.id) _review = widget.review;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isOwnReview =>
|
||||||
|
widget.currentUsername != null &&
|
||||||
|
widget.currentUsername!.isNotEmpty &&
|
||||||
|
_review.username == widget.currentUsername;
|
||||||
|
|
||||||
|
String _timeAgo(DateTime dt) {
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inSeconds < 60) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 30) return '${diff.inDays}d ago';
|
||||||
|
if (diff.inDays < 365) return '${(diff.inDays / 30).floor()}mo ago';
|
||||||
|
return '${(diff.inDays / 365).floor()}y ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _avatarColor(String name) {
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF0F45CF), const Color(0xFF7C3AED), const Color(0xFFEC4899),
|
||||||
|
const Color(0xFFF59E0B), const Color(0xFF10B981), const Color(0xFFEF4444),
|
||||||
|
const Color(0xFF06B6D4), const Color(0xFF8B5CF6),
|
||||||
|
];
|
||||||
|
return colors[name.hashCode.abs() % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleHelpful() async {
|
||||||
|
if (_isOwnReview) return;
|
||||||
|
try {
|
||||||
|
final newCount = await widget.onHelpful(_review.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_review = _review.copyWith(
|
||||||
|
helpfulCount: newCount,
|
||||||
|
userMarkedHelpful: !_review.userMarkedHelpful,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleFlag() async {
|
||||||
|
if (_isOwnReview || _review.userFlagged) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Report Review'),
|
||||||
|
content: const Text('Are you sure you want to report this review as inappropriate?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Report', style: TextStyle(color: Color(0xFFEF4444))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
try {
|
||||||
|
await widget.onFlag(_review.id);
|
||||||
|
if (mounted) setState(() => _review = _review.copyWith(userFlagged: true));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final comment = _review.comment ?? '';
|
||||||
|
final isLong = comment.length > 150;
|
||||||
|
final displayComment = isLong && !_expanded ? '${comment.substring(0, 150)}...' : comment;
|
||||||
|
final initial = _review.username.isNotEmpty ? _review.username[0].toUpperCase() : '?';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFF1F5F9)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://api.dicebear.com/9.x/notionists/svg?seed=${Uri.encodeComponent(_review.username)}',
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
placeholder: (_, __) => CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: _avatarColor(_review.username),
|
||||||
|
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: _avatarColor(_review.username),
|
||||||
|
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_review.username.split('@').first,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF1E293B)),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_review.isVerified) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.verified, size: 14, color: Color(0xFF22C55E)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(_timeAgo(_review.createdAt), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StarDisplay(rating: _review.rating.toDouble(), size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Comment
|
||||||
|
if (comment.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(displayComment, style: const TextStyle(fontSize: 13, color: Color(0xFF334155), height: 1.4)),
|
||||||
|
if (isLong)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
_expanded ? 'Show less' : 'Read more',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Footer actions
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Helpful button
|
||||||
|
InkWell(
|
||||||
|
onTap: _isOwnReview ? null : _handleHelpful,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_review.userMarkedHelpful ? Icons.thumb_up : Icons.thumb_up_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
if (_review.helpfulCount > 0) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${_review.helpfulCount}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Flag button
|
||||||
|
if (!_isOwnReview)
|
||||||
|
InkWell(
|
||||||
|
onTap: _review.userFlagged ? null : _handleFlag,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Icon(
|
||||||
|
_review.userFlagged ? Icons.flag : Icons.flag_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: _review.userFlagged ? const Color(0xFFEF4444) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
lib/features/reviews/widgets/review_form.dart
Normal file
190
lib/features/reviews/widgets/review_form.dart
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// lib/features/reviews/widgets/review_form.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
import 'star_rating_input.dart';
|
||||||
|
|
||||||
|
class ReviewForm extends StatefulWidget {
|
||||||
|
final int eventId;
|
||||||
|
final ReviewModel? existingReview;
|
||||||
|
final Future<void> Function(int rating, String? comment) onSubmit;
|
||||||
|
|
||||||
|
const ReviewForm({
|
||||||
|
Key? key,
|
||||||
|
required this.eventId,
|
||||||
|
this.existingReview,
|
||||||
|
required this.onSubmit,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReviewForm> createState() => _ReviewFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _FormState { idle, loading, success }
|
||||||
|
|
||||||
|
class _ReviewFormState extends State<ReviewForm> with SingleTickerProviderStateMixin {
|
||||||
|
int _rating = 0;
|
||||||
|
final _commentController = TextEditingController();
|
||||||
|
_FormState _state = _FormState.idle;
|
||||||
|
bool _isLoggedIn = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
late final AnimationController _checkController;
|
||||||
|
late final Animation<double> _checkScale;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkAuth();
|
||||||
|
if (widget.existingReview != null) {
|
||||||
|
_rating = widget.existingReview!.rating;
|
||||||
|
_commentController.text = widget.existingReview!.comment ?? '';
|
||||||
|
}
|
||||||
|
_checkController = AnimationController(vsync: this, duration: const Duration(milliseconds: 600));
|
||||||
|
_checkScale = CurvedAnimation(parent: _checkController, curve: Curves.elasticOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAuth() async {
|
||||||
|
final token = await TokenStorage.getToken();
|
||||||
|
final username = await TokenStorage.getUsername();
|
||||||
|
if (mounted) setState(() => _isLoggedIn = token != null && username != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit() async {
|
||||||
|
if (_rating == 0) {
|
||||||
|
setState(() => _error = 'Please select a rating');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() { _state = _FormState.loading; _error = null; });
|
||||||
|
try {
|
||||||
|
await widget.onSubmit(_rating, _commentController.text);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _state = _FormState.success);
|
||||||
|
_checkController.forward(from: 0);
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (mounted) setState(() => _state = _FormState.idle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _state = _FormState.idle; _error = userFriendlyError(e); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_commentController.dispose();
|
||||||
|
_checkController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_isLoggedIn) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF8FAFC),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.login, color: Color(0xFF64748B), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Expanded(
|
||||||
|
child: Text('Log in to write a review', style: TextStyle(color: Color(0xFF64748B), fontSize: 14)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
child: _state == _FormState.success
|
||||||
|
? Container(
|
||||||
|
key: const ValueKey('success'),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0FDF4),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFF86EFAC)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ScaleTransition(
|
||||||
|
scale: _checkScale,
|
||||||
|
child: const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('Review submitted!', style: TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w600, fontSize: 15)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
key: const ValueKey('form'),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.existingReview != null ? 'Update your review' : 'Write a review',
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1E293B)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(child: StarRatingInput(rating: _rating, onRatingChanged: (r) => setState(() { _rating = r; _error = null; }))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _commentController,
|
||||||
|
maxLength: 500,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Share your experience (optional)',
|
||||||
|
hintStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFFF8FAFC),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFFE2E8F0))),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF0F45CF), width: 1.5)),
|
||||||
|
counterStyle: const TextStyle(color: Color(0xFF94A3B8), fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(_error!, style: const TextStyle(color: Color(0xFFEF4444), fontSize: 12)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 46,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _state == _FormState.loading ? null : _handleSubmit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0F45CF),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
disabledBackgroundColor: const Color(0xFF0F45CF).withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
child: _state == _FormState.loading
|
||||||
|
? const SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: Text(
|
||||||
|
widget.existingReview != null ? 'Update Review' : 'Submit Review',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/features/reviews/widgets/review_section.dart
Normal file
214
lib/features/reviews/widgets/review_section.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// lib/features/reviews/widgets/review_section.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||||
|
import '../../../core/storage/token_storage.dart';
|
||||||
|
import '../../../widgets/bouncing_loader.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
import '../services/review_service.dart';
|
||||||
|
import '../../../core/analytics/posthog_service.dart';
|
||||||
|
import 'review_summary.dart';
|
||||||
|
import 'review_form.dart';
|
||||||
|
import 'review_card.dart';
|
||||||
|
|
||||||
|
class ReviewSection extends StatefulWidget {
|
||||||
|
final int eventId;
|
||||||
|
|
||||||
|
const ReviewSection({Key? key, required this.eventId}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReviewSection> createState() => _ReviewSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReviewSectionState extends State<ReviewSection> {
|
||||||
|
final ReviewService _service = ReviewService();
|
||||||
|
|
||||||
|
List<ReviewModel> _reviews = [];
|
||||||
|
ReviewStatsModel? _stats;
|
||||||
|
ReviewModel? _userReview;
|
||||||
|
String? _currentUsername;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
int _page = 1;
|
||||||
|
int _total = 0;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
_currentUsername = await TokenStorage.getUsername();
|
||||||
|
await _loadReviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadReviews() async {
|
||||||
|
setState(() { _loading = true; _error = null; });
|
||||||
|
try {
|
||||||
|
final response = await _service.getReviews(widget.eventId, page: 1);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_reviews = response.reviews;
|
||||||
|
_stats = response.stats;
|
||||||
|
_userReview = response.userReview;
|
||||||
|
_total = response.total;
|
||||||
|
_page = 1;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() { _loading = false; _error = userFriendlyError(e); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
if (_loadingMore || _reviews.length >= _total) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
try {
|
||||||
|
final response = await _service.getReviews(widget.eventId, page: _page + 1);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_reviews.addAll(response.reviews);
|
||||||
|
_page = response.page;
|
||||||
|
_total = response.total;
|
||||||
|
_loadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSubmit(int rating, String? comment) async {
|
||||||
|
await _service.submitReview(widget.eventId, rating, comment);
|
||||||
|
PostHogService.instance.capture('review_submitted', properties: {
|
||||||
|
'event_id': widget.eventId,
|
||||||
|
'rating': rating,
|
||||||
|
});
|
||||||
|
await _loadReviews(); // Refresh to get updated stats + review list
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _handleHelpful(int reviewId) async {
|
||||||
|
return _service.markHelpful(reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleFlag(int reviewId) async {
|
||||||
|
await _service.flagReview(reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section header
|
||||||
|
const Text(
|
||||||
|
'Reviews & Ratings',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFF1E293B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
if (_loading)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: BouncingLoader(color: Color(0xFF0F45CF)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(_error!, style: const TextStyle(color: Color(0xFF94A3B8), fontSize: 13)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _loadReviews,
|
||||||
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
|
label: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
// Summary card
|
||||||
|
if (_stats != null && _stats!.reviewCount > 0) ...[
|
||||||
|
ReviewSummary(stats: _stats!),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Review form
|
||||||
|
ReviewForm(
|
||||||
|
eventId: widget.eventId,
|
||||||
|
existingReview: _userReview,
|
||||||
|
onSubmit: _handleSubmit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
if (_reviews.isNotEmpty)
|
||||||
|
const Divider(color: Color(0xFFF1F5F9), thickness: 1),
|
||||||
|
|
||||||
|
// Reviews list
|
||||||
|
if (_reviews.isEmpty && (_stats == null || _stats!.reviewCount == 0))
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No reviews yet. Be the first to share your experience!',
|
||||||
|
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 14),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
AnimationLimiter(
|
||||||
|
child: Column(
|
||||||
|
children: AnimationConfiguration.toStaggeredList(
|
||||||
|
duration: const Duration(milliseconds: 375),
|
||||||
|
childAnimationBuilder: (widget) => SlideAnimation(
|
||||||
|
verticalOffset: 50.0,
|
||||||
|
child: FadeInAnimation(child: widget),
|
||||||
|
),
|
||||||
|
children: List.generate(_reviews.length, (i) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ReviewCard(
|
||||||
|
review: _reviews[i],
|
||||||
|
currentUsername: _currentUsername,
|
||||||
|
onHelpful: _handleHelpful,
|
||||||
|
onFlag: _handleFlag,
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Load more
|
||||||
|
if (_reviews.length < _total)
|
||||||
|
Center(
|
||||||
|
child: _loadingMore
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: BouncingLoader(color: Color(0xFF0F45CF)),
|
||||||
|
)
|
||||||
|
: TextButton(
|
||||||
|
onPressed: _loadMore,
|
||||||
|
child: const Text(
|
||||||
|
'Show more reviews',
|
||||||
|
style: TextStyle(color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
lib/features/reviews/widgets/review_summary.dart
Normal file
171
lib/features/reviews/widgets/review_summary.dart
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// lib/features/reviews/widgets/review_summary.dart
|
||||||
|
import 'dart:math' show pi;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
|
||||||
|
class _RatingRingPainter extends CustomPainter {
|
||||||
|
final double rating;
|
||||||
|
|
||||||
|
const _RatingRingPainter({required this.rating});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final radius = size.width / 2 - 6;
|
||||||
|
|
||||||
|
// Background track
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
-pi / 2,
|
||||||
|
2 * pi,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white12
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 7
|
||||||
|
..strokeCap = StrokeCap.round,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filled arc
|
||||||
|
if (rating > 0) {
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
-pi / 2,
|
||||||
|
(rating.clamp(0.0, 5.0) / 5.0) * 2 * pi,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFBBF24)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 7
|
||||||
|
..strokeCap = StrokeCap.round,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RatingRingPainter old) => old.rating != rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RatingRingWidget extends StatelessWidget {
|
||||||
|
final double rating;
|
||||||
|
final int reviewCount;
|
||||||
|
|
||||||
|
const _RatingRingWidget({required this.rating, required this.reviewCount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _RatingRingPainter(rating: rating),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
rating.toStringAsFixed(1),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'/5',
|
||||||
|
style: TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$reviewCount ${reviewCount == 1 ? 'review' : 'reviews'}',
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReviewSummary extends StatelessWidget {
|
||||||
|
final ReviewStatsModel stats;
|
||||||
|
|
||||||
|
const ReviewSummary({Key? key, required this.stats}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final maxCount = stats.distribution.values.fold<int>(0, (a, b) => a > b ? a : b);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, offset: const Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Left: circular rating ring
|
||||||
|
_RatingRingWidget(
|
||||||
|
rating: stats.averageRating,
|
||||||
|
reviewCount: stats.reviewCount,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
// Right: distribution bars
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final star = 5 - i;
|
||||||
|
final count = stats.distribution[star] ?? 0;
|
||||||
|
final fraction = maxCount > 0 ? count / maxCount : 0.0;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
child: Text('$star', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF64748B))),
|
||||||
|
),
|
||||||
|
const Icon(Icons.star_rounded, size: 12, color: Color(0xFFFBBF24)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
height: 8,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: fraction,
|
||||||
|
backgroundColor: const Color(0xFFF1F5F9),
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFBBF24)),
|
||||||
|
minHeight: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text('$count', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/features/reviews/widgets/star_display.dart
Normal file
42
lib/features/reviews/widgets/star_display.dart
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// lib/features/reviews/widgets/star_display.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class StarDisplay extends StatelessWidget {
|
||||||
|
final double rating;
|
||||||
|
final double size;
|
||||||
|
final Color filledColor;
|
||||||
|
final Color emptyColor;
|
||||||
|
|
||||||
|
const StarDisplay({
|
||||||
|
Key? key,
|
||||||
|
required this.rating,
|
||||||
|
this.size = 16,
|
||||||
|
this.filledColor = const Color(0xFFFBBF24),
|
||||||
|
this.emptyColor = const Color(0xFFD1D5DB),
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final starPos = i + 1;
|
||||||
|
IconData icon;
|
||||||
|
Color color;
|
||||||
|
|
||||||
|
if (rating >= starPos) {
|
||||||
|
icon = Icons.star_rounded;
|
||||||
|
color = filledColor;
|
||||||
|
} else if (rating >= starPos - 0.5) {
|
||||||
|
icon = Icons.star_half_rounded;
|
||||||
|
color = filledColor;
|
||||||
|
} else {
|
||||||
|
icon = Icons.star_outline_rounded;
|
||||||
|
color = emptyColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Icon(icon, size: size, color: color);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
lib/features/reviews/widgets/star_rating_input.dart
Normal file
56
lib/features/reviews/widgets/star_rating_input.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// lib/features/reviews/widgets/star_rating_input.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class StarRatingInput extends StatelessWidget {
|
||||||
|
final int rating;
|
||||||
|
final ValueChanged<int> onRatingChanged;
|
||||||
|
final double starSize;
|
||||||
|
|
||||||
|
const StarRatingInput({
|
||||||
|
Key? key,
|
||||||
|
required this.rating,
|
||||||
|
required this.onRatingChanged,
|
||||||
|
this.starSize = 36,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
static const _labels = ['', 'Poor', 'Fair', 'Good', 'Very Good', 'Excellent'];
|
||||||
|
static const _starGold = Color(0xFFFBBF24);
|
||||||
|
static const _starEmpty = Color(0xFFD1D5DB);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final starIndex = i + 1;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => onRatingChanged(starIndex),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Icon(
|
||||||
|
starIndex <= rating ? Icons.star_rounded : Icons.star_outline_rounded,
|
||||||
|
size: starSize,
|
||||||
|
color: starIndex <= rating ? _starGold : _starEmpty,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
if (rating > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_labels[rating],
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _starGold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
197
lib/features/share/share_rank_card.dart
Normal file
197
lib/features/share/share_rank_card.dart
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import '../../widgets/tier_avatar_ring.dart';
|
||||||
|
|
||||||
|
class ShareRankCard extends StatefulWidget {
|
||||||
|
final String username;
|
||||||
|
final String tier;
|
||||||
|
final int rank;
|
||||||
|
final int ep;
|
||||||
|
final int rewardPoints;
|
||||||
|
|
||||||
|
const ShareRankCard({
|
||||||
|
super.key,
|
||||||
|
required this.username,
|
||||||
|
required this.tier,
|
||||||
|
required this.rank,
|
||||||
|
required this.ep,
|
||||||
|
this.rewardPoints = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareRankCard> createState() => _ShareRankCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareRankCardState extends State<ShareRankCard> {
|
||||||
|
final GlobalKey _boundaryKey = GlobalKey();
|
||||||
|
bool _sharing = false;
|
||||||
|
|
||||||
|
static const _tierGradients = {
|
||||||
|
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
|
||||||
|
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
|
||||||
|
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
|
||||||
|
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
|
||||||
|
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Color> get _gradient {
|
||||||
|
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _share() async {
|
||||||
|
if (_sharing) return;
|
||||||
|
setState(() => _sharing = true);
|
||||||
|
try {
|
||||||
|
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
|
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
if (byteData == null) return;
|
||||||
|
final bytes = byteData.buffer.asUint8List();
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(file.path)],
|
||||||
|
text: 'I\'m ranked #${widget.rank} on Eventify with ${widget.ep} EP! 🏆 #Eventify #Kerala',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not share rank card')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _sharing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
|
key: _boundaryKey,
|
||||||
|
child: Container(
|
||||||
|
width: 320,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Tier gradient header bar
|
||||||
|
Container(
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: _gradient),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Avatar
|
||||||
|
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Username
|
||||||
|
Text(
|
||||||
|
widget.username,
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Tier badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: _gradient),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.tier.isEmpty ? 'Contributor' : widget.tier,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Stats row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_stat('Rank', '#${widget.rank}'),
|
||||||
|
Container(width: 1, height: 40, color: Colors.white12),
|
||||||
|
_stat('EP', '${widget.ep}'),
|
||||||
|
Container(width: 1, height: 40, color: Colors.white12),
|
||||||
|
_stat('RP', '${widget.rewardPoints}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Branding
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'EVENTIFY',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _sharing ? null : _share,
|
||||||
|
icon: _sharing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.share, size: 18),
|
||||||
|
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1D4ED8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stat(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,29 @@
|
|||||||
|
|
||||||
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 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/home_desktop_screen.dart';
|
import 'screens/home_desktop_screen.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'screens/desktop_login_screen.dart';
|
import 'screens/desktop_login_screen.dart';
|
||||||
import 'screens/responsive_layout.dart'; // keep this path if your file is under lib/screens/
|
import 'screens/responsive_layout.dart';
|
||||||
import 'core/theme_manager.dart';
|
import 'core/theme_manager.dart';
|
||||||
|
import 'core/analytics/posthog_service.dart';
|
||||||
|
import 'features/auth/providers/auth_provider.dart';
|
||||||
|
import 'features/gamification/providers/gamification_provider.dart';
|
||||||
|
import 'features/booking/providers/checkout_provider.dart';
|
||||||
|
import 'features/notifications/providers/notification_provider.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await ThemeManager.init(); // load saved theme preference
|
await ThemeManager.init(); // load saved theme preference
|
||||||
|
await PostHogService.instance.init();
|
||||||
|
|
||||||
|
// Increase image cache for smoother scrolling and faster re-renders
|
||||||
|
PaintingBinding.instance.imageCache.maximumSize = 500;
|
||||||
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 * 1024 * 1024; // 200 MB
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +97,14 @@ class MyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<ThemeMode>(
|
return MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => GamificationProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => CheckoutProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => NotificationProvider()),
|
||||||
|
],
|
||||||
|
child: ValueListenableBuilder<ThemeMode>(
|
||||||
valueListenable: ThemeManager.themeMode,
|
valueListenable: ThemeManager.themeMode,
|
||||||
builder: (context, mode, _) {
|
builder: (context, mode, _) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
@@ -97,6 +116,7 @@ class MyApp extends StatelessWidget {
|
|||||||
home: const StartupScreen(),
|
home: const StartupScreen(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
// lib/screens/booking_screen.dart
|
// lib/screens/booking_screen.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'checkout_screen.dart';
|
||||||
|
|
||||||
class BookingScreen extends StatefulWidget {
|
class BookingScreen extends StatefulWidget {
|
||||||
// Keep onBook in the constructor if you want to use it later, but we won't call it here.
|
|
||||||
final VoidCallback? onBook;
|
final VoidCallback? onBook;
|
||||||
final String image;
|
final String image;
|
||||||
|
final int? eventId;
|
||||||
|
final String? eventName;
|
||||||
|
|
||||||
const BookingScreen({
|
const BookingScreen({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.onBook,
|
this.onBook,
|
||||||
this.image = 'assets/images/event1.jpg',
|
this.image = 'assets/images/event1.jpg',
|
||||||
|
this.eventId,
|
||||||
|
this.eventName,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -39,11 +43,22 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
|
|||||||
bool _booked = false;
|
bool _booked = false;
|
||||||
|
|
||||||
void _performLocalBooking() {
|
void _performLocalBooking() {
|
||||||
// mark locally booked (do NOT call widget.onBook())
|
// If event data is available, navigate to real checkout
|
||||||
|
if (widget.eventId != null) {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => CheckoutScreen(
|
||||||
|
eventId: widget.eventId!,
|
||||||
|
eventName: widget.eventName ?? 'Event',
|
||||||
|
eventImage: widget.image,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback: demo booking for events without IDs
|
||||||
if (!_booked) {
|
if (!_booked) {
|
||||||
setState(() => _booked = true);
|
setState(() => _booked = true);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Tickets booked (demo)')),
|
const SnackBar(content: Text('Tickets booked (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// lib/screens/calendar_screen.dart
|
// lib/screens/calendar_screen.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/utils/error_utils.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);
|
||||||
@@ -92,7 +95,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loadingMonth = false);
|
if (mounted) setState(() => _loadingMonth = false);
|
||||||
}
|
}
|
||||||
@@ -115,7 +118,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
final events = await _service.getEventsForDate(yyyyMMdd);
|
final events = await _service.getEventsForDate(yyyyMMdd);
|
||||||
if (mounted) setState(() => _eventsOfDay = events);
|
if (mounted) setState(() => _eventsOfDay = events);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loadingDay = false);
|
if (mounted) setState(() => _loadingDay = false);
|
||||||
}
|
}
|
||||||
@@ -501,7 +504,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
: (e.startDate != null && e.endDate != null ? '${e.startDate} - ${e.endDate}' : (e.startDate ?? ''));
|
: (e.startDate != null && e.endDate != null ? '${e.startDate} - ${e.endDate}' : (e.startDate ?? ''));
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id, initialEvent: e))),
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
margin: const EdgeInsets.fromLTRB(20, 10, 20, 10),
|
margin: const EdgeInsets.fromLTRB(20, 10, 20, 10),
|
||||||
@@ -511,7 +514,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 +551,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, initialEvent: e))),
|
||||||
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 +857,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 +931,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])),
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
500
lib/screens/checkout_screen.dart
Normal file
500
lib/screens/checkout_screen.dart
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
// lib/screens/checkout_screen.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../features/booking/providers/checkout_provider.dart';
|
||||||
|
import '../features/booking/services/payment_service.dart';
|
||||||
|
import '../features/booking/models/booking_models.dart';
|
||||||
|
import '../core/utils/error_utils.dart';
|
||||||
|
import 'tickets_booked_screen.dart';
|
||||||
|
|
||||||
|
class CheckoutScreen extends StatefulWidget {
|
||||||
|
final int eventId;
|
||||||
|
final String eventName;
|
||||||
|
final String? eventImage;
|
||||||
|
|
||||||
|
const CheckoutScreen({
|
||||||
|
Key? key,
|
||||||
|
required this.eventId,
|
||||||
|
required this.eventName,
|
||||||
|
this.eventImage,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CheckoutScreen> createState() => _CheckoutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||||
|
late final PaymentService _paymentService;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _emailCtrl = TextEditingController();
|
||||||
|
final _phoneCtrl = TextEditingController();
|
||||||
|
final _promoCtrl = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_paymentService = PaymentService();
|
||||||
|
_paymentService.initialize(
|
||||||
|
onSuccess: _onPaymentSuccess,
|
||||||
|
onError: _onPaymentError,
|
||||||
|
);
|
||||||
|
_prefillUserData();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<CheckoutProvider>().initForEvent(widget.eventId, widget.eventName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prefillUserData() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_emailCtrl.text = prefs.getString('email') ?? '';
|
||||||
|
_nameCtrl.text = prefs.getString('display_name') ?? '';
|
||||||
|
_phoneCtrl.text = prefs.getString('phone_number') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPaymentSuccess(dynamic response) {
|
||||||
|
final provider = context.read<CheckoutProvider>();
|
||||||
|
provider.markPaymentSuccess(response.paymentId ?? 'success');
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const TicketsBookedScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPaymentError(dynamic response) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Payment failed: ${response.message ?? "Please try again"}'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_paymentService.dispose();
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_emailCtrl.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_promoCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Checkout', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: Consumer<CheckoutProvider>(
|
||||||
|
builder: (ctx, provider, _) {
|
||||||
|
if (provider.loading && provider.availableTickets.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (provider.error != null && provider.availableTickets.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(provider.error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => provider.initForEvent(widget.eventId, widget.eventName),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildStepIndicator(provider),
|
||||||
|
Expanded(child: _buildCurrentStep(provider)),
|
||||||
|
_buildBottomBar(provider),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepIndicator(CheckoutProvider provider) {
|
||||||
|
final steps = ['Tickets', 'Details', 'Payment'];
|
||||||
|
final currentIdx = provider.currentStep.index;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(steps.length, (i) {
|
||||||
|
final isActive = i <= currentIdx;
|
||||||
|
return Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 28, height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isActive ? const Color(0xFF0B63D6) : Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text('${i + 1}', style: TextStyle(
|
||||||
|
color: isActive ? Colors.white : Colors.grey,
|
||||||
|
fontSize: 13, fontWeight: FontWeight.w600,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(steps[i], style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
color: isActive ? Colors.black : Colors.grey,
|
||||||
|
)),
|
||||||
|
if (i < steps.length - 1) Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
color: i < currentIdx ? const Color(0xFF0B63D6) : Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCurrentStep(CheckoutProvider provider) {
|
||||||
|
switch (provider.currentStep) {
|
||||||
|
case CheckoutStep.tickets:
|
||||||
|
return _buildTicketSelection(provider);
|
||||||
|
case CheckoutStep.details:
|
||||||
|
return _buildDetailsForm(provider);
|
||||||
|
case CheckoutStep.payment:
|
||||||
|
return _buildPaymentReview(provider);
|
||||||
|
case CheckoutStep.confirmation:
|
||||||
|
return const Center(child: Text('Booking confirmed!'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTicketSelection(CheckoutProvider provider) {
|
||||||
|
if (provider.availableTickets.isEmpty) {
|
||||||
|
return const Center(child: Text('No tickets available for this event.'));
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(widget.eventName, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
...provider.availableTickets.map((ticket) {
|
||||||
|
final cartMatches = provider.cart.where((c) => c.ticket.id == ticket.id);
|
||||||
|
final cartItem = cartMatches.isNotEmpty ? cartMatches.first : null;
|
||||||
|
final qty = cartItem?.quantity ?? 0;
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: qty > 0 ? const Color(0xFF0B63D6) : Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(ticket.ticketType, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Rs ${ticket.price.toStringAsFixed(0)}', style: const TextStyle(color: Color(0xFF0B63D6), fontWeight: FontWeight.w700, fontSize: 18)),
|
||||||
|
if (ticket.description != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(ticket.description!, style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: qty > 0 ? () => provider.setTicketQuantity(ticket, qty - 1) : null,
|
||||||
|
),
|
||||||
|
Text('$qty', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline, color: Color(0xFF0B63D6)),
|
||||||
|
onPressed: qty < ticket.availableQuantity
|
||||||
|
? () => provider.setTicketQuantity(ticket, qty + 1)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDetailsForm(CheckoutProvider provider) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Contact Details', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_field('Full Name', _nameCtrl, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||||
|
_field('Email', _emailCtrl, type: TextInputType.emailAddress, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||||
|
_field('Phone', _phoneCtrl, type: TextInputType.phone, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Consumer<CheckoutProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _promoCtrl,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Promo Code (optional)',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
suffixIcon: provider.promoApplied
|
||||||
|
? const Icon(Icons.check_circle, color: Colors.green, size: 20)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
enabled: !provider.promoApplied,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: provider.promoApplied
|
||||||
|
? OutlinedButton(
|
||||||
|
onPressed: () {
|
||||||
|
provider.resetPromo();
|
||||||
|
_promoCtrl.clear();
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
)
|
||||||
|
: ElevatedButton(
|
||||||
|
onPressed: provider.loading
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final ok = await provider.applyPromo(_promoCtrl.text);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(provider.promoMessage ??
|
||||||
|
(ok ? 'Promo applied!' : 'Invalid promo code')),
|
||||||
|
backgroundColor: ok ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0B63D6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: const Text('Apply'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (provider.promoApplied && provider.promoMessage != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.local_offer, size: 14, color: Colors.green),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${provider.promoMessage} — saves \u20b9${provider.discountAmount.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.green),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _field(String label, TextEditingController ctrl, {TextInputType? type, String? Function(String?)? validator}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: TextFormField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: type,
|
||||||
|
validator: validator,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPaymentReview(CheckoutProvider provider) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
const Text('Order Summary', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
...provider.cart.map((item) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('${item.ticket.ticketType} x${item.quantity}'),
|
||||||
|
Text('Rs ${item.subtotal.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
const Divider(height: 32),
|
||||||
|
if (provider.promoApplied) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Subtotal', style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
Text('Rs ${provider.subtotal.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.local_offer, size: 14, color: Colors.green),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
provider.couponCode != null ? 'Promo (${provider.couponCode})' : 'Promo discount',
|
||||||
|
style: const TextStyle(color: Colors.green),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text('- Rs ${provider.discountAmount.toStringAsFixed(0)}',
|
||||||
|
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.w600)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text('Total', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||||
|
Text('Rs ${provider.total.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Color(0xFF0B63D6))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_nameCtrl.text.isNotEmpty) ...[
|
||||||
|
Text('Name: ${_nameCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
Text('Email: ${_emailCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
Text('Phone: ${_phoneCtrl.text}', style: TextStyle(color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomBar(CheckoutProvider provider) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.of(context).padding.bottom + 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.08), blurRadius: 12, offset: const Offset(0, -4))],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (provider.currentStep != CheckoutStep.tickets)
|
||||||
|
TextButton(
|
||||||
|
onPressed: provider.previousStep,
|
||||||
|
child: const Text('Back'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (provider.currentStep == CheckoutStep.tickets)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: provider.hasItems ? provider.nextStep : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0B63D6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: Text('Continue Rs ${provider.subtotal.toStringAsFixed(0)}'),
|
||||||
|
)
|
||||||
|
else if (provider.currentStep == CheckoutStep.details)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_formKey.currentState?.validate() ?? false) {
|
||||||
|
provider.setShipping(ShippingDetails(
|
||||||
|
name: _nameCtrl.text,
|
||||||
|
email: _emailCtrl.text,
|
||||||
|
phone: _phoneCtrl.text,
|
||||||
|
));
|
||||||
|
provider.nextStep();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0B63D6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: const Text('Review Order'),
|
||||||
|
)
|
||||||
|
else if (provider.currentStep == CheckoutStep.payment)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: provider.loading ? null : () => _processPayment(provider),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0B63D6),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
child: provider.loading
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: Text('Pay Rs ${provider.total.toStringAsFixed(0)}'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _processPayment(CheckoutProvider provider) async {
|
||||||
|
try {
|
||||||
|
await provider.processCheckout();
|
||||||
|
_paymentService.openPayment(
|
||||||
|
amount: provider.total,
|
||||||
|
email: _emailCtrl.text,
|
||||||
|
phone: _phoneCtrl.text,
|
||||||
|
eventName: widget.eventName,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(userFriendlyError(e)), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
271
lib/screens/contributor_profile_screen.dart
Normal file
271
lib/screens/contributor_profile_screen.dart
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
// lib/screens/contributor_profile_screen.dart
|
||||||
|
// CTR-004 — Public contributor profile page.
|
||||||
|
// Shows avatar, tier ring, EP stats, and submission grid for any contributor.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../features/gamification/models/gamification_models.dart';
|
||||||
|
import '../features/gamification/services/gamification_service.dart';
|
||||||
|
import '../widgets/tier_avatar_ring.dart';
|
||||||
|
|
||||||
|
class ContributorProfileScreen extends StatefulWidget {
|
||||||
|
final String contributorId;
|
||||||
|
final String contributorName;
|
||||||
|
|
||||||
|
const ContributorProfileScreen({
|
||||||
|
super.key,
|
||||||
|
required this.contributorId,
|
||||||
|
required this.contributorName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContributorProfileScreen> createState() => _ContributorProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
|
||||||
|
DashboardResponse? _data;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final data = await GamificationService().getDashboardForUser(widget.contributorId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_data = data;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Could not load profile';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0F172A),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF0F172A),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(
|
||||||
|
widget.contributorName,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFF3B82F6)),
|
||||||
|
)
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
final profile = _data!.profile;
|
||||||
|
final submissions = _data!.submissions;
|
||||||
|
final tierStr = tierLabel(profile.tier);
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Avatar with tier ring
|
||||||
|
TierAvatarRing(
|
||||||
|
username: widget.contributorName,
|
||||||
|
tier: tierStr,
|
||||||
|
size: 88,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.contributorName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E3A8A),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tierStr,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF93C5FD)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Stats row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_statCard('EP', '${profile.currentEp}'),
|
||||||
|
_statCard('Events', '${submissions.length}'),
|
||||||
|
_statCard(
|
||||||
|
'Approved',
|
||||||
|
'${submissions.where((s) => s.status.toUpperCase() == 'APPROVED').length}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (submissions.isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, i) => _buildSubmissionTile(submissions[i]),
|
||||||
|
childCount: submissions.length,
|
||||||
|
),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Text(
|
||||||
|
'No submissions yet',
|
||||||
|
style: TextStyle(color: Colors.white38),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statCard(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmissionTile(SubmissionModel s) {
|
||||||
|
final Color statusColor;
|
||||||
|
switch (s.status.toUpperCase()) {
|
||||||
|
case 'APPROVED':
|
||||||
|
statusColor = const Color(0xFF22C55E);
|
||||||
|
break;
|
||||||
|
case 'REJECTED':
|
||||||
|
statusColor = const Color(0xFFEF4444);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusColor = const Color(0xFFFBBF24); // PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionModel.images is List<String>; use first image if present.
|
||||||
|
final String? firstImage = s.images.isNotEmpty ? s.images.first : null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E293B),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (firstImage != null && firstImage.isNotEmpty)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: Image.network(
|
||||||
|
firstImage,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.9),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.status,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (s.eventName.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [Colors.black87, Colors.transparent],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.vertical(bottom: Radius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.eventName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
// lib/screens/desktop_login_screen.dart
|
// lib/screens/desktop_login_screen.dart
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/utils/error_utils.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';
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
final message = userFriendlyError(e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
setState(() => _isAnimating = false);
|
setState(() => _isAnimating = false);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -241,7 +243,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'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@@ -324,7 +336,7 @@ class _DesktopRegisterScreenState extends State<DesktopRegisterScreen> {
|
|||||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)));
|
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
final message = userFriendlyError(e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,17 @@
|
|||||||
// lib/screens/login_screen.dart
|
// lib/screens/login_screen.dart
|
||||||
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
import '../core/utils/error_utils.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:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../features/auth/services/auth_service.dart';
|
import '../features/auth/services/auth_service.dart';
|
||||||
|
import '../features/auth/providers/auth_provider.dart';
|
||||||
|
import '../core/auth/auth_guard.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
import 'responsive_layout.dart';
|
||||||
|
import 'home_desktop_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
const LoginScreen({Key? key}) : super(key: key);
|
const LoginScreen({Key? key}) : super(key: key);
|
||||||
@@ -47,9 +53,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);
|
||||||
@@ -107,7 +111,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
final message = userFriendlyError(e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
@@ -124,6 +128,27 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _performGoogleLogin() async {
|
||||||
|
try {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await Provider.of<AuthProvider>(context, listen: false).googleLogin();
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => ResponsiveLayout(mobile: HomeScreen(), desktop: const HomeDesktopScreen())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(userFriendlyError(e))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Glassmorphism pill-shaped input decoration
|
/// Glassmorphism pill-shaped input decoration
|
||||||
InputDecoration _glassInputDecoration({
|
InputDecoration _glassInputDecoration({
|
||||||
required String hint,
|
required String hint,
|
||||||
@@ -474,7 +499,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
color: Color(0xFF4285F4),
|
color: Color(0xFF4285F4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: _showComingSoon,
|
onTap: _performGoogleLogin,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_socialButton(
|
_socialButton(
|
||||||
@@ -511,6 +536,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -542,6 +595,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
final AuthService _auth = AuthService();
|
final AuthService _auth = AuthService();
|
||||||
|
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
String? _selectedDistrict;
|
||||||
|
|
||||||
|
static const _districts = [
|
||||||
|
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||||
|
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||||
|
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -572,6 +632,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
email: email,
|
email: email,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
password: pass,
|
password: pass,
|
||||||
|
district: _selectedDistrict,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -579,7 +640,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
final message = userFriendlyError(e);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
@@ -626,6 +687,13 @@ class _RegisterScreenState extends State<RegisterScreen> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
|
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _selectedDistrict,
|
||||||
|
decoration: const InputDecoration(labelText: 'District (optional)'),
|
||||||
|
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
|
||||||
|
onChanged: (v) => setState(() => _selectedDistrict = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
|
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
|
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,23 @@
|
|||||||
// lib/screens/search_screen.dart
|
// lib/screens/search_screen.dart
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../core/utils/error_utils.dart';
|
||||||
|
|
||||||
// Location packages
|
// Location packages
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:geocoding/geocoding.dart';
|
import 'package:geocoding/geocoding.dart';
|
||||||
|
|
||||||
/// Data model for a location suggestion (city + optional pincode).
|
/// Data model for a location suggestion (city + optional pincode + optional coords).
|
||||||
class _LocationItem {
|
class _LocationItem {
|
||||||
final String city;
|
final String city;
|
||||||
final String? district;
|
final String? district;
|
||||||
final String? pincode;
|
final String? pincode;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
|
||||||
const _LocationItem({required this.city, this.district, this.pincode});
|
const _LocationItem({required this.city, this.district, this.pincode, this.lat, this.lng});
|
||||||
|
|
||||||
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
|
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
|
||||||
String get displaySubtitle => pincode ?? '';
|
String get displaySubtitle => pincode ?? '';
|
||||||
@@ -45,50 +50,43 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
'Kottayam',
|
'Kottayam',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Searchable location database – Kerala towns/cities with pincodes.
|
/// Searchable location database – loaded from assets/data/kerala_pincodes.json.
|
||||||
static const List<_LocationItem> _locationDb = [
|
List<_LocationItem> _locationDb = [];
|
||||||
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
|
bool _pinsLoaded = false;
|
||||||
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
|
|
||||||
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
|
|
||||||
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
|
|
||||||
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
|
|
||||||
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
|
|
||||||
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
|
|
||||||
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
|
|
||||||
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
|
|
||||||
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
|
|
||||||
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
|
|
||||||
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
|
|
||||||
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
|
|
||||||
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
|
|
||||||
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
|
|
||||||
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
|
|
||||||
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
|
|
||||||
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
|
|
||||||
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
|
|
||||||
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
|
|
||||||
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
|
|
||||||
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
|
|
||||||
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
|
|
||||||
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
|
|
||||||
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
|
|
||||||
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
|
|
||||||
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
|
|
||||||
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
|
|
||||||
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
|
|
||||||
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
|
|
||||||
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
|
|
||||||
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
|
|
||||||
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
|
|
||||||
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
|
|
||||||
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
|
|
||||||
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
|
|
||||||
];
|
|
||||||
|
|
||||||
List<_LocationItem> _searchResults = [];
|
List<_LocationItem> _searchResults = [];
|
||||||
bool _showSearchResults = false;
|
bool _showSearchResults = false;
|
||||||
bool _loadingLocation = false;
|
bool _loadingLocation = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadKeralaData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadKeralaData() async {
|
||||||
|
if (_pinsLoaded) return;
|
||||||
|
try {
|
||||||
|
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
|
||||||
|
final List<dynamic> list = jsonDecode(jsonStr);
|
||||||
|
final loaded = list.map((e) => _LocationItem(
|
||||||
|
city: e['city'] as String,
|
||||||
|
district: e['district'] as String?,
|
||||||
|
pincode: e['pincode'] as String?,
|
||||||
|
lat: (e['lat'] as num?)?.toDouble(),
|
||||||
|
lng: (e['lng'] as num?)?.toDouble(),
|
||||||
|
)).toList();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_locationDb = loaded;
|
||||||
|
_pinsLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// fallback: keep empty list, search won't crash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_ctrl.dispose();
|
_ctrl.dispose();
|
||||||
@@ -112,8 +110,28 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pop with a structured result so home_screen can update the display label,
|
||||||
|
/// pincode, and GPS coordinates used for haversine filtering.
|
||||||
|
void _selectWithPincode(String label, {String? pincode, double? lat, double? lng}) {
|
||||||
|
final result = <String, dynamic>{
|
||||||
|
'label': label,
|
||||||
|
'pincode': pincode ?? 'all',
|
||||||
|
};
|
||||||
|
if (lat != null && lng != null) {
|
||||||
|
result['lat'] = lat;
|
||||||
|
result['lng'] = lng;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(result);
|
||||||
|
}
|
||||||
|
|
||||||
void _selectAndClose(String location) {
|
void _selectAndClose(String location) {
|
||||||
Navigator.of(context).pop(location);
|
// Looks up pincode + coordinates from the database for the given city name.
|
||||||
|
final match = _locationDb.cast<_LocationItem?>().firstWhere(
|
||||||
|
(loc) => loc!.city.toLowerCase() == location.toLowerCase() ||
|
||||||
|
loc.displayTitle.toLowerCase() == location.toLowerCase(),
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
_selectWithPincode(location, pincode: match?.pincode, lat: match?.lat, lng: match?.lng);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _useCurrentLocation() async {
|
Future<void> _useCurrentLocation() async {
|
||||||
@@ -128,13 +146,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
|
||||||
Navigator.of(context).pop('Current Location');
|
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
||||||
|
|
||||||
|
String label = 'Current Location';
|
||||||
try {
|
try {
|
||||||
final placemarks = await placemarkFromCoordinates(pos.latitude, pos.longitude);
|
final placemarks = await placemarkFromCoordinates(pos.latitude, pos.longitude);
|
||||||
if (placemarks.isNotEmpty) {
|
if (placemarks.isNotEmpty) {
|
||||||
@@ -143,17 +162,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
|
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
|
||||||
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||||||
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
|
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
|
||||||
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
if (parts.isNotEmpty) label = parts.join(', ');
|
||||||
if (mounted) Navigator.of(context).pop(label);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
|
if (mounted) {
|
||||||
|
// Return lat/lng so home_screen can use haversine filtering
|
||||||
|
Navigator.of(context).pop(<String, dynamic>{
|
||||||
|
'label': label,
|
||||||
|
'pincode': 'all',
|
||||||
|
'lat': pos.latitude,
|
||||||
|
'lng': pos.longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
} catch (e) {
|
} 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(userFriendlyError(e))));
|
||||||
Navigator.of(context).pop('Current Location');
|
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loadingLocation = false);
|
if (mounted) setState(() => _loadingLocation = false);
|
||||||
@@ -169,10 +195,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 +333,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) {
|
||||||
@@ -322,10 +352,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
|
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
|
||||||
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
|
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
|
||||||
: null,
|
: null,
|
||||||
onTap: () => _selectAndClose(loc.returnValue),
|
onTap: () => _selectWithPincode(loc.displayTitle, pincode: loc.pincode, lat: loc.lat, lng: loc.lng),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
] 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),
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
99
lib/widgets/bouncing_loader.dart
Normal file
99
lib/widgets/bouncing_loader.dart
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
// lib/widgets/bouncing_loader.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Three-dot bouncing loader using Curves.bounceOut.
|
||||||
|
/// Drop-in replacement for CircularProgressIndicator on full-screen loads.
|
||||||
|
class BouncingLoader extends StatefulWidget {
|
||||||
|
final Color? color;
|
||||||
|
final double dotSize;
|
||||||
|
final double spacing;
|
||||||
|
|
||||||
|
const BouncingLoader({
|
||||||
|
Key? key,
|
||||||
|
this.color,
|
||||||
|
this.dotSize = 8.0,
|
||||||
|
this.spacing = 6.0,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BouncingLoader> createState() => _BouncingLoaderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BouncingLoaderState extends State<BouncingLoader> with TickerProviderStateMixin {
|
||||||
|
late final List<AnimationController> _controllers;
|
||||||
|
late final List<Animation<double>> _animations;
|
||||||
|
|
||||||
|
static const _duration = Duration(milliseconds: 600);
|
||||||
|
static const _staggerDelay = Duration(milliseconds: 200);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controllers = List.generate(
|
||||||
|
3,
|
||||||
|
(i) => AnimationController(vsync: this, duration: _duration),
|
||||||
|
);
|
||||||
|
_animations = _controllers.map((c) {
|
||||||
|
return Tween<double>(begin: 0.0, end: -12.0).animate(
|
||||||
|
CurvedAnimation(parent: c, curve: Curves.bounceOut),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_startWithStagger();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startWithStagger() async {
|
||||||
|
for (int i = 0; i < _controllers.length; i++) {
|
||||||
|
await Future.delayed(i == 0 ? Duration.zero : _staggerDelay);
|
||||||
|
if (!mounted) return;
|
||||||
|
_startLoop(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startLoop(int index) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_controllers[index].forward(from: 0).whenComplete(() {
|
||||||
|
if (mounted) {
|
||||||
|
Future.delayed(
|
||||||
|
Duration(milliseconds: _staggerDelay.inMilliseconds * (_controllers.length - 1)),
|
||||||
|
() { if (mounted) _startLoop(index); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in _controllers) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dotColor = widget.color ?? Theme.of(context).colorScheme.primary;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(3, (i) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: widget.spacing / 2),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animations[i],
|
||||||
|
builder: (_, __) => Transform.translate(
|
||||||
|
offset: Offset(0, _animations[i].value),
|
||||||
|
child: Container(
|
||||||
|
width: widget.dotSize,
|
||||||
|
height: widget.dotSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: dotColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
lib/widgets/desktop_sidebar.dart
Normal file
141
lib/widgets/desktop_sidebar.dart
Normal 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);
|
||||||
|
}
|
||||||
142
lib/widgets/desktop_topbar.dart
Normal file
142
lib/widgets/desktop_topbar.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
lib/widgets/eventify_bottom_sheet.dart
Normal file
96
lib/widgets/eventify_bottom_sheet.dart
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
void showEventifyBottomSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required Widget child,
|
||||||
|
double initialSize = 0.5,
|
||||||
|
double minSize = 0.3,
|
||||||
|
double maxSize = 0.9,
|
||||||
|
bool isDismissible = true,
|
||||||
|
}) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
isDismissible: isDismissible,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => DraggableScrollableSheet(
|
||||||
|
initialChildSize: initialSize,
|
||||||
|
minChildSize: minSize,
|
||||||
|
maxChildSize: maxSize,
|
||||||
|
expand: false,
|
||||||
|
builder: (_, scrollController) => _EventifyBottomSheetContent(
|
||||||
|
title: title,
|
||||||
|
child: child,
|
||||||
|
scrollController: scrollController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventifyBottomSheetContent extends StatelessWidget {
|
||||||
|
const _EventifyBottomSheetContent({
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
required this.scrollController,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF0F172A),
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white54),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.white12, height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
lib/widgets/glass_card.dart
Normal file
53
lib/widgets/glass_card.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'dart:ui';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class GlassCard extends StatelessWidget {
|
||||||
|
const GlassCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(16),
|
||||||
|
this.margin,
|
||||||
|
this.borderRadius = 16,
|
||||||
|
this.blur = 10,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.borderColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final double borderRadius;
|
||||||
|
final double blur;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? borderColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final effectiveBackground =
|
||||||
|
backgroundColor ?? const Color(0xFF1E293B).withOpacity(0.6);
|
||||||
|
final effectiveBorder =
|
||||||
|
borderColor ?? Colors.white.withOpacity(0.08);
|
||||||
|
|
||||||
|
Widget card = ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: effectiveBackground,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: Border.all(color: effectiveBorder, width: 1),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (margin != null) {
|
||||||
|
return Container(margin: margin, child: card);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
lib/widgets/landscape_section_header.dart
Normal file
58
lib/widgets/landscape_section_header.dart
Normal 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!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/widgets/landscape_shell.dart
Normal file
67
lib/widgets/landscape_shell.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
lib/widgets/responsive_shell.dart
Normal file
84
lib/widgets/responsive_shell.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
134
lib/widgets/skeleton_loader.dart
Normal file
134
lib/widgets/skeleton_loader.dart
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
|
||||||
|
/// Generic shimmer rectangle with configurable dimensions and border radius.
|
||||||
|
class SkeletonBox extends StatelessWidget {
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double borderRadius;
|
||||||
|
|
||||||
|
const SkeletonBox({
|
||||||
|
Key? key,
|
||||||
|
this.width = double.infinity,
|
||||||
|
required this.height,
|
||||||
|
this.borderRadius = 8,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: isDark ? const Color(0xFF2D2D2D) : Colors.grey[300]!,
|
||||||
|
highlightColor: isDark ? const Color(0xFF3D3D3D) : Colors.grey[100]!,
|
||||||
|
child: Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer placeholder for a compact event card (used in horizontal lists).
|
||||||
|
class EventCardSkeleton extends StatelessWidget {
|
||||||
|
const EventCardSkeleton({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
SkeletonBox(height: 140, borderRadius: 12),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
SkeletonBox(height: 14, width: 160),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
SkeletonBox(height: 12, width: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer placeholder for a full-width event list row.
|
||||||
|
class EventListSkeleton extends StatelessWidget {
|
||||||
|
const EventListSkeleton({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||||
|
child: Row(
|
||||||
|
children: const [
|
||||||
|
SkeletonBox(width: 64, height: 64, borderRadius: 10),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SkeletonBox(height: 14),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
SkeletonBox(height: 12, width: 140),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer placeholder for hero carousel area.
|
||||||
|
class HeroCarouselSkeleton extends StatelessWidget {
|
||||||
|
const HeroCarouselSkeleton({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: SkeletonBox(height: 320, borderRadius: 24),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer grid for achievements tab.
|
||||||
|
class AchievementGridSkeleton extends StatelessWidget {
|
||||||
|
const AchievementGridSkeleton({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: List.generate(4, (_) => const SkeletonBox(height: 160, borderRadius: 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shimmer placeholder for profile stat cards row.
|
||||||
|
class ProfileStatsSkeleton extends StatelessWidget {
|
||||||
|
const ProfileStatsSkeleton({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: List.generate(3, (_) => const Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: SkeletonBox(height: 80, borderRadius: 12),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
lib/widgets/tier_avatar_ring.dart
Normal file
117
lib/widgets/tier_avatar_ring.dart
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
|
class TierAvatarRing extends StatelessWidget {
|
||||||
|
final String username;
|
||||||
|
final String tier;
|
||||||
|
final double size;
|
||||||
|
final bool showDiceBear;
|
||||||
|
final String? imageUrl;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
static const Map<String, Color> _tierColors = {
|
||||||
|
'Bronze': Color(0xFFFED7AA),
|
||||||
|
'Silver': Color(0xFFE2E8F0),
|
||||||
|
'Gold': Color(0xFFFEF3C7),
|
||||||
|
'Platinum': Color(0xFFEDE9FE),
|
||||||
|
'Diamond': Color(0xFFE0E7FF),
|
||||||
|
};
|
||||||
|
|
||||||
|
static const Color _fallbackColor = Color(0xFF475569);
|
||||||
|
|
||||||
|
const TierAvatarRing({
|
||||||
|
super.key,
|
||||||
|
required this.username,
|
||||||
|
required this.tier,
|
||||||
|
this.size = 48.0,
|
||||||
|
this.showDiceBear = true,
|
||||||
|
this.imageUrl,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
Color get _ringColor => _tierColors[tier] ?? _fallbackColor;
|
||||||
|
|
||||||
|
String get _avatarUrl {
|
||||||
|
if (imageUrl != null && imageUrl!.isNotEmpty) {
|
||||||
|
return imageUrl!;
|
||||||
|
}
|
||||||
|
return 'https://api.dicebear.com/9.x/notionists/svg?seed=$username';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar() {
|
||||||
|
final double radius = size / 2 - 5;
|
||||||
|
|
||||||
|
if (!showDiceBear) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: size * 0.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: _avatarUrl,
|
||||||
|
imageBuilder: (context, imageProvider) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundImage: imageProvider,
|
||||||
|
),
|
||||||
|
placeholder: (context, url) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: SizedBox(
|
||||||
|
width: size * 0.4,
|
||||||
|
height: size * 0.4,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: size * 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Color ringColor = _ringColor;
|
||||||
|
final double containerSize = size + 6;
|
||||||
|
|
||||||
|
final Widget ring = Container(
|
||||||
|
width: containerSize,
|
||||||
|
height: containerSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: ringColor, width: 3),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: ringColor.withOpacity(0.4),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(child: _buildAvatar()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: ring,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,22 @@ import Foundation
|
|||||||
|
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
|
import google_sign_in_ios
|
||||||
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
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
|
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
196
pubspec.lock
196
pubspec.lock
@@ -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:
|
||||||
@@ -113,6 +137,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.8"
|
version: "1.0.8"
|
||||||
|
eventify:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: eventify
|
||||||
|
sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -182,6 +214,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:
|
||||||
@@ -206,6 +246,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.30"
|
version: "2.0.30"
|
||||||
|
flutter_staggered_animations:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_staggered_animations
|
||||||
|
sha256: "81d3c816c9bb0dca9e8a5d5454610e21ffb068aedb2bde49d2f8d04f75538351"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -304,6 +352,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.1.3"
|
version: "0.1.3"
|
||||||
|
google_identity_services_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_identity_services_web
|
||||||
|
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.3+1"
|
||||||
google_maps:
|
google_maps:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -352,6 +408,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.14+3"
|
version: "0.5.14+3"
|
||||||
|
google_sign_in:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: google_sign_in
|
||||||
|
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.0"
|
||||||
|
google_sign_in_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_android
|
||||||
|
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.2.1"
|
||||||
|
google_sign_in_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_ios
|
||||||
|
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.9.0"
|
||||||
|
google_sign_in_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_platform_interface
|
||||||
|
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.0"
|
||||||
|
google_sign_in_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: google_sign_in_web
|
||||||
|
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.4+4"
|
||||||
html:
|
html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -361,7 +457,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.15.6"
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
@@ -520,6 +616,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:
|
||||||
@@ -537,7 +649,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
@@ -616,6 +728,30 @@ 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"
|
||||||
|
razorpay_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: razorpay_flutter
|
||||||
|
sha256: "8d985b769808cb6c8d3f2fbcc25f9ab78e29191965c31c98e2d69d55d9d20ff1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.3"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
sanitize_html:
|
sanitize_html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -696,6 +832,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shimmer:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shimmer
|
||||||
|
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
simple_gesture_detector:
|
simple_gesture_detector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -717,6 +861,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 +933,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:
|
||||||
|
|||||||
12
pubspec.yaml
12
pubspec.yaml
@@ -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,15 @@ 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
|
||||||
|
path_provider: ^2.1.0
|
||||||
|
provider: ^6.1.2
|
||||||
video_player: ^2.8.1
|
video_player: ^2.8.1
|
||||||
|
cached_network_image: ^3.3.1
|
||||||
|
razorpay_flutter: ^1.3.7
|
||||||
|
google_sign_in: ^6.2.2
|
||||||
|
http: ^1.2.0
|
||||||
|
shimmer: ^3.0.0
|
||||||
|
flutter_staggered_animations: ^1.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -32,6 +40,8 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/icon/hand_stop.svg
|
- assets/icon/hand_stop.svg
|
||||||
|
- assets/login-bg.mp4
|
||||||
|
- assets/data/kerala_pincodes.json
|
||||||
fonts:
|
fonts:
|
||||||
- family: Gilroy
|
- family: Gilroy
|
||||||
fonts:
|
fonts:
|
||||||
|
|||||||
3
run_web.sh
Executable file
3
run_web.sh
Executable 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}"
|
||||||
Reference in New Issue
Block a user