Compare commits
71 Commits
d536d287cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3484fa9885 | |||
| 7867e6c728 | |||
| 98a5d541aa | |||
| b9efe18669 | |||
| ebe654f9c3 | |||
| f3250737bd | |||
| 754b04dc05 | |||
| 5e00e431e3 | |||
| b2f0943797 | |||
| 6990b62645 | |||
| c85564efc8 | |||
| 593fc9dcf9 | |||
| 6b6f08fd26 | |||
| d0762668d6 | |||
| 9f1de2bead | |||
| c40e600937 | |||
| 479fe5e119 | |||
|
|
bbef5b376d | ||
| aefb381ed3 | |||
|
|
d921ac2b78 | ||
| 4c57391bbd | |||
|
|
7bc396bdde | ||
| 685c6755d8 | |||
| b8fcd29aff | |||
| b24df66b31 | |||
| c6c313854d | |||
| 8481b14a7a | |||
| 42b71beae2 | |||
| a32ead31c2 | |||
| bb06bd8ac6 | |||
| d3d7d04305 | |||
| 3729ee0abf | |||
| e3f501ae4b | |||
| ec607209aa | |||
| 7cd64883e2 | |||
| e9752c3d61 | |||
| e365361451 | |||
| 8955febd00 | |||
| bc12fe70aa | |||
| 81872070e4 | |||
| 6c533614b3 | |||
| 2fc45e0c5b | |||
| 34a39ada31 | |||
| 206602fca6 | |||
| ee97c54f73 | |||
| 1badeff966 | |||
| a7f3b215e4 | |||
| c32f343558 | |||
| 1e90f5fc4b | |||
| bc6fde1b90 | |||
| 9dd78be03e | |||
| 1c73fb8d9d | |||
| 0c4e62d00e | |||
| 6d29b95118 | |||
| d74e637a59 | |||
| 0982e4fdee | |||
| 9fd5fc3d3b | |||
| 2c109f692c | |||
| 8d9bbe888e | |||
| 002ed3ee98 | |||
| 2aa05366ad | |||
| 50caad21a5 | |||
| 5b98f41596 | |||
| 5d9de1553d | |||
| 89e12a707b | |||
| 3816c2c844 | |||
| d6d8ac6dbf | |||
| d0efb3b10d | |||
| 809912305a | |||
| e0f34398c2 | |||
| 4acf75902c |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -45,3 +45,20 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
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
|
||||||
|
|||||||
196
CHANGELOG.md
Normal file
196
CHANGELOG.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# 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/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-04-10
|
||||||
|
|
||||||
|
Patch release — hotfix for Google Sign-In broken in 2.0.0.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Sign in with Google** (`lib/screens/login_screen.dart`): Resolved authentication failure introduced in 2.0.0. Google OAuth flow now completes correctly and exchanges tokens with Django `POST /accounts/google-auth/` as expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-04-10
|
||||||
|
|
||||||
|
Public release milestone. Full backend integration, real image upload pipeline, complete personal profile system, and production Android build infrastructure.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Real image upload pipeline** (`lib/core/api/api_client.dart`): `uploadFile()` method uses `http.MultipartRequest` with explicit MIME type detection from file extension. Supports JPEG, PNG, WebP, MP4, MOV. Files upload to Node.js `/api/v1/upload/file` → OneDrive via Microsoft Graph API, returning a shareable anonymous link.
|
||||||
|
- **Contribute image upload to OneDrive** (`lib/features/gamification/services/gamification_service.dart`): `submitContribution()` now uploads all selected images before submitting the event form. Uploaded file metadata (including OneDrive share URL) is passed as `media` array in the contribution payload — replacing the broken device-path-as-string approach.
|
||||||
|
- **Upload endpoint constant** (`lib/core/api/api_endpoints.dart`): `ApiEndpoints.uploadFile` pointing to `$_nodeBase/v1/upload/file`.
|
||||||
|
- **Full personal info form in Edit Profile sheet** (`lib/screens/profile_screen.dart`):
|
||||||
|
- First Name, Last Name fields
|
||||||
|
- Email (read-only, locked from direct edit)
|
||||||
|
- Phone number field
|
||||||
|
- District picker with 183-day change cooldown — shows next-change date when locked
|
||||||
|
- Place, Pincode, State, Country fields
|
||||||
|
- All fields loaded from SharedPreferences cache and API on open; saved via `PATCH /api/user/update-profile/`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **App version** displayed in Settings → About updated to `2.0(b)` (`lib/screens/settings_screen.dart`).
|
||||||
|
- **All "(demo)" labels replaced with "(coming soon)"** across the app:
|
||||||
|
- `settings_screen.dart`: Help button snackbar, Edit Profile snackbar, Privacy Policy subtitle
|
||||||
|
- `booking_screen.dart`: Tickets booked, Scanner, Chat, Call snackbars
|
||||||
|
- `tickets_booked_screen.dart`: Scanner, Chat/WhatsApp, Call snackbars
|
||||||
|
- `calendar_screen.dart`: Notifications snackbar
|
||||||
|
- **`pubspec.yaml` version bumped** to `2.0.0+20` (version name `2.0.0`, build code `20`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Android build version override** (`android/app/build.gradle.kts`): Removed hardcoded `versionCode = 17` and `versionName = "1.6.1(p)"` — both now read from `flutter.versionCode` / `flutter.versionName` (sourced from `pubspec.yaml`). This was causing Play Store rejections ("version code 17 already used") on every release build.
|
||||||
|
- **`http_parser` dependency added** (`pubspec.yaml`): Required for explicit `MediaType` MIME typing in `MultipartRequest`. Without it, file uploads defaulted to `application/octet-stream` and were rejected by the Node.js multer middleware.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
- **Production AAB built and signed** with `upload-keystore-new.jks` — build 20, version name `2.0` — submitted to Google Play Console.
|
||||||
|
- **`build.gradle.kts` signing config** reads `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` from `gradle.properties` or environment variables (no secrets in source).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.1] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 4 — animation polish and final feature gaps. Flutter app reaches full feature parity with Consumer Web App v1.4.9.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **BouncingLoader widget** (`lib/widgets/bouncing_loader.dart`): 3-dot bouncing animation with staggered 200 ms delays using `Curves.bounceOut`. Replaces `CircularProgressIndicator` in home, contribute, and review screens. Accepts `color`, `dotSize`, and `spacing` parameters.
|
||||||
|
- **DiceBear Notionists avatars on review cards** (REV-001): `CachedNetworkImage` fetches `api.dicebear.com/9.x/notionists/svg?seed={username}`. Falls back to coloured initial letter `CircleAvatar` on error or while loading.
|
||||||
|
- **Server-side event search** (HOME-007): Search modal now sends `q` param to `EventsByPincodeView`; client-side filter stays for instant `onChanged` feedback while server results load on submit. Cache is bypassed for search queries. Django backend updated with `Q(title__icontains=q) | Q(description__icontains=q)` OR filter.
|
||||||
|
- **`flutter_staggered_animations: ^1.1.1`** added to pubspec.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Review list stagger animation** (REV-003): `AnimationLimiter` + `AnimationConfiguration.toStaggeredList` wraps review cards with 375 ms slide-up + fade-in per item.
|
||||||
|
- **Review submit success spring animation** (REV-004): Checkmark icon now animates with `ScaleTransition` driven by `Curves.elasticOut` (600 ms) instead of a static icon swap.
|
||||||
|
- **Hero transitions on event cards** (UX-005): `Hero(tag: 'event-hero-{id}')` wraps event images in home screen and matching destination in learn more screen — enabling shared-element transitions.
|
||||||
|
- **FadeTransition on learn more screen** (UX-005): Screen body fades in with `Curves.easeIn` (350 ms) after event data loads.
|
||||||
|
- **AnimatedList stagger on leaderboard** (UX-005): `SliverList` entries animate with `AnimationConfiguration.staggeredList` — 375 ms slide-up + fade-in per row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.6.0] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 3 — 26 medium-priority gaps. Profile editing, contributor profiles, share cards, booking promo codes, and UX system components.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Eventify ID badge** (AUTH-003): Verified badge displayed on profile and contributor cards for accounts with confirmed identity.
|
||||||
|
- **DiceBear TierAvatarRing** (`lib/widgets/tier_avatar_ring.dart`): Tier-coloured ring around profile avatars using DiceBear seed — Bronze/Silver/Gold/Platinum/Diamond colours.
|
||||||
|
- **Profile photo upload to server** (AUTH-006 / PROF-002): `PATCH /api/user/update-profile/` multipart endpoint; photo picker + crop flow integrated.
|
||||||
|
- **District picker** (PROF-004): 14 Kerala districts selectable from a bottom sheet; stored against user profile.
|
||||||
|
- **183-day profile cooldown lock** (AUTH-005): Username and display name locked for 183 days after last change; countdown shown in edit form.
|
||||||
|
- **Kerala pincodes JSON** (`assets/data/kerala_pincodes.json`) (LOC-003): Full offline pincode dataset covering all 14 districts; powers location-aware event discovery without API round trips.
|
||||||
|
- **Promo code input on booking** (BOOK-003): `POST /bookings/apply-promo/` endpoint; inline validation with success/error state in booking bar.
|
||||||
|
- **Contributor profile screen** (`lib/screens/contributor_profile_screen.dart`) (CTR-004/005): Public view of any contributor's stats, tier, events submitted, and achievements.
|
||||||
|
- **Share rank card** (`lib/features/share/share_rank_card.dart`) (SHARE-001/002): Generates a shareable tier/EP card image; `share_plus` used for native share sheet.
|
||||||
|
- **Share status button on contributor dashboard** (CTR-003): `OutlinedButton.icon` with `Share.share()` near tier/EP display.
|
||||||
|
- **GlassCard widget** (`lib/widgets/glass_card.dart`) (UX-003): Reusable frosted-glass surface used across gamification and profile screens.
|
||||||
|
- **EventifyBottomSheet** (`lib/widgets/eventify_bottom_sheet.dart`) (UX-004): Standardised bottom sheet with drag handle, rounded corners, and safe-area inset.
|
||||||
|
- **Featured events carousel** (HOME-004): Auto-scrolling hero carousel for featured/sponsored events on home screen.
|
||||||
|
- **Event image gallery** (EVT-001): Full-screen `PageView` carousel inside learn more screen with dot indicator.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Real gamification data** (GAM-003/004/006): EP/RP transaction history, tier progression, and leaderboard all wired to live Node.js API — mock data removed.
|
||||||
|
- **Leaderboard card district display** (LDR-003): District badge shown per rank row; district filter pill row added above leaderboard.
|
||||||
|
- **Achievement badge display + unlock animation** (ACH-002/003): Badges rendered from API; confetti-style animation plays on first unlock.
|
||||||
|
- **Review responses** (REV-002): Organiser reply thread displayed below each review card.
|
||||||
|
- **Profile bio and social links** (PROF-001): Edit form includes bio textarea and links for Instagram, Twitter, LinkedIn.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 2 — 11 high-priority gaps. Authentication hardening, location services, real gamification data hookup, skeleton loading, and booking flow fixes.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Email OTP verification** (AUTH-004): 6-digit OTP sent on registration; verification screen blocks app access until confirmed.
|
||||||
|
- **Password reset flow** (AUTH-002): Forgot-password → OTP → new password screens; Django `POST /accounts/password-reset/` endpoint integrated.
|
||||||
|
- **Location permission + haversine distance sorting** (LOC-001/002): `geolocator` requests permission at startup; events sorted by straight-line distance from user's GPS coordinate.
|
||||||
|
- **Skeleton loading with shimmer** (UX-001): `shimmer: ^3.0.0` added; event feed, leaderboard, and profile screens show shimmer placeholders while data loads.
|
||||||
|
- **Contributor stats real API** (CTR-001): EP/RP balance and tier fetched from Node.js gamification endpoint on dashboard load.
|
||||||
|
- **Achievement progress tracking** (ACH-001): Progress bars and completion state fetched from API; local mock removed.
|
||||||
|
- **Profile stats row** (PROF-003): Likes / Posts / Views counts fetched from user profile API and displayed in profile header.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Search modal with server pincode detection** (HOME-001/002/003): Search bottom sheet auto-detects user pincode; category filter chips filter from API results.
|
||||||
|
- **Real tier and EP display** (GAM-002/005): Contributor dashboard shows live tier and EP from Node.js API; tier badge in profile header updated to match.
|
||||||
|
- **District filter on leaderboard** (LDR-002): Leaderboard district pills populated from API; selecting filters rank table in real time.
|
||||||
|
- **Booking bar fixes** (EVT-003/BOOK-004): Fixed ticket-count stepper; booking confirmation screen correctly shows booking reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-04-04
|
||||||
|
|
||||||
|
Phase 1 — critical gaps. Live backend integration replacing all mock/stub data, payment checkout, and OAuth.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Gamification API integration**: EP, RP, leaderboard, and achievements wired to live Node.js endpoints at `app.eventifyplus.com/api/v1/` — all mock `GamificationService` data replaced.
|
||||||
|
- **Razorpay checkout** (BOOK-001): Native Razorpay SDK integrated; `POST /bookings/create/` → order creation → Razorpay payment sheet → `POST /bookings/verify/` webhook.
|
||||||
|
- **Google OAuth login** (AUTH-001): `google_sign_in` flow; tokens exchanged with Django `POST /accounts/google-auth/` endpoint.
|
||||||
|
- **Notification panel** (NOTIF-002/003/004): `DraggableScrollableSheet` notification drawer with 4 colour-coded notification types (booking, event, system, promo); mark-individual-read and mark-all-read actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **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>
|
||||||
|
|||||||
Binary file not shown.
@@ -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 = flutter.versionCode
|
||||||
versionName = "1.2(p)"
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
android/app/proguard-rules.pro
vendored
Normal file
87
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Razorpay
|
||||||
|
-keepattributes *Annotation*,Signature,*Annotation*
|
||||||
|
-dontwarn com.razorpay.**
|
||||||
|
-keep class com.razorpay.** { *; }
|
||||||
|
-optimizations !method/inlining/
|
||||||
|
-keepclasseswithmembers class * {
|
||||||
|
public void onPayment*(...);
|
||||||
|
}
|
||||||
|
-keep class proguard.annotation.Keep
|
||||||
|
-keep class proguard.annotation.KeepClassMembers
|
||||||
|
-keep @proguard.annotation.Keep class * { *; }
|
||||||
|
-keep @proguard.annotation.KeepClassMembers class * {
|
||||||
|
<fields>;
|
||||||
|
<methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Google Sign-In / Play Services
|
||||||
|
-keep class com.google.android.gms.** { *; }
|
||||||
|
-keep interface com.google.android.gms.** { *; }
|
||||||
|
-dontwarn com.google.android.gms.**
|
||||||
|
-keep class com.google.firebase.** { *; }
|
||||||
|
-dontwarn com.google.firebase.**
|
||||||
|
|
||||||
|
# Geolocator / Geocoding
|
||||||
|
-keep class com.baseflow.** { *; }
|
||||||
|
-dontwarn com.baseflow.**
|
||||||
|
|
||||||
|
# url_launcher, share_plus, image_picker, path_provider, etc.
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
-dontwarn io.flutter.plugins.**
|
||||||
|
|
||||||
|
# OkHttp (used by many network libs)
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
-dontwarn javax.annotation.**
|
||||||
|
-keep class okhttp3.** { *; }
|
||||||
|
-keep interface okhttp3.** { *; }
|
||||||
|
|
||||||
|
# Keep native methods
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Parcelable classes
|
||||||
|
-keep class * implements android.os.Parcelable {
|
||||||
|
public static final android.os.Parcelable$Creator *;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep Serializable classes
|
||||||
|
-keepclassmembers class * implements java.io.Serializable {
|
||||||
|
static final long serialVersionUID;
|
||||||
|
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
||||||
|
!static !transient <fields>;
|
||||||
|
private void writeObject(java.io.ObjectOutputStream);
|
||||||
|
private void readObject(java.io.ObjectInputStream);
|
||||||
|
java.lang.Object writeReplace();
|
||||||
|
java.lang.Object readResolve();
|
||||||
|
}
|
||||||
@@ -18,6 +18,18 @@
|
|||||||
android:name="com.google.android.geo.API_KEY"
|
android:name="com.google.android.geo.API_KEY"
|
||||||
android:value="YOUR_GOOGLE_MAPS_API_KEY"/>
|
android:value="YOUR_GOOGLE_MAPS_API_KEY"/>
|
||||||
|
|
||||||
|
<!-- Splash video plays first, then launches MainActivity -->
|
||||||
|
<activity
|
||||||
|
android:name=".SplashActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -31,11 +43,6 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme" />
|
android:resource="@style/NormalTheme" />
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below. Used by Flutter tool. -->
|
<!-- Don't delete the meta-data below. Used by Flutter tool. -->
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.sicherhaven.eventify
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.view.WindowInsetsController
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.VideoView
|
||||||
|
|
||||||
|
class SplashActivity : Activity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
)
|
||||||
|
|
||||||
|
// White background matches splash logo/video content
|
||||||
|
val container = FrameLayout(this)
|
||||||
|
container.setBackgroundColor(Color.WHITE)
|
||||||
|
setContentView(container, ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
))
|
||||||
|
|
||||||
|
// Edge-to-edge: hide both status bar and navigation bar (after setContentView so DecorView exists)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window.insetsController?.let {
|
||||||
|
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
|
||||||
|
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.decorView.systemUiVisibility = (
|
||||||
|
View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val videoView = VideoView(this)
|
||||||
|
container.addView(videoView, FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
FrameLayout.LayoutParams.MATCH_PARENT
|
||||||
|
))
|
||||||
|
|
||||||
|
val uri = Uri.parse("android.resource://$packageName/${R.raw.splash_video}")
|
||||||
|
videoView.setVideoURI(uri)
|
||||||
|
|
||||||
|
videoView.setOnPreparedListener { mp ->
|
||||||
|
mp.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING)
|
||||||
|
mp.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
videoView.setOnCompletionListener {
|
||||||
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
videoView.setOnErrorListener { _, _, _ ->
|
||||||
|
startActivity(Intent(this, MainActivity::class.java))
|
||||||
|
finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
videoView.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
android/app/src/main/res/raw/splash_video.mp4
Normal file
BIN
android/app/src/main/res/raw/splash_video.mp4
Normal file
Binary file not shown.
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
BIN
assets/fonts/Gilroy-Bold.ttf
Normal file
BIN
assets/fonts/Gilroy-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-BoldItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-ExtraBold.ttf
Normal file
BIN
assets/fonts/Gilroy-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-ExtraBoldItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-Light.ttf
Normal file
BIN
assets/fonts/Gilroy-Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-LightItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-LightItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-Medium.ttf
Normal file
BIN
assets/fonts/Gilroy-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-MediumItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-Regular.ttf
Normal file
BIN
assets/fonts/Gilroy-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-RegularItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-RegularItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-SemiBold.ttf
Normal file
BIN
assets/fonts/Gilroy-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Gilroy-SemiBoldItalic.ttf
Normal file
BIN
assets/fonts/Gilroy-SemiBoldItalic.ttf
Normal file
Binary file not shown.
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
|
||||||
|
@@ -47,5 +47,18 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>GIDClientID</key>
|
||||||
|
<string>639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd.apps.googleusercontent.com</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Editor</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.googleusercontent.apps.639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
// lib/core/api/api_client.dart
|
// lib/core/api/api_client.dart
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show SocketException;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http_parser/http_parser.dart';
|
||||||
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: 25);
|
||||||
|
static const Duration _retryDelay = Duration(milliseconds: 600);
|
||||||
|
// Set to true to enable mock/offline development mode (useful when backend is unavailable)
|
||||||
|
static const bool _developmentMode = false;
|
||||||
|
|
||||||
/// POST request
|
/// POST request
|
||||||
///
|
///
|
||||||
@@ -25,21 +31,148 @@ class ApiClient {
|
|||||||
|
|
||||||
late http.Response response;
|
late http.Response response;
|
||||||
try {
|
try {
|
||||||
response = await http
|
response = await _postWithRetry(url, headers, finalBody);
|
||||||
.post(
|
|
||||||
Uri.parse(url),
|
|
||||||
headers: headers,
|
|
||||||
body: jsonEncode(finalBody),
|
|
||||||
)
|
|
||||||
.timeout(_timeout);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
|
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
|
||||||
|
|
||||||
|
// Development mode: return mock responses for common endpoints on network errors
|
||||||
|
if (_developmentMode) {
|
||||||
|
if (url.contains('/user/login/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock login response');
|
||||||
|
final email = finalBody['username'] ?? 'test@example.com';
|
||||||
|
return {
|
||||||
|
'token': 'mock_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
'username': email,
|
||||||
|
'email': email,
|
||||||
|
'phone_number': '+1234567890',
|
||||||
|
};
|
||||||
|
} else if (url.contains('/user/register/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock register response');
|
||||||
|
final email = finalBody['email'] ?? 'test@example.com';
|
||||||
|
return {
|
||||||
|
'token': 'mock_token_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
'username': email,
|
||||||
|
'email': email,
|
||||||
|
'phone_number': finalBody['phone_number'] ?? '+1234567890',
|
||||||
|
};
|
||||||
|
} else if (url.contains('/events/type-list/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock event types');
|
||||||
|
return {
|
||||||
|
'event_types': [
|
||||||
|
{'id': 1, 'event_type': 'Concert', 'event_type_icon': null},
|
||||||
|
{'id': 2, 'event_type': 'Workshop', 'event_type_icon': null},
|
||||||
|
{'id': 3, 'event_type': 'Festival', 'event_type_icon': null},
|
||||||
|
{'id': 4, 'event_type': 'Sports', 'event_type_icon': null},
|
||||||
|
{'id': 5, 'event_type': 'Conference', 'event_type_icon': null},
|
||||||
|
{'id': 6, 'event_type': 'Exhibition', 'event_type_icon': null},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} else if (url.contains('/events/pincode-events/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock events');
|
||||||
|
return {'events': _mockEvents};
|
||||||
|
} else if (url.contains('/events/event-details/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock event detail');
|
||||||
|
final eventId = finalBody['event_id'] ?? 1;
|
||||||
|
final match = _mockEvents.where((e) => e['id'] == eventId);
|
||||||
|
return match.isNotEmpty
|
||||||
|
? Map<String, dynamic>.from(match.first)
|
||||||
|
: Map<String, dynamic>.from(_mockEvents.first);
|
||||||
|
} else if (url.contains('/events/events-by-month-year/')) {
|
||||||
|
if (kDebugMode) debugPrint('Development mode: returning mock calendar');
|
||||||
|
return {
|
||||||
|
'total_number_of_events': 3,
|
||||||
|
'dates': ['2026-04-05', '2026-04-12', '2026-04-20'],
|
||||||
|
'date_events': [
|
||||||
|
{'date': '2026-04-05', 'count': 1},
|
||||||
|
{'date': '2026-04-12', 'count': 2},
|
||||||
|
{'date': '2026-04-20', 'count': 1},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw Exception('Network error: $e');
|
throw Exception('Network error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _handleResponse(url, response, finalBody);
|
return _handleResponse(url, response, finalBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST with one retry on transient network errors.
|
||||||
|
/// Retries on SocketException / TimeoutException only.
|
||||||
|
Future<http.Response> _postWithRetry(
|
||||||
|
String url,
|
||||||
|
Map<String, String> headers,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await http
|
||||||
|
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||||
|
.timeout(_timeout);
|
||||||
|
} on SocketException {
|
||||||
|
if (kDebugMode) debugPrint('ApiClient.post retry after SocketException');
|
||||||
|
await Future.delayed(_retryDelay);
|
||||||
|
return await http
|
||||||
|
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||||
|
.timeout(_timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
if (kDebugMode) debugPrint('ApiClient.post retry after TimeoutException');
|
||||||
|
await Future.delayed(_retryDelay);
|
||||||
|
return await http
|
||||||
|
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||||
|
.timeout(_timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload a single file as multipart/form-data.
|
||||||
|
///
|
||||||
|
/// Returns the `file` object from the server response:
|
||||||
|
/// `{ fileId, url, name, type, mimeType, size, backend }`
|
||||||
|
Future<Map<String, dynamic>> uploadFile(String url, String filePath) async {
|
||||||
|
final request = http.MultipartRequest('POST', Uri.parse(url));
|
||||||
|
const _mimeMap = <String, List<String>>{
|
||||||
|
'jpg': ['image', 'jpeg'],
|
||||||
|
'jpeg': ['image', 'jpeg'],
|
||||||
|
'png': ['image', 'png'],
|
||||||
|
'webp': ['image', 'webp'],
|
||||||
|
'mp4': ['video', 'mp4'],
|
||||||
|
'mov': ['video', 'quicktime'],
|
||||||
|
};
|
||||||
|
final ext = filePath.split('.').last.toLowerCase();
|
||||||
|
final parts = _mimeMap[ext] ?? ['image', 'jpeg'];
|
||||||
|
request.files.add(await http.MultipartFile.fromPath(
|
||||||
|
'file',
|
||||||
|
filePath,
|
||||||
|
contentType: MediaType(parts[0], parts[1]),
|
||||||
|
));
|
||||||
|
|
||||||
|
late http.StreamedResponse streamed;
|
||||||
|
try {
|
||||||
|
streamed = await request.send().timeout(const Duration(seconds: 60));
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Upload network error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = await streamed.stream.bytesToString();
|
||||||
|
dynamic decoded;
|
||||||
|
try {
|
||||||
|
decoded = jsonDecode(body);
|
||||||
|
} catch (_) {
|
||||||
|
throw Exception('Upload response parse error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streamed.statusCode >= 200 && streamed.statusCode < 300) {
|
||||||
|
if (decoded is Map<String, dynamic> && decoded['file'] is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded['file'] as Map);
|
||||||
|
}
|
||||||
|
return decoded is Map<String, dynamic> ? decoded : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final msg = (decoded is Map && decoded['message'] is String)
|
||||||
|
? decoded['message'] as String
|
||||||
|
: 'Upload failed (${streamed.statusCode})';
|
||||||
|
throw Exception(msg);
|
||||||
|
}
|
||||||
|
|
||||||
/// GET request
|
/// GET request
|
||||||
///
|
///
|
||||||
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
||||||
@@ -50,21 +183,24 @@ class ApiClient {
|
|||||||
bool requiresAuth = true,
|
bool requiresAuth = true,
|
||||||
}) async {
|
}) async {
|
||||||
// build final query params including auth if needed
|
// build final query params including auth if needed
|
||||||
final Map<String, dynamic> finalParams = {};
|
final originalUri = Uri.parse(url);
|
||||||
|
final queryParams = <String, String>{...originalUri.queryParameters};
|
||||||
|
|
||||||
if (requiresAuth) {
|
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');
|
queryParams['token'] = token;
|
||||||
|
queryParams['username'] = username;
|
||||||
}
|
}
|
||||||
finalParams['token'] = token;
|
// Guest mode: proceed without token — let backend decide
|
||||||
finalParams['username'] = username;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params != null) finalParams.addAll(params);
|
if (params != null) {
|
||||||
|
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
final uri = originalUri.replace(queryParameters: queryParams);
|
||||||
|
|
||||||
late http.Response response;
|
late http.Response response;
|
||||||
try {
|
try {
|
||||||
@@ -74,10 +210,157 @@ class ApiClient {
|
|||||||
throw Exception('Network error: $e');
|
throw Exception('Network error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _handleResponse(url, response, finalParams);
|
return _handleResponse(url, response, queryParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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': '680001',
|
||||||
|
'place': 'Thekkinkadu Maidanam',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 5,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event1/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event1a/800/500'},
|
||||||
|
{'is_primary': false, 'image': 'https://picsum.photos/seed/event1b/800/500'},
|
||||||
|
],
|
||||||
|
'important_information': 'Please carry a valid photo ID for entry.',
|
||||||
|
'venue_name': 'Maidanam Grounds',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 10.5276,
|
||||||
|
'longitude': 76.2144,
|
||||||
|
'location_name': 'Thrissur',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Entry', 'value': 'Free with registration'},
|
||||||
|
{'title': 'Parking', 'value': 'Available on-site'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 2,
|
||||||
|
'name': 'Sunset Music Festival',
|
||||||
|
'title': 'Sunset Music Festival',
|
||||||
|
'description':
|
||||||
|
'An open-air music festival featuring live performances from top artists across genres. Enjoy food stalls, art installations, and an unforgettable sunset experience.',
|
||||||
|
'start_date': '2026-04-20',
|
||||||
|
'end_date': '2026-04-20',
|
||||||
|
'start_time': '16:00',
|
||||||
|
'end_time': '23:00',
|
||||||
|
'pincode': '400001',
|
||||||
|
'place': 'Marine Drive Amphitheatre',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 1,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event2/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event2a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Marine Drive Amphitheatre',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 18.9432,
|
||||||
|
'longitude': 72.8235,
|
||||||
|
'location_name': 'Mumbai',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Age Limit', 'value': '16+'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 3,
|
||||||
|
'name': 'Creative Design Workshop',
|
||||||
|
'title': 'Hands-on Design Workshop',
|
||||||
|
'description':
|
||||||
|
'A full-day workshop on UI/UX design principles, prototyping in Figma, and building design systems. Perfect for beginners and intermediate designers.',
|
||||||
|
'start_date': '2026-05-03',
|
||||||
|
'end_date': '2026-05-03',
|
||||||
|
'start_time': '10:00',
|
||||||
|
'end_time': '17:00',
|
||||||
|
'pincode': '110001',
|
||||||
|
'place': 'Design Hub Co-working',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 2,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event3/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event3a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Design Hub',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 28.6139,
|
||||||
|
'longitude': 77.2090,
|
||||||
|
'location_name': 'New Delhi',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Bring', 'value': 'Laptop with Figma installed'},
|
||||||
|
{'title': 'Seats', 'value': '30 max'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 4,
|
||||||
|
'name': 'Marathon for a Cause',
|
||||||
|
'title': 'City Marathon 2026',
|
||||||
|
'description':
|
||||||
|
'Run for fitness, run for charity! Choose from 5K, 10K, or full marathon routes through the city. All proceeds support local education initiatives.',
|
||||||
|
'start_date': '2026-04-12',
|
||||||
|
'end_date': '2026-04-12',
|
||||||
|
'start_time': '05:30',
|
||||||
|
'end_time': '12:00',
|
||||||
|
'pincode': '600001',
|
||||||
|
'place': 'Marina Beach Road',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 4,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event4/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event4a/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Marina Beach',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 13.0500,
|
||||||
|
'longitude': 80.2824,
|
||||||
|
'location_name': 'Chennai',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Registration', 'value': 'Closes April 10'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 5,
|
||||||
|
'name': 'Art & Culture Exhibition',
|
||||||
|
'title': 'Contemporary Art Exhibition',
|
||||||
|
'description':
|
||||||
|
'Explore contemporary artworks from emerging and established artists. The exhibition features paintings, sculptures, and digital art installations.',
|
||||||
|
'start_date': '2026-05-10',
|
||||||
|
'end_date': '2026-05-15',
|
||||||
|
'start_time': '11:00',
|
||||||
|
'end_time': '20:00',
|
||||||
|
'pincode': '500001',
|
||||||
|
'place': 'Salar Jung Museum Grounds',
|
||||||
|
'is_bookable': true,
|
||||||
|
'event_type': 6,
|
||||||
|
'thumb_img': 'https://picsum.photos/seed/event5/600/400',
|
||||||
|
'images': [
|
||||||
|
{'is_primary': true, 'image': 'https://picsum.photos/seed/event5a/800/500'},
|
||||||
|
{'is_primary': false, 'image': 'https://picsum.photos/seed/event5b/800/500'},
|
||||||
|
],
|
||||||
|
'venue_name': 'Salar Jung Museum',
|
||||||
|
'event_status': 'active',
|
||||||
|
'latitude': 17.3713,
|
||||||
|
'longitude': 78.4804,
|
||||||
|
'location_name': 'Hyderabad',
|
||||||
|
'important_info': [
|
||||||
|
{'title': 'Entry Fee', 'value': '₹200'},
|
||||||
|
{'title': 'Photography', 'value': 'Allowed without flash'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Build request body and attach token + username if available
|
||||||
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
||||||
final Map<String, dynamic> finalBody = {};
|
final Map<String, dynamic> finalBody = {};
|
||||||
|
|
||||||
@@ -85,13 +368,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);
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,20 @@
|
|||||||
class ApiEndpoints {
|
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"
|
// em.eventifyplus.com / uat.eventifyplus.com DNS → K8s, broken TLS. backend.eventifyplus.com → EC2 174.129.72.160, valid cert.
|
||||||
static const String baseUrl = "https://uat.eventifyplus.com/api";
|
static const String baseUrl = "https://backend.eventifyplus.com/api";
|
||||||
|
|
||||||
|
/// Base URL for media files (images, icons uploaded via Django admin).
|
||||||
|
/// Relative paths like `/media/...` are resolved against this.
|
||||||
|
static const String mediaBaseUrl = "https://backend.eventifyplus.com";
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
static const String register = "$baseUrl/user/register/";
|
static const String register = "$baseUrl/user/register/";
|
||||||
static const String login = "$baseUrl/user/login/";
|
static const String login = "$baseUrl/user/login/";
|
||||||
static const String logout = "$baseUrl/user/logout/";
|
static const String logout = "$baseUrl/user/logout/";
|
||||||
static const String status = "$baseUrl/user/status/";
|
static const String status = "$baseUrl/user/status/";
|
||||||
|
static const String updateProfile = "$baseUrl/user/update-profile/";
|
||||||
|
static const String forgotPassword = "$baseUrl/user/forgot-password/";
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
||||||
@@ -18,9 +24,46 @@ class ApiEndpoints {
|
|||||||
static const String eventImages = "$baseUrl/events/event-images/"; // event-images
|
static const String eventImages = "$baseUrl/events/event-images/"; // event-images
|
||||||
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
||||||
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
||||||
|
static const String featuredEvents = "$baseUrl/events/featured-events/";
|
||||||
|
static const String topEvents = "$baseUrl/events/top-events/";
|
||||||
|
|
||||||
// Bookings
|
// Bookings
|
||||||
// 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";
|
||||||
|
|
||||||
|
// File upload (Node.js — routes to OneDrive or GDrive via STORAGE_BACKEND env)
|
||||||
|
static const String uploadFile = "$_nodeBase/v1/upload/file";
|
||||||
|
|
||||||
|
// Gamification / Contributor Module
|
||||||
|
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||||
|
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||||
|
static const String shopItems = "$_nodeBase/v1/shop/items";
|
||||||
|
static const String shopRedeem = "$_nodeBase/v1/shop/redeem";
|
||||||
|
static const String contributeSubmit = "$_nodeBase/v1/gamification/submit-event";
|
||||||
|
static const String gradeContribution = "$_nodeBase/v1/admin/contributions/"; // append {id}/grade/
|
||||||
|
|
||||||
|
// Bookings
|
||||||
|
static const String ticketMetaList = "$baseUrl/bookings/ticket-meta/list/";
|
||||||
|
static const String cartAdd = "$baseUrl/bookings/cart/add/";
|
||||||
|
static const String checkout = "$baseUrl/bookings/checkout/";
|
||||||
|
static const String checkIn = "$baseUrl/bookings/check-in/";
|
||||||
|
|
||||||
|
// Auth - Google OAuth
|
||||||
|
static const String googleLogin = "$baseUrl/user/google-login/";
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
static const String notificationList = "$baseUrl/notifications/list/";
|
||||||
|
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
|
||||||
|
static const String notificationCount = "$baseUrl/notifications/count";
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ class ThemeManager {
|
|||||||
|
|
||||||
/// Call during app startup to load saved preference.
|
/// Call during app startup to load saved preference.
|
||||||
static Future<void> init() async {
|
static Future<void> init() async {
|
||||||
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final isDark = prefs.getBool(_prefKey) ?? false;
|
final isDark = prefs.getBool(_prefKey) ?? false;
|
||||||
themeMode.value = isDark ? ThemeMode.dark : ThemeMode.light;
|
themeMode.value = isDark ? ThemeMode.dark : ThemeMode.light;
|
||||||
|
} catch (e) {
|
||||||
|
// If SharedPreferences fails, default to light theme
|
||||||
|
print('Error initializing theme: $e');
|
||||||
|
themeMode.value = ThemeMode.light;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set theme and persist
|
/// Set theme and persist
|
||||||
|
|||||||
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,14 +1,24 @@
|
|||||||
// 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 {
|
||||||
final ApiClient _api = ApiClient();
|
final ApiClient _api = ApiClient();
|
||||||
|
|
||||||
|
/// Google OAuth 2.0 Web Client ID from Google Cloud Console.
|
||||||
|
/// Must match the `GOOGLE_CLIENT_ID` env var set on the Django backend
|
||||||
|
/// so the server can verify the `id_token` audience.
|
||||||
|
/// Source: Google Cloud Console → APIs & Services → Credentials → Web application.
|
||||||
|
static const String _googleWebClientId =
|
||||||
|
'639347358523-mtkm3i8vssuhsun80rp2llt09eou0p8g.apps.googleusercontent.com';
|
||||||
|
|
||||||
/// LOGIN → returns UserModel
|
/// LOGIN → returns UserModel
|
||||||
Future<UserModel> login(String username, String password) async {
|
Future<UserModel> login(String username, String password) async {
|
||||||
try {
|
try {
|
||||||
@@ -33,6 +43,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 +67,24 @@ 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());
|
||||||
|
|
||||||
|
// Save profile photo from login response
|
||||||
|
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||||
|
if (rawPhoto.isNotEmpty) {
|
||||||
|
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||||
|
await prefs.setString('profileImage_$savedEmail', photoUrl);
|
||||||
|
await prefs.setString('profileImage', photoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Eventify ID
|
||||||
|
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||||
|
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||||
|
|
||||||
|
PostHogService.instance.identify(savedEmail, properties: {
|
||||||
|
'username': displayCandidate,
|
||||||
|
'login_method': 'email',
|
||||||
|
});
|
||||||
|
PostHogService.instance.capture('user_logged_in');
|
||||||
|
|
||||||
return UserModel.fromJson(res);
|
return UserModel.fromJson(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
||||||
@@ -66,15 +97,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 +126,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 +162,79 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GOOGLE OAUTH LOGIN → returns UserModel
|
||||||
|
Future<UserModel> googleLogin() async {
|
||||||
|
try {
|
||||||
|
final googleSignIn = GoogleSignIn(
|
||||||
|
scopes: const ['email', 'profile'],
|
||||||
|
serverClientId: _googleWebClientId,
|
||||||
|
);
|
||||||
|
final account = await googleSignIn.signIn();
|
||||||
|
if (account == null) throw Exception('Google sign-in cancelled');
|
||||||
|
|
||||||
|
final auth = await account.authentication;
|
||||||
|
final idToken = auth.idToken;
|
||||||
|
if (idToken == null) throw Exception('Failed to get Google ID token');
|
||||||
|
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.googleLogin,
|
||||||
|
body: {'id_token': idToken},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final token = res['token'];
|
||||||
|
if (token == null) throw Exception('Token missing from response');
|
||||||
|
|
||||||
|
final serverEmail = (res['email'] as String?) ?? account.email;
|
||||||
|
final displayName = (res['username'] as String?) ?? account.displayName ?? serverEmail;
|
||||||
|
|
||||||
|
AuthGuard.setGuest(false);
|
||||||
|
await TokenStorage.saveToken(token.toString(), serverEmail);
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString('current_email', serverEmail);
|
||||||
|
await prefs.setString('email', serverEmail);
|
||||||
|
final perKey = 'display_name_$serverEmail';
|
||||||
|
if ((prefs.getString(perKey) ?? '').isEmpty) {
|
||||||
|
await prefs.setString(perKey, displayName);
|
||||||
|
}
|
||||||
|
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||||
|
|
||||||
|
// Save profile photo from Google login response
|
||||||
|
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||||
|
if (rawPhoto.isNotEmpty) {
|
||||||
|
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||||
|
await prefs.setString('profileImage_$serverEmail', photoUrl);
|
||||||
|
await prefs.setString('profileImage', photoUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Eventify ID
|
||||||
|
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||||
|
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||||
|
|
||||||
|
PostHogService.instance.identify(serverEmail, properties: {
|
||||||
|
'username': displayName,
|
||||||
|
'login_method': 'google',
|
||||||
|
});
|
||||||
|
PostHogService.instance.capture('user_logged_in');
|
||||||
|
|
||||||
|
return UserModel.fromJson(res);
|
||||||
|
} catch (e) {
|
||||||
|
if (kDebugMode) debugPrint('AuthService.googleLogin error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FORGOT PASSWORD → backend sends reset instructions by email.
|
||||||
|
/// Frontend never leaks whether the email is registered — same UX on success and 404.
|
||||||
|
Future<void> forgotPassword(String email) async {
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.forgotPassword,
|
||||||
|
body: {'email': email},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Logout – clear auth token and current_email (keep per-account display_name entries so they persist)
|
/// 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 +246,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,19 @@ 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;
|
||||||
|
|
||||||
|
// Curation flags
|
||||||
|
final bool isFeatured;
|
||||||
|
final bool isTopEvent;
|
||||||
|
|
||||||
EventModel({
|
EventModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -82,6 +104,13 @@ 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,
|
||||||
|
this.isFeatured = false,
|
||||||
|
this.isTopEvent = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 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 +158,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 +168,13 @@ 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?,
|
||||||
|
isFeatured: j['is_featured'] == true || j['is_featured']?.toString().toLowerCase() == 'true',
|
||||||
|
isTopEvent: j['is_top_event'] == true || j['is_top_event']?.toString().toLowerCase() == 'true',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,119 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get events filtered by pincode with pagination.
|
||||||
|
/// [page] starts at 1. [pageSize] defaults to 50.
|
||||||
|
/// Returns a list of events for the requested page.
|
||||||
|
Future<List<EventModel>> getEventsByPincode(String pincode, {int page = 1, int pageSize = 50, int perType = 5, String q = ''}) async {
|
||||||
|
// Use cache for 'all' pincode queries (first page only, no active search)
|
||||||
|
if (pincode == 'all' &&
|
||||||
|
page == 1 &&
|
||||||
|
q.isEmpty &&
|
||||||
|
_cachedAllEvents != null &&
|
||||||
|
_eventsCacheTime != null &&
|
||||||
|
DateTime.now().difference(_eventsCacheTime!) < _eventsCacheTTL) {
|
||||||
|
return _cachedAllEvents!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> body = {'pincode': pincode, 'page': page, 'page_size': pageSize};
|
||||||
|
// Diverse mode: fetch a few events per type so all categories are represented
|
||||||
|
if (perType > 0 && page == 1) body['per_type'] = perType;
|
||||||
|
// Server-side search filter
|
||||||
|
if (q.isNotEmpty) body['q'] = q;
|
||||||
|
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.eventsByPincode,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
final list = <EventModel>[];
|
||||||
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
|
if (events is List) {
|
||||||
|
for (final e in events) {
|
||||||
|
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pincode == 'all' && page == 1) {
|
||||||
|
_cachedAllEvents = list;
|
||||||
|
_eventsCacheTime = DateTime.now();
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get events filtered by pincode (POST to /events/pincode-events/)
|
/// Event details — requiresAuth: false so guests can fetch full details
|
||||||
/// Use pincode='all' to fetch all events.
|
Future<EventModel> getEventDetails(int eventId) async {
|
||||||
Future<List<EventModel>> getEventsByPincode(String pincode) async {
|
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
|
||||||
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode});
|
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Related events by event_type_id (EVT-002).
|
||||||
|
/// Fetches events with the same category, silently returns [] on failure.
|
||||||
|
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
|
||||||
|
try {
|
||||||
|
final res = await _api.post(
|
||||||
|
ApiEndpoints.eventsByCategory,
|
||||||
|
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
|
||||||
|
if (results is List) {
|
||||||
|
return results
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => EventModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// silently fail — related events are non-critical
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get events by GPS coordinates using haversine distance filtering.
|
||||||
|
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
|
||||||
|
final body = {
|
||||||
|
'latitude': lat,
|
||||||
|
'longitude': lng,
|
||||||
|
'radius_km': radiusKm,
|
||||||
|
'page': 1,
|
||||||
|
'page_size': 50,
|
||||||
|
'per_type': 5,
|
||||||
|
};
|
||||||
|
final res = await _api.post(ApiEndpoints.eventsByPincode, body: body, requiresAuth: false);
|
||||||
final list = <EventModel>[];
|
final list = <EventModel>[];
|
||||||
final events = res['events'] ?? res['data'] ?? [];
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
if (events is List) {
|
if (events is List) {
|
||||||
@@ -38,30 +129,48 @@ class EventsService {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event details
|
/// Featured events for the home screen hero carousel.
|
||||||
Future<EventModel> getEventDetails(int eventId) async {
|
Future<List<EventModel>> getFeaturedEvents() async {
|
||||||
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId});
|
final res = await _api.post(ApiEndpoints.featuredEvents, requiresAuth: false);
|
||||||
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
|
if (events is List) {
|
||||||
|
return events
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => EventModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Top events for the home screen top events section.
|
||||||
|
Future<List<EventModel>> getTopEvents() async {
|
||||||
|
final res = await _api.post(ApiEndpoints.topEvents, requiresAuth: false);
|
||||||
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
|
if (events is List) {
|
||||||
|
return events
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((e) => EventModel.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
/// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
361
lib/features/gamification/models/gamification_models.dart
Normal file
361
lib/features/gamification/models/gamification_models.dart
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
// lib/features/gamification/models/gamification_models.dart
|
||||||
|
// Data models matching TechDocs v2 DB schema for the Contributor Module.
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
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 String username;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final String? district;
|
||||||
|
final String? eventifyId;
|
||||||
|
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
|
||||||
|
final int currentEp; // Liquid EP accumulated this month.
|
||||||
|
final int currentRp; // Spendable Reward Points.
|
||||||
|
final ContributorTier tier;
|
||||||
|
|
||||||
|
const UserGamificationProfile({
|
||||||
|
required this.userId,
|
||||||
|
required this.username,
|
||||||
|
this.avatarUrl,
|
||||||
|
this.district,
|
||||||
|
this.eventifyId,
|
||||||
|
required this.lifetimeEp,
|
||||||
|
required this.currentEp,
|
||||||
|
required this.currentRp,
|
||||||
|
required this.tier,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
debugPrint('Mapping UserGamificationProfile from JSON: $json');
|
||||||
|
final ep = (json['lifetime_ep'] as int?) ?? (json['points'] as int?) ?? (json['total_points'] as int?) ?? 0;
|
||||||
|
return UserGamificationProfile(
|
||||||
|
userId: (json['user_id'] ?? json['email'] ?? json['userId'] ?? '').toString(),
|
||||||
|
username: (json['username'] ?? json['name'] ?? json['full_name'] ?? json['display_name'] ?? '').toString(),
|
||||||
|
avatarUrl: json['profile_image'] as String? ?? json['avatar_url'] as String? ?? json['profile_pic'] as String?,
|
||||||
|
district: json['district'] as String? ?? json['location'] as String?,
|
||||||
|
eventifyId: (json['eventify_id'] ?? json['eventifyId'] ?? json['id'] ?? '').toString(),
|
||||||
|
lifetimeEp: ep,
|
||||||
|
currentEp: (json['current_ep'] as int?) ?? (json['monthly_points'] as int?) ?? (json['points_this_month'] as int?) ?? 0,
|
||||||
|
currentRp: (json['current_rp'] as int?) ?? (json['reward_points'] as int?) ?? (json['rp'] as int?) ?? 0,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/features/gamification/providers/gamification_provider.dart
Normal file
272
lib/features/gamification/providers/gamification_provider.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
// lib/features/gamification/providers/gamification_provider.dart
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../../core/utils/error_utils.dart';
|
||||||
|
import '../models/gamification_models.dart';
|
||||||
|
import '../services/gamification_service.dart';
|
||||||
|
import '../../events/services/events_service.dart';
|
||||||
|
import '../../events/models/event_models.dart';
|
||||||
|
|
||||||
|
class GamificationProvider extends ChangeNotifier {
|
||||||
|
final GamificationService _service = GamificationService();
|
||||||
|
|
||||||
|
// State
|
||||||
|
UserGamificationProfile? profile;
|
||||||
|
List<LeaderboardEntry> leaderboard = [];
|
||||||
|
List<ShopItem> shopItems = [];
|
||||||
|
List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
|
||||||
|
List<SubmissionModel> submissions = [];
|
||||||
|
CurrentUserStats? currentUserStats;
|
||||||
|
int totalParticipants = 0;
|
||||||
|
List<String> eventCategories = [];
|
||||||
|
|
||||||
|
// Leaderboard filters — matches web version
|
||||||
|
String leaderboardDistrict = 'Overall Kerala';
|
||||||
|
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
|
||||||
|
|
||||||
|
bool isLoading = false;
|
||||||
|
bool isLeaderboardLoading = false;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
// TTL guard — prevents redundant API calls from multiple screens
|
||||||
|
DateTime? _lastLoadTime;
|
||||||
|
static const _loadTtl = Duration(minutes: 2);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> loadAll({bool force = false}) async {
|
||||||
|
debugPrint('GamificationProvider.loadAll(force: $force) called');
|
||||||
|
// Skip if recently loaded (within 2 minutes) unless forced or profile is null
|
||||||
|
if (!force && profile != null && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
||||||
|
debugPrint('GamificationProvider.loadAll skipped due to TTL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
debugPrint('GamificationProvider: Requesting dashboard, leaderboard, etc...');
|
||||||
|
final results = await Future.wait([
|
||||||
|
_service.getDashboard().catchError((e) {
|
||||||
|
debugPrint('Dashboard error: $e');
|
||||||
|
return const DashboardResponse(
|
||||||
|
profile: UserGamificationProfile(
|
||||||
|
userId: '',
|
||||||
|
username: '',
|
||||||
|
lifetimeEp: 0,
|
||||||
|
currentEp: 0,
|
||||||
|
currentRp: 0,
|
||||||
|
tier: ContributorTier.BRONZE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod).catchError((e) {
|
||||||
|
debugPrint('Leaderboard error: $e');
|
||||||
|
return const LeaderboardResponse(entries: []);
|
||||||
|
}),
|
||||||
|
_service.getShopItems().catchError((e) {
|
||||||
|
debugPrint('Shop error: $e');
|
||||||
|
return <ShopItem>[];
|
||||||
|
}),
|
||||||
|
_service.getAchievements().catchError((e) {
|
||||||
|
debugPrint('Achievements error: $e');
|
||||||
|
return <AchievementBadge>[];
|
||||||
|
}),
|
||||||
|
EventsService().getEventTypes().catchError((e) {
|
||||||
|
debugPrint('EventTypes error: $e');
|
||||||
|
return <EventTypeModel>[];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final dashboard = results[0] as DashboardResponse;
|
||||||
|
profile = dashboard.profile;
|
||||||
|
submissions = dashboard.submissions;
|
||||||
|
|
||||||
|
final lbResponse = results[1] as LeaderboardResponse;
|
||||||
|
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
|
||||||
|
currentUserStats = lbResponse.currentUser;
|
||||||
|
totalParticipants = lbResponse.totalParticipants;
|
||||||
|
|
||||||
|
shopItems = results[2] as List<ShopItem>;
|
||||||
|
|
||||||
|
// Prefer achievements from dashboard API; fall back to fetched or existing defaults
|
||||||
|
final dashAchievements = dashboard.achievements;
|
||||||
|
final fetchedAchievements = results[3] as List<AchievementBadge>;
|
||||||
|
|
||||||
|
if (dashAchievements.isNotEmpty) {
|
||||||
|
achievements = dashAchievements;
|
||||||
|
} else if (fetchedAchievements.isNotEmpty) {
|
||||||
|
achievements = fetchedAchievements;
|
||||||
|
}
|
||||||
|
|
||||||
|
final eventTypes = results[4] as List<EventTypeModel>;
|
||||||
|
if (eventTypes.isNotEmpty) {
|
||||||
|
eventCategories = eventTypes.map((e) => e.name).toList();
|
||||||
|
}
|
||||||
|
// Otherwise, keep current defaults
|
||||||
|
|
||||||
|
_lastLoadTime = DateTime.now();
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load leaderboard independently (decoupled from loadAll)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> loadLeaderboard() async {
|
||||||
|
isLeaderboardLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await _service.getLeaderboard(
|
||||||
|
district: leaderboardDistrict,
|
||||||
|
timePeriod: leaderboardTimePeriod,
|
||||||
|
);
|
||||||
|
leaderboard = response.entries;
|
||||||
|
currentUserStats = response.currentUser;
|
||||||
|
totalParticipants = response.totalParticipants;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
isLeaderboardLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change district filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setDistrict(String district) async {
|
||||||
|
if (leaderboardDistrict == district) return;
|
||||||
|
leaderboardDistrict = district;
|
||||||
|
isLeaderboardLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||||
|
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
|
||||||
|
currentUserStats = response.currentUser;
|
||||||
|
totalParticipants = response.totalParticipants;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
isLeaderboardLoading = false;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change time period filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setTimePeriod(String period) async {
|
||||||
|
if (leaderboardTimePeriod == period) return;
|
||||||
|
leaderboardTimePeriod = period;
|
||||||
|
isLeaderboardLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
|
||||||
|
leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
|
||||||
|
currentUserStats = response.currentUser;
|
||||||
|
totalParticipants = response.totalParticipants;
|
||||||
|
} catch (e) {
|
||||||
|
error = userFriendlyError(e);
|
||||||
|
} finally {
|
||||||
|
isLeaderboardLoading = false;
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
username: profile!.username,
|
||||||
|
avatarUrl: profile!.avatarUrl,
|
||||||
|
district: profile!.district,
|
||||||
|
eventifyId: profile!.eventifyId,
|
||||||
|
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,
|
||||||
|
username: profile!.username,
|
||||||
|
avatarUrl: profile!.avatarUrl,
|
||||||
|
district: profile!.district,
|
||||||
|
eventifyId: profile!.eventifyId,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: Filter by district and re-rank results locally.
|
||||||
|
// This is a fallback in case the backend returns a global list for a district-specific query.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
List<LeaderboardEntry> _filterAndReRank(List<LeaderboardEntry> entries, String district, String period) {
|
||||||
|
if (entries.isEmpty) return [];
|
||||||
|
|
||||||
|
List<LeaderboardEntry> result = entries;
|
||||||
|
if (district != 'Overall Kerala') {
|
||||||
|
// Case-insensitive filtering to be robust
|
||||||
|
result = entries.where((e) => e.district?.toLowerCase() == district.toLowerCase()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort based on period
|
||||||
|
if (period == 'this_month') {
|
||||||
|
result.sort((a, b) => b.monthlyPoints.compareTo(a.monthlyPoints));
|
||||||
|
} else {
|
||||||
|
result.sort((a, b) => b.lifetimeEp.compareTo(a.lifetimeEp));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign new ranks based on local sort order
|
||||||
|
return List.generate(result.length, (i) {
|
||||||
|
final e = result[i];
|
||||||
|
return LeaderboardEntry(
|
||||||
|
rank: i + 1,
|
||||||
|
username: e.username,
|
||||||
|
avatarUrl: e.avatarUrl,
|
||||||
|
lifetimeEp: e.lifetimeEp,
|
||||||
|
monthlyPoints: e.monthlyPoints,
|
||||||
|
tier: e.tier,
|
||||||
|
eventsCount: e.eventsCount,
|
||||||
|
isCurrentUser: e.isCurrentUser,
|
||||||
|
district: e.district,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
208
lib/features/gamification/services/gamification_service.dart
Normal file
208
lib/features/gamification/services/gamification_service.dart
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// 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=${Uri.encodeComponent(email)}';
|
||||||
|
final res = await _api.get(url, requiresAuth: false);
|
||||||
|
|
||||||
|
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||||
|
final rawSubs = res['submissions'] as List? ?? [];
|
||||||
|
final rawAchievements = res['achievements'] as List? ?? [];
|
||||||
|
|
||||||
|
final submissions = rawSubs
|
||||||
|
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final achievements = rawAchievements
|
||||||
|
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
profile: UserGamificationProfile.fromJson(profileJson),
|
||||||
|
submissions: submissions,
|
||||||
|
achievements: achievements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public contributor profile (any user by userId / email)
|
||||||
|
// GET /v1/gamification/dashboard?user_id={userId}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<DashboardResponse> getDashboardForUser(String userId) async {
|
||||||
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(userId)}';
|
||||||
|
final res = await _api.get(url, requiresAuth: false);
|
||||||
|
|
||||||
|
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||||
|
final rawSubs = res['submissions'] as List? ?? [];
|
||||||
|
final rawAchievements = res['achievements'] as List? ?? [];
|
||||||
|
|
||||||
|
final submissions = rawSubs
|
||||||
|
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final achievements = rawAchievements
|
||||||
|
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
profile: UserGamificationProfile.fromJson(profileJson),
|
||||||
|
submissions: submissions,
|
||||||
|
achievements: achievements,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience — returns just the profile (backward-compatible with provider).
|
||||||
|
Future<UserGamificationProfile> getProfile() async {
|
||||||
|
final dashboard = await getDashboard();
|
||||||
|
return dashboard.profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaderboard
|
||||||
|
// 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
|
||||||
|
// 1. Upload each image to /v1/upload/file → get back { url, fileId, ... }
|
||||||
|
// 2. POST /v1/gamification/submit-event with `media` (uploaded objects)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
|
final email = await _getUserEmail();
|
||||||
|
|
||||||
|
// Upload images if present
|
||||||
|
final rawPaths = (data['images'] as List?)?.cast<String>() ?? [];
|
||||||
|
final List<Map<String, dynamic>> uploadedMedia = [];
|
||||||
|
|
||||||
|
for (final path in rawPaths) {
|
||||||
|
final result = await _api.uploadFile(ApiEndpoints.uploadFile, path);
|
||||||
|
uploadedMedia.add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build submission body — use `media` (server canonical field)
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'user_id': email,
|
||||||
|
...Map.from(data)..remove('images'),
|
||||||
|
if (uploadedMedia.isNotEmpty) 'media': uploadedMedia,
|
||||||
|
};
|
||||||
|
|
||||||
|
await _api.post(
|
||||||
|
ApiEndpoints.contributeSubmit,
|
||||||
|
body: body,
|
||||||
|
requiresAuth: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Achievements — sourced from dashboard API `achievements` array.
|
||||||
|
// Falls back to default badges if API doesn't return achievements yet.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<AchievementBadge>> getAchievements() async {
|
||||||
|
try {
|
||||||
|
final dashboard = await getDashboard();
|
||||||
|
if (dashboard.achievements.isNotEmpty) return dashboard.achievements;
|
||||||
|
} catch (_) {
|
||||||
|
// Fall through to defaults
|
||||||
|
}
|
||||||
|
return defaultBadges;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const defaultBadges = [
|
||||||
|
AchievementBadge(id: 'badge-01', title: 'Newcomer', description: 'First Event Posted', iconName: 'star', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-02', title: 'Contributor', description: '10th Event Posted within a month', iconName: 'crown', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-03', title: 'On Fire!', description: '3 Day Streak of logging in', iconName: 'fire', isUnlocked: false, progress: 0.67),
|
||||||
|
AchievementBadge(id: 'badge-04', title: 'Verified', description: 'Identity Verified successfully', iconName: 'verified', isUnlocked: true, progress: 1.0),
|
||||||
|
AchievementBadge(id: 'badge-05', title: 'Quality', description: '5 Star Event Rating received', iconName: 'star', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-06', title: 'Community', description: 'Referred 5 Friends to the platform', iconName: 'community', isUnlocked: false, progress: 0.4),
|
||||||
|
AchievementBadge(id: 'badge-07', title: 'Expert', description: 'Level 10 Reached in 3 months', iconName: 'expert', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-08', title: 'Precision', description: '100% Data Accuracy on all events', iconName: 'precision', isUnlocked: false, progress: 0.0),
|
||||||
|
];
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
241
lib/features/reviews/widgets/review_card.dart
Normal file
241
lib/features/reviews/widgets/review_card.dart
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
// lib/features/reviews/widgets/review_card.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../models/review_models.dart';
|
||||||
|
import 'star_display.dart';
|
||||||
|
|
||||||
|
class ReviewCard extends StatefulWidget {
|
||||||
|
final ReviewModel review;
|
||||||
|
final String? currentUsername;
|
||||||
|
final Future<int> Function(int reviewId) onHelpful;
|
||||||
|
final Future<void> Function(int reviewId) onFlag;
|
||||||
|
|
||||||
|
const ReviewCard({
|
||||||
|
Key? key,
|
||||||
|
required this.review,
|
||||||
|
this.currentUsername,
|
||||||
|
required this.onHelpful,
|
||||||
|
required this.onFlag,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReviewCard> createState() => _ReviewCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReviewCardState extends State<ReviewCard> {
|
||||||
|
late ReviewModel _review;
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_review = widget.review;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant ReviewCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.review.id != widget.review.id) _review = widget.review;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isOwnReview =>
|
||||||
|
widget.currentUsername != null &&
|
||||||
|
widget.currentUsername!.isNotEmpty &&
|
||||||
|
_review.username == widget.currentUsername;
|
||||||
|
|
||||||
|
String _timeAgo(DateTime dt) {
|
||||||
|
final diff = DateTime.now().difference(dt);
|
||||||
|
if (diff.inSeconds < 60) return 'Just now';
|
||||||
|
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||||
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||||
|
if (diff.inDays < 30) return '${diff.inDays}d ago';
|
||||||
|
if (diff.inDays < 365) return '${(diff.inDays / 30).floor()}mo ago';
|
||||||
|
return '${(diff.inDays / 365).floor()}y ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _avatarColor(String name) {
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF0F45CF), const Color(0xFF7C3AED), const Color(0xFFEC4899),
|
||||||
|
const Color(0xFFF59E0B), const Color(0xFF10B981), const Color(0xFFEF4444),
|
||||||
|
const Color(0xFF06B6D4), const Color(0xFF8B5CF6),
|
||||||
|
];
|
||||||
|
return colors[name.hashCode.abs() % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleHelpful() async {
|
||||||
|
if (_isOwnReview) return;
|
||||||
|
try {
|
||||||
|
final newCount = await widget.onHelpful(_review.id);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_review = _review.copyWith(
|
||||||
|
helpfulCount: newCount,
|
||||||
|
userMarkedHelpful: !_review.userMarkedHelpful,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleFlag() async {
|
||||||
|
if (_isOwnReview || _review.userFlagged) return;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Report Review'),
|
||||||
|
content: const Text('Are you sure you want to report this review as inappropriate?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Report', style: TextStyle(color: Color(0xFFEF4444))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
try {
|
||||||
|
await widget.onFlag(_review.id);
|
||||||
|
if (mounted) setState(() => _review = _review.copyWith(userFlagged: true));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final comment = _review.comment ?? '';
|
||||||
|
final isLong = comment.length > 150;
|
||||||
|
final displayComment = isLong && !_expanded ? '${comment.substring(0, 150)}...' : comment;
|
||||||
|
final initial = _review.username.isNotEmpty ? _review.username[0].toUpperCase() : '?';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFF1F5F9)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: 'https://api.dicebear.com/9.x/notionists/svg?seed=${Uri.encodeComponent(_review.username)}',
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
memCacheWidth: 72,
|
||||||
|
memCacheHeight: 72,
|
||||||
|
maxWidthDiskCache: 144,
|
||||||
|
maxHeightDiskCache: 144,
|
||||||
|
placeholder: (_, __) => CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: _avatarColor(_review.username),
|
||||||
|
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => CircleAvatar(
|
||||||
|
radius: 18,
|
||||||
|
backgroundColor: _avatarColor(_review.username),
|
||||||
|
child: Text(initial, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
_review.username.split('@').first,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: Color(0xFF1E293B)),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_review.isVerified) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.verified, size: 14, color: Color(0xFF22C55E)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(_timeAgo(_review.createdAt), style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StarDisplay(rating: _review.rating.toDouble(), size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Comment
|
||||||
|
if (comment.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(displayComment, style: const TextStyle(fontSize: 13, color: Color(0xFF334155), height: 1.4)),
|
||||||
|
if (isLong)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
_expanded ? 'Show less' : 'Read more',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF0F45CF), fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Footer actions
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Helpful button
|
||||||
|
InkWell(
|
||||||
|
onTap: _isOwnReview ? null : _handleHelpful,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_review.userMarkedHelpful ? Icons.thumb_up : Icons.thumb_up_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
if (_review.helpfulCount > 0) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${_review.helpfulCount}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: _review.userMarkedHelpful ? const Color(0xFF0F45CF) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Flag button
|
||||||
|
if (!_isOwnReview)
|
||||||
|
InkWell(
|
||||||
|
onTap: _review.userFlagged ? null : _handleFlag,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Icon(
|
||||||
|
_review.userFlagged ? Icons.flag : Icons.flag_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: _review.userFlagged ? const Color(0xFFEF4444) : const Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
547
lib/features/share/share_card_generator.dart
Normal file
547
lib/features/share/share_card_generator.dart
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
// lib/features/share/share_card_generator.dart
|
||||||
|
//
|
||||||
|
// Pure dart:ui Canvas generator — produces a 1080×1920 PNG story card
|
||||||
|
// without embedding any widget in the tree. Drop-in replacement for
|
||||||
|
// the old RepaintBoundary + ShareRankCard approach.
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
// ── Tier theme data (ported from share_rank_card.dart) ─────────────────────
|
||||||
|
|
||||||
|
const _tierThemes = <String, _TierTheme>{
|
||||||
|
'Bronze': _TierTheme(
|
||||||
|
stops: [Color(0xFF92400E), Color(0xFFB45309), Color(0xFFD97706)],
|
||||||
|
ring: Color(0xFFD97706),
|
||||||
|
),
|
||||||
|
'Silver': _TierTheme(
|
||||||
|
stops: [Color(0xFF334155), Color(0xFF475569), Color(0xFF64748B)],
|
||||||
|
ring: Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
'Gold': _TierTheme(
|
||||||
|
stops: [Color(0xFF78350F), Color(0xFF92400E), Color(0xFFB45309)],
|
||||||
|
ring: Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
'Platinum': _TierTheme(
|
||||||
|
stops: [Color(0xFF4C1D95), Color(0xFF5B21B6), Color(0xFF7C3AED)],
|
||||||
|
ring: Color(0xFF8B5CF6),
|
||||||
|
),
|
||||||
|
'Diamond': _TierTheme(
|
||||||
|
stops: [Color(0xFF312E81), Color(0xFF4338CA), Color(0xFF6366F1)],
|
||||||
|
ring: Color(0xFF6366F1),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
class _TierTheme {
|
||||||
|
final List<Color> stops;
|
||||||
|
final Color ring;
|
||||||
|
const _TierTheme({required this.stops, required this.ring});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generates a 1080×1920 PNG share card entirely via dart:ui Canvas.
|
||||||
|
/// Returns raw PNG bytes ready for [Share.shareXFiles].
|
||||||
|
Future<Uint8List> generateShareCardPng({
|
||||||
|
required String username,
|
||||||
|
required String tier,
|
||||||
|
required int lifetimeEp,
|
||||||
|
required int currentEp,
|
||||||
|
required int rewardPoints,
|
||||||
|
String? eventifyId,
|
||||||
|
String? district,
|
||||||
|
String? imageUrl,
|
||||||
|
}) async {
|
||||||
|
const double w = 1080;
|
||||||
|
const double h = 1920;
|
||||||
|
|
||||||
|
// Resolve tier theme
|
||||||
|
final capTier = tier.isEmpty
|
||||||
|
? 'Bronze'
|
||||||
|
: (tier[0].toUpperCase() + tier.substring(1).toLowerCase());
|
||||||
|
final theme = _tierThemes[capTier] ?? _tierThemes['Bronze']!;
|
||||||
|
|
||||||
|
// Load avatar (if available)
|
||||||
|
ui.Image? avatarImage;
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
avatarImage = await _loadNetworkImage(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw ────────────────────────────────────────────────────────────────
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, w, h));
|
||||||
|
|
||||||
|
// Layout constants (all at 3x of the original 360×640 widget)
|
||||||
|
const headerH = 894.0; // flex 45 of (1920-132)
|
||||||
|
const panelH = 894.0; // flex 45
|
||||||
|
const footerH = 132.0; // 44 * 3
|
||||||
|
const panelTop = headerH;
|
||||||
|
const footerTop = panelTop + panelH;
|
||||||
|
const cornerR = 84.0; // 28 * 3
|
||||||
|
const pad = 60.0; // 20 * 3
|
||||||
|
|
||||||
|
// 1. Gradient header background
|
||||||
|
final gradientPaint = Paint()
|
||||||
|
..shader = ui.Gradient.linear(
|
||||||
|
const Offset(w / 2, 0),
|
||||||
|
Offset(w / 2, headerH),
|
||||||
|
theme.stops,
|
||||||
|
[0.0, 0.5, 1.0],
|
||||||
|
);
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(0, 0, w, headerH), gradientPaint);
|
||||||
|
|
||||||
|
// 2. White panel (rounded top corners)
|
||||||
|
final panelRRect = RRect.fromRectAndCorners(
|
||||||
|
const Rect.fromLTWH(0, panelTop, w, panelH),
|
||||||
|
topLeft: const Radius.circular(cornerR),
|
||||||
|
topRight: const Radius.circular(cornerR),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(panelRRect, Paint()..color = Colors.white);
|
||||||
|
|
||||||
|
// 3. Footer
|
||||||
|
canvas.drawRect(
|
||||||
|
const Rect.fromLTWH(0, footerTop, w, footerH),
|
||||||
|
Paint()..color = const Color(0xFF0F45CF),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Header content ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
const double avatarSize = 228; // 76 * 3
|
||||||
|
const double ringGap = 9; // 3 * 3
|
||||||
|
const double ringWidth = 15; // 5 * 3
|
||||||
|
const double totalSize = avatarSize + (ringGap + ringWidth) * 2;
|
||||||
|
const double avatarCenterY = 340;
|
||||||
|
const avatarCenter = Offset(w / 2, avatarCenterY);
|
||||||
|
|
||||||
|
// Draw ring
|
||||||
|
final ringPaint = Paint()
|
||||||
|
..color = theme.ring
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = ringWidth;
|
||||||
|
canvas.drawCircle(avatarCenter, totalSize / 2 - ringWidth / 2, ringPaint);
|
||||||
|
|
||||||
|
// Draw avatar image or initials
|
||||||
|
const double avatarRadius = avatarSize / 2;
|
||||||
|
if (avatarImage != null) {
|
||||||
|
canvas.save();
|
||||||
|
final clipPath = Path()
|
||||||
|
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
|
||||||
|
canvas.clipPath(clipPath);
|
||||||
|
final src = Rect.fromLTWH(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
avatarImage.width.toDouble(),
|
||||||
|
avatarImage.height.toDouble(),
|
||||||
|
);
|
||||||
|
final dst = Rect.fromCircle(center: avatarCenter, radius: avatarRadius);
|
||||||
|
canvas.drawImageRect(avatarImage, src, dst, Paint());
|
||||||
|
canvas.restore();
|
||||||
|
} else {
|
||||||
|
// Initials fallback
|
||||||
|
canvas.save();
|
||||||
|
final clipPath = Path()
|
||||||
|
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
|
||||||
|
canvas.clipPath(clipPath);
|
||||||
|
canvas.drawCircle(
|
||||||
|
avatarCenter,
|
||||||
|
avatarRadius,
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: 0.25),
|
||||||
|
);
|
||||||
|
final initials = username.length >= 2
|
||||||
|
? username.substring(0, 2).toUpperCase()
|
||||||
|
: username.toUpperCase();
|
||||||
|
final tp = _layoutText(
|
||||||
|
initials,
|
||||||
|
fontSize: avatarSize * 0.32,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
tp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
avatarCenter.dx - tp.width / 2,
|
||||||
|
avatarCenter.dy - tp.height / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username (below avatar)
|
||||||
|
final displayName =
|
||||||
|
username.length > 20 ? username.substring(0, 20) : username;
|
||||||
|
final userTp = _layoutText(
|
||||||
|
displayName,
|
||||||
|
fontSize: 66, // 22 * 3
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: -0.9,
|
||||||
|
maxWidth: w - pad * 2,
|
||||||
|
);
|
||||||
|
userTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset((w - userTp.width) / 2, avatarCenterY + totalSize / 2 + 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tier badge pill
|
||||||
|
final tierLabel = tier.isEmpty ? 'CONTRIBUTOR' : tier.toUpperCase();
|
||||||
|
final badgeText = '\u2605 $tierLabel EXPLORER';
|
||||||
|
final badgeTp = _layoutText(
|
||||||
|
badgeText,
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
);
|
||||||
|
const badgePadH = 42.0; // 14 * 3
|
||||||
|
const badgePadV = 15.0; // 5 * 3
|
||||||
|
final badgeW = badgeTp.width + badgePadH * 2;
|
||||||
|
final badgeH = badgeTp.height + badgePadV * 2;
|
||||||
|
final badgeY =
|
||||||
|
avatarCenterY + totalSize / 2 + 30 + userTp.height + 18;
|
||||||
|
final badgeRRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, badgeY + badgeH / 2),
|
||||||
|
width: badgeW,
|
||||||
|
height: badgeH,
|
||||||
|
),
|
||||||
|
const Radius.circular(60),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
badgeRRect,
|
||||||
|
Paint()..color = Colors.black.withValues(alpha: 0.35),
|
||||||
|
);
|
||||||
|
badgeTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset((w - badgeTp.width) / 2, badgeY + badgePadV),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── White panel content ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
double cy = panelTop + pad; // running y cursor inside panel
|
||||||
|
|
||||||
|
// Lifetime EP hero card
|
||||||
|
const heroCardH = 195.0; // approximate height for label + number + subtitle
|
||||||
|
final heroRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(pad, cy, w - pad * 2, heroCardH),
|
||||||
|
const Radius.circular(42), // 14 * 3
|
||||||
|
);
|
||||||
|
final heroBgPaint = Paint()
|
||||||
|
..shader = ui.Gradient.linear(
|
||||||
|
Offset(pad, cy),
|
||||||
|
Offset(w - pad, cy),
|
||||||
|
[
|
||||||
|
theme.stops.first.withValues(alpha: 0.12),
|
||||||
|
theme.stops.last.withValues(alpha: 0.06),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
canvas.drawRRect(heroRect, heroBgPaint);
|
||||||
|
|
||||||
|
// "LIFETIME EP ⚡"
|
||||||
|
const heroInnerPad = 48.0; // 16 * 3
|
||||||
|
const heroInnerPadV = 42.0; // 14 * 3
|
||||||
|
final labelTp = _layoutText(
|
||||||
|
'LIFETIME EP \u26A1',
|
||||||
|
fontSize: 30, // 10 * 3
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
);
|
||||||
|
labelTp.paint(canvas, Offset(pad + heroInnerPad, cy + heroInnerPadV));
|
||||||
|
|
||||||
|
// Big EP number
|
||||||
|
final bigNumTp = _layoutText(
|
||||||
|
formatEp(lifetimeEp),
|
||||||
|
fontSize: 108, // 36 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: theme.stops.first,
|
||||||
|
);
|
||||||
|
bigNumTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(pad + heroInnerPad, cy + heroInnerPadV + labelTp.height + 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Eventify Points earned"
|
||||||
|
final subTp = _layoutText(
|
||||||
|
'Eventify Points earned',
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
color: const Color(0xFF94A3B8),
|
||||||
|
);
|
||||||
|
subTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
pad + heroInnerPad,
|
||||||
|
cy + heroInnerPadV + labelTp.height + 12 + bigNumTp.height + 3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy += heroCardH + 30; // 10 * 3 gap
|
||||||
|
|
||||||
|
// ── Liquid EP + Reward Points side-by-side pills ──────────────────────
|
||||||
|
const pillGap = 24.0; // 8 * 3
|
||||||
|
final pillW = (w - pad * 2 - pillGap) / 2;
|
||||||
|
const pillH = 120.0;
|
||||||
|
|
||||||
|
// Left pill — Liquid EP
|
||||||
|
_drawStatPill(
|
||||||
|
canvas,
|
||||||
|
x: pad,
|
||||||
|
y: cy,
|
||||||
|
width: pillW,
|
||||||
|
height: pillH,
|
||||||
|
emoji: '\u26A1',
|
||||||
|
label: 'LIQUID EP',
|
||||||
|
value: formatEp(currentEp),
|
||||||
|
bgColor: const Color(0xFFEFF6FF),
|
||||||
|
textColor: const Color(0xFF1D4ED8),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Right pill — Reward Points
|
||||||
|
_drawStatPill(
|
||||||
|
canvas,
|
||||||
|
x: pad + pillW + pillGap,
|
||||||
|
y: cy,
|
||||||
|
width: pillW,
|
||||||
|
height: pillH,
|
||||||
|
emoji: '\uD83C\uDFC6',
|
||||||
|
label: 'REWARD POINTS',
|
||||||
|
value: formatEp(rewardPoints),
|
||||||
|
bgColor: const Color(0xFFFFFBEB),
|
||||||
|
textColor: const Color(0xFF92400E),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy += pillH + 36; // 12 * 3
|
||||||
|
|
||||||
|
// ── Dashed divider ────────────────────────────────────────────────────
|
||||||
|
final dashPaint = Paint()
|
||||||
|
..color = const Color(0xFFE2E8F0)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
const dashW = 15.0;
|
||||||
|
const dashGap = 15.0;
|
||||||
|
double dx = pad;
|
||||||
|
while (dx < w - pad) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(dx, cy),
|
||||||
|
Offset((dx + dashW).clamp(0, w - pad), cy),
|
||||||
|
dashPaint,
|
||||||
|
);
|
||||||
|
dx += dashW + dashGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
cy += 30; // 10 * 3
|
||||||
|
|
||||||
|
// ── CTA text ──────────────────────────────────────────────────────────
|
||||||
|
final ctaTp = _layoutText(
|
||||||
|
'Join me on Eventify Plus!',
|
||||||
|
fontSize: 42, // 14 * 3
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
);
|
||||||
|
ctaTp.paint(canvas, Offset((w - ctaTp.width) / 2, cy));
|
||||||
|
cy += ctaTp.height + 6;
|
||||||
|
|
||||||
|
final ctaSubTp = _layoutText(
|
||||||
|
'Discover events. Earn rewards.',
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
ctaSubTp.paint(canvas, Offset((w - ctaSubTp.width) / 2, cy));
|
||||||
|
cy += ctaSubTp.height;
|
||||||
|
|
||||||
|
// ── Optional eventifyId pill ──────────────────────────────────────────
|
||||||
|
if (eventifyId != null && eventifyId.isNotEmpty) {
|
||||||
|
cy += 30;
|
||||||
|
final idTp = _layoutText(
|
||||||
|
eventifyId,
|
||||||
|
fontSize: 33,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF1D4ED8),
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
);
|
||||||
|
final idPillW = idTp.width + 72; // 12*3 * 2
|
||||||
|
final idPillH = idTp.height + 24; // 4*3 * 2
|
||||||
|
final idRRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, cy + idPillH / 2),
|
||||||
|
width: idPillW,
|
||||||
|
height: idPillH,
|
||||||
|
),
|
||||||
|
const Radius.circular(36),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(idRRect, Paint()..color = const Color(0xFFEFF6FF));
|
||||||
|
idTp.paint(canvas, Offset((w - idTp.width) / 2, cy + 12));
|
||||||
|
cy += idPillH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Optional district ─────────────────────────────────────────────────
|
||||||
|
if (district != null && district.isNotEmpty) {
|
||||||
|
cy += 18; // 6 * 3
|
||||||
|
final distTp = _layoutText(
|
||||||
|
'\uD83D\uDCCD $district',
|
||||||
|
fontSize: 33,
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
distTp.paint(canvas, Offset((w - distTp.width) / 2, cy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer content ────────────────────────────────────────────────────
|
||||||
|
final boltTp = _layoutText(
|
||||||
|
'\u26A1',
|
||||||
|
fontSize: 42,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
|
||||||
|
final brandTp = _layoutText(
|
||||||
|
'E V E N T I F Y',
|
||||||
|
fontSize: 39, // 13 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 9,
|
||||||
|
);
|
||||||
|
|
||||||
|
final urlTp = _layoutText(
|
||||||
|
'eventifyplus.com',
|
||||||
|
fontSize: 30, // 10 * 3
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: const Color(0xFF93C5FD),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Center the row: bolt + 18px + brand + 36px + url
|
||||||
|
const gap1 = 18.0;
|
||||||
|
const gap2 = 36.0;
|
||||||
|
final totalRowW =
|
||||||
|
boltTp.width + gap1 + brandTp.width + gap2 + urlTp.width;
|
||||||
|
final rowX = (w - totalRowW) / 2;
|
||||||
|
final footerCenterY = footerTop + footerH / 2;
|
||||||
|
|
||||||
|
boltTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(rowX, footerCenterY - boltTp.height / 2),
|
||||||
|
);
|
||||||
|
brandTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(rowX + boltTp.width + gap1, footerCenterY - brandTp.height / 2),
|
||||||
|
);
|
||||||
|
urlTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
rowX + boltTp.width + gap1 + brandTp.width + gap2,
|
||||||
|
footerCenterY - urlTp.height / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Finalize ──────────────────────────────────────────────────────────
|
||||||
|
avatarImage?.dispose();
|
||||||
|
final picture = recorder.endRecording();
|
||||||
|
final image = await picture.toImage(w.toInt(), h.toInt());
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
if (byteData == null) {
|
||||||
|
throw StateError('Failed to encode share card to PNG');
|
||||||
|
}
|
||||||
|
return byteData.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Loads a network image as a [ui.Image] for Canvas drawing.
|
||||||
|
Future<ui.Image?> _loadNetworkImage(String url) async {
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5));
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
final codec = await ui.instantiateImageCodec(response.bodyBytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
return frame.image;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Share card avatar load failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates and lays out a [TextPainter] for Canvas drawing.
|
||||||
|
TextPainter _layoutText(
|
||||||
|
String text, {
|
||||||
|
required double fontSize,
|
||||||
|
FontWeight fontWeight = FontWeight.w400,
|
||||||
|
Color color = Colors.black,
|
||||||
|
double letterSpacing = 0,
|
||||||
|
String fontFamily = 'Gilroy',
|
||||||
|
double maxWidth = 1080,
|
||||||
|
}) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: color,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
)..layout(maxWidth: maxWidth);
|
||||||
|
return tp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a stat pill (e.g. Liquid EP, Reward Points).
|
||||||
|
void _drawStatPill(
|
||||||
|
Canvas canvas, {
|
||||||
|
required double x,
|
||||||
|
required double y,
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
required String emoji,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color bgColor,
|
||||||
|
required Color textColor,
|
||||||
|
}) {
|
||||||
|
const r = 36.0;
|
||||||
|
const pad = 36.0;
|
||||||
|
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, width, height), const Radius.circular(r)),
|
||||||
|
Paint()..color = bgColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
final labelTp = _layoutText(
|
||||||
|
'$emoji $label',
|
||||||
|
fontSize: 27, // 9 * 3
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 0.9,
|
||||||
|
maxWidth: width - pad * 2,
|
||||||
|
);
|
||||||
|
labelTp.paint(canvas, Offset(x + pad, y + pad * 0.6));
|
||||||
|
|
||||||
|
final valTp = _layoutText(
|
||||||
|
value,
|
||||||
|
fontSize: 60, // 20 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: textColor,
|
||||||
|
maxWidth: width - pad * 2,
|
||||||
|
);
|
||||||
|
valTp.paint(canvas, Offset(x + pad, y + pad * 0.6 + labelTp.height + 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a number with commas (e.g. 1234 → "1,234", 1234567 → "1.2M").
|
||||||
|
String formatEp(int n) {
|
||||||
|
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
if (n >= 1000) {
|
||||||
|
final s = n.toString();
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
if (i > 0 && (s.length - i) % 3 == 0) buf.write(',');
|
||||||
|
buf.write(s[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +51,12 @@ class MyApp extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static const String _fontFamily = 'Gilroy';
|
||||||
|
|
||||||
ThemeData _lightTheme() {
|
ThemeData _lightTheme() {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
|
fontFamily: _fontFamily,
|
||||||
primarySwatch: primarySwatch,
|
primarySwatch: primarySwatch,
|
||||||
scaffoldBackgroundColor: const Color(0xFFF7F5FB),
|
scaffoldBackgroundColor: const Color(0xFFF7F5FB),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
@@ -61,9 +76,9 @@ class MyApp extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ThemeData _darkTheme() {
|
ThemeData _darkTheme() {
|
||||||
// Basic dark theme based on your sample — tweak colors as desired
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
|
fontFamily: _fontFamily,
|
||||||
primarySwatch: primarySwatch,
|
primarySwatch: primarySwatch,
|
||||||
scaffoldBackgroundColor: const Color(0xFF0B1220),
|
scaffoldBackgroundColor: const Color(0xFF0B1220),
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
@@ -75,14 +90,21 @@ class MyApp extends StatelessWidget {
|
|||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0B63D6)),
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0B63D6)),
|
||||||
),
|
),
|
||||||
cardColor: const Color(0xFF0E1620),
|
cardColor: const Color(0xFF0E1620),
|
||||||
textTheme: ThemeData.dark().textTheme,
|
textTheme: ThemeData.dark().textTheme.apply(fontFamily: _fontFamily),
|
||||||
useMaterial3: false,
|
useMaterial3: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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(
|
||||||
@@ -94,6 +116,7 @@ class MyApp extends StatelessWidget {
|
|||||||
home: const StartupScreen(),
|
home: const StartupScreen(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,9 +138,17 @@ class _StartupScreenState extends State<StartupScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLoginState() async {
|
Future<void> _loadLoginState() async {
|
||||||
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final hasEmail = prefs.getString('email') != null && prefs.getString('email')!.isNotEmpty;
|
final hasEmail = prefs.getString('email') != null && prefs.getString('email')!.isNotEmpty;
|
||||||
setState(() => _loggedIn = hasEmail);
|
setState(() => _loggedIn = hasEmail);
|
||||||
|
} catch (e) {
|
||||||
|
// If SharedPreferences fails (common on web with plugin issues), default to not logged in
|
||||||
|
print('Error loading login state: $e');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loggedIn = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -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 (coming soon)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,15 +220,15 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
|
|||||||
|
|
||||||
// action icons (scanner / chat / call)
|
// action icons (scanner / chat / call)
|
||||||
_iconSquare(primary, Icons.qr_code_scanner, onTap: () {
|
_iconSquare(primary, Icons.qr_code_scanner, onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner tapped (demo)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner (coming soon)')));
|
||||||
}),
|
}),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
_iconSquare(primary, Icons.chat, onTap: () {
|
_iconSquare(primary, Icons.chat, onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat tapped (demo)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat (coming soon)')));
|
||||||
}),
|
}),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
_iconSquare(primary, Icons.call, onTap: () {
|
_iconSquare(primary, Icons.call, onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call tapped (demo)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call (coming soon)')));
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// lib/screens/calendar_screen.dart
|
// lib/screens/calendar_screen.dart
|
||||||
import 'dart:math' as math;
|
|
||||||
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);
|
||||||
@@ -25,8 +27,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
|
|
||||||
final Set<String> _markedDates = {};
|
final Set<String> _markedDates = {};
|
||||||
final Map<String, int> _dateCounts = {};
|
final Map<String, int> _dateCounts = {};
|
||||||
final Map<String, List<String>> _dateThumbnails = {};
|
|
||||||
|
|
||||||
List<EventModel> _eventsOfDay = [];
|
List<EventModel> _eventsOfDay = [];
|
||||||
|
|
||||||
// Scroll controller for the calendar grid
|
// Scroll controller for the calendar grid
|
||||||
@@ -67,7 +67,6 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
_loadingMonth = true;
|
_loadingMonth = true;
|
||||||
_markedDates.clear();
|
_markedDates.clear();
|
||||||
_dateCounts.clear();
|
_dateCounts.clear();
|
||||||
_dateThumbnails.clear();
|
|
||||||
_eventsOfDay = [];
|
_eventsOfDay = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,44 +94,13 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_markedDates.isNotEmpty) {
|
|
||||||
await _fetchThumbnailsForDates(_markedDates.toList());
|
|
||||||
}
|
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchThumbnailsForDates(List<String> dates) async {
|
|
||||||
for (final date in dates) {
|
|
||||||
try {
|
|
||||||
final events = await _service.getEventsForDate(date);
|
|
||||||
final thumbs = <String>[];
|
|
||||||
for (final e in events) {
|
|
||||||
String? url;
|
|
||||||
if (e.thumbImg != null && e.thumbImg!.trim().isNotEmpty) {
|
|
||||||
url = e.thumbImg!.trim();
|
|
||||||
} else if (e.images.isNotEmpty && e.images.first.image.trim().isNotEmpty) {
|
|
||||||
url = e.images.first.image.trim();
|
|
||||||
}
|
|
||||||
if (url != null && url.isNotEmpty) thumbs.add(url);
|
|
||||||
if (thumbs.length >= 3) break;
|
|
||||||
}
|
|
||||||
if (thumbs.isNotEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _dateThumbnails[date] = thumbs);
|
|
||||||
} else {
|
|
||||||
_dateThumbnails[date] = thumbs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore per-date errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onSelectDate(String yyyyMMdd) async {
|
Future<void> _onSelectDate(String yyyyMMdd) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loadingDay = true;
|
_loadingDay = true;
|
||||||
@@ -150,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);
|
||||||
}
|
}
|
||||||
@@ -381,9 +349,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
itemCount: totalItems,
|
itemCount: totalItems,
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 7,
|
crossAxisCount: 7,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 4,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 4,
|
||||||
childAspectRatio: 1,
|
childAspectRatio: 0.78,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final cellDate = firstCellDate.add(Duration(days: index));
|
final cellDate = firstCellDate.add(Duration(days: index));
|
||||||
@@ -391,8 +359,9 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
final dayIndex = cellDate.day;
|
final dayIndex = cellDate.day;
|
||||||
final key = _ymKey(cellDate);
|
final key = _ymKey(cellDate);
|
||||||
final hasEvents = _markedDates.contains(key);
|
final hasEvents = _markedDates.contains(key);
|
||||||
final thumbnails = _dateThumbnails[key] ?? [];
|
final eventCount = _dateCounts[key] ?? 0;
|
||||||
final isSelected = selectedDate.year == cellDate.year && selectedDate.month == cellDate.month && selectedDate.day == cellDate.day;
|
final isSelected = selectedDate.year == cellDate.year && selectedDate.month == cellDate.month && selectedDate.day == cellDate.day;
|
||||||
|
final isToday = cellDate.year == DateTime.now().year && cellDate.month == DateTime.now().month && cellDate.day == DateTime.now().day;
|
||||||
|
|
||||||
final dayTextColor = inCurrentMonth
|
final dayTextColor = inCurrentMonth
|
||||||
? (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black87)
|
? (Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black87)
|
||||||
@@ -408,52 +377,57 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
},
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// rounded date cell
|
// rounded date cell
|
||||||
Container(
|
Container(
|
||||||
width: 36,
|
width: 32,
|
||||||
height: 36,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? primaryColor.withOpacity(0.14) : Colors.transparent,
|
color: isSelected
|
||||||
|
? primaryColor
|
||||||
|
: isToday
|
||||||
|
? primaryColor.withOpacity(0.12)
|
||||||
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
'$dayIndex',
|
'$dayIndex',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600,
|
fontWeight: (isSelected || isToday) ? FontWeight.w700 : FontWeight.w500,
|
||||||
color: isSelected ? primaryColor : dayTextColor,
|
color: isSelected
|
||||||
fontSize: 14,
|
? Colors.white
|
||||||
|
: isToday
|
||||||
|
? primaryColor
|
||||||
|
: dayTextColor,
|
||||||
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 3),
|
||||||
// small event indicators (thumbnail overlap or dot)
|
// event indicator dots
|
||||||
if (hasEvents && thumbnails.isNotEmpty)
|
if (hasEvents && inCurrentMonth)
|
||||||
SizedBox(
|
Row(
|
||||||
height: 14,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: thumbnails.take(2).toList().asMap().entries.map((entry) {
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
final i = entry.key;
|
children: List.generate(
|
||||||
final url = entry.value;
|
eventCount.clamp(1, 3),
|
||||||
return Transform.translate(
|
(i) => Container(
|
||||||
offset: Offset(i * -6.0, 0),
|
width: 5,
|
||||||
child: Container(
|
height: 5,
|
||||||
margin: const EdgeInsets.only(left: 4),
|
margin: EdgeInsets.only(left: i > 0 ? 2 : 0),
|
||||||
width: 14,
|
decoration: BoxDecoration(
|
||||||
height: 14,
|
color: isSelected
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: Theme.of(context).cardColor, width: 1.0)),
|
? primaryColor
|
||||||
child: ClipOval(child: Image.network(url, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(color: Theme.of(context).dividerColor))),
|
: const Color(0xFFEF4444),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (hasEvents)
|
|
||||||
Container(width: 18, height: 6, decoration: BoxDecoration(color: primaryColor, borderRadius: BorderRadius.circular(6)))
|
|
||||||
else
|
else
|
||||||
const SizedBox.shrink(),
|
const SizedBox(height: 5),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -530,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),
|
||||||
@@ -540,7 +514,20 @@ 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,
|
||||||
|
maxWidthDiskCache: 800,
|
||||||
|
maxHeightDiskCache: 600,
|
||||||
|
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),
|
||||||
@@ -566,50 +553,268 @@ 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,
|
||||||
|
maxWidthDiskCache: 600,
|
||||||
|
maxHeightDiskCache: 600,
|
||||||
|
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,
|
||||||
@@ -637,7 +842,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 0,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (coming soon)'))),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -656,32 +861,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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -689,44 +935,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
277
lib/screens/contributor_profile_screen.dart
Normal file
277
lib/screens/contributor_profile_screen.dart
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
// lib/screens/contributor_profile_screen.dart
|
||||||
|
// CTR-004 — Public contributor profile page.
|
||||||
|
// Shows avatar, tier ring, EP stats, and submission grid for any contributor.
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../features/gamification/models/gamification_models.dart';
|
||||||
|
import '../features/gamification/services/gamification_service.dart';
|
||||||
|
import '../widgets/tier_avatar_ring.dart';
|
||||||
|
|
||||||
|
class ContributorProfileScreen extends StatefulWidget {
|
||||||
|
final String contributorId;
|
||||||
|
final String contributorName;
|
||||||
|
|
||||||
|
const ContributorProfileScreen({
|
||||||
|
super.key,
|
||||||
|
required this.contributorId,
|
||||||
|
required this.contributorName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContributorProfileScreen> createState() => _ContributorProfileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
|
||||||
|
DashboardResponse? _data;
|
||||||
|
bool _loading = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final data = await GamificationService().getDashboardForUser(widget.contributorId);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_data = data;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Could not load profile';
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0F172A),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: const Color(0xFF0F172A),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
title: Text(
|
||||||
|
widget.contributorName,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFF3B82F6)),
|
||||||
|
)
|
||||||
|
: _error != null
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_error!,
|
||||||
|
style: const TextStyle(color: Colors.white54),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
final profile = _data!.profile;
|
||||||
|
final submissions = _data!.submissions;
|
||||||
|
final tierStr = tierLabel(profile.tier);
|
||||||
|
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Avatar with tier ring
|
||||||
|
TierAvatarRing(
|
||||||
|
username: widget.contributorName,
|
||||||
|
tier: tierStr,
|
||||||
|
size: 88,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
widget.contributorName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E3A8A),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
tierStr,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF93C5FD)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Stats row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_statCard('EP', '${profile.currentEp}'),
|
||||||
|
_statCard('Events', '${submissions.length}'),
|
||||||
|
_statCard(
|
||||||
|
'Approved',
|
||||||
|
'${submissions.where((s) => s.status.toUpperCase() == 'APPROVED').length}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (submissions.isNotEmpty)
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, i) => _buildSubmissionTile(submissions[i]),
|
||||||
|
childCount: submissions.length,
|
||||||
|
),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(32),
|
||||||
|
child: Text(
|
||||||
|
'No submissions yet',
|
||||||
|
style: TextStyle(color: Colors.white38),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statCard(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmissionTile(SubmissionModel s) {
|
||||||
|
final Color statusColor;
|
||||||
|
switch (s.status.toUpperCase()) {
|
||||||
|
case 'APPROVED':
|
||||||
|
statusColor = const Color(0xFF22C55E);
|
||||||
|
break;
|
||||||
|
case 'REJECTED':
|
||||||
|
statusColor = const Color(0xFFEF4444);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusColor = const Color(0xFFFBBF24); // PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmissionModel.images is List<String>; use first image if present.
|
||||||
|
final String? firstImage = s.images.isNotEmpty ? s.images.first : null;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF1E293B),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
if (firstImage != null && firstImage.isNotEmpty)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox.expand(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: firstImage,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: 400,
|
||||||
|
memCacheHeight: 300,
|
||||||
|
maxWidthDiskCache: 800,
|
||||||
|
maxHeightDiskCache: 600,
|
||||||
|
placeholder: (_, __) => Container(color: const Color(0xFF1E293B)),
|
||||||
|
errorWidget: (_, __, ___) => Container(color: const Color(0xFF334155)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.9),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.status,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (s.eventName.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [Colors.black87, Colors.transparent],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.vertical(bottom: Radius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.eventName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -13,9 +15,23 @@ class DesktopLoginScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin {
|
class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin {
|
||||||
|
// Login controllers
|
||||||
final TextEditingController _emailCtrl = TextEditingController();
|
final TextEditingController _emailCtrl = TextEditingController();
|
||||||
final TextEditingController _passCtrl = TextEditingController();
|
final TextEditingController _passCtrl = TextEditingController();
|
||||||
|
|
||||||
|
// Signup controllers
|
||||||
|
final TextEditingController _signupEmailCtrl = TextEditingController();
|
||||||
|
final TextEditingController _signupPhoneCtrl = TextEditingController();
|
||||||
|
final TextEditingController _signupPassCtrl = TextEditingController();
|
||||||
|
final TextEditingController _signupConfirmCtrl = TextEditingController();
|
||||||
|
String? _signupDistrict;
|
||||||
|
|
||||||
|
static const _districts = [
|
||||||
|
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||||
|
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||||
|
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||||
|
];
|
||||||
|
|
||||||
final AuthService _auth = AuthService();
|
final AuthService _auth = AuthService();
|
||||||
|
|
||||||
AnimationController? _controller;
|
AnimationController? _controller;
|
||||||
@@ -29,13 +45,18 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
final Curve _curve = Curves.easeInOutCubic;
|
final Curve _curve = Curves.easeInOutCubic;
|
||||||
|
|
||||||
bool _isAnimating = false;
|
bool _isAnimating = false;
|
||||||
bool _loading = false; // network loading flag
|
bool _loading = false;
|
||||||
|
bool _isSignupMode = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller?.dispose();
|
_controller?.dispose();
|
||||||
_emailCtrl.dispose();
|
_emailCtrl.dispose();
|
||||||
_passCtrl.dispose();
|
_passCtrl.dispose();
|
||||||
|
_signupEmailCtrl.dispose();
|
||||||
|
_signupPhoneCtrl.dispose();
|
||||||
|
_signupPassCtrl.dispose();
|
||||||
|
_signupConfirmCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +71,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
_leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
_leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)),
|
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)),
|
||||||
);
|
);
|
||||||
|
|
||||||
_formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
_formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||||
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
|
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
|
||||||
);
|
);
|
||||||
@@ -66,9 +86,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
Future<void> _performLoginFlow(double initialLeftWidth) async {
|
Future<void> _performLoginFlow(double initialLeftWidth) async {
|
||||||
if (_isAnimating || _loading) return;
|
if (_isAnimating || _loading) return;
|
||||||
|
|
||||||
setState(() {
|
setState(() => _loading = true);
|
||||||
_loading = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
final email = _emailCtrl.text.trim();
|
final email = _emailCtrl.text.trim();
|
||||||
final password = _passCtrl.text;
|
final password = _passCtrl.text;
|
||||||
@@ -85,14 +103,9 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Capture user model returned by AuthService (AuthService already saves prefs)
|
|
||||||
await _auth.login(email, password);
|
await _auth.login(email, password);
|
||||||
|
|
||||||
// on success run opening animation
|
|
||||||
await _startCollapseAnimation(initialLeftWidth);
|
await _startCollapseAnimation(initialLeftWidth);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
||||||
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
||||||
transitionDuration: Duration.zero,
|
transitionDuration: Duration.zero,
|
||||||
@@ -100,90 +113,135 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
setState(() => _isAnimating = false);
|
setState(() => _isAnimating = false);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() {
|
if (mounted) setState(() => _loading = false);
|
||||||
_loading = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openRegister() {
|
Future<void> _performSignupFlow(double initialLeftWidth) async {
|
||||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen()));
|
if (_isAnimating || _loading) return;
|
||||||
|
|
||||||
|
final email = _signupEmailCtrl.text.trim();
|
||||||
|
final phone = _signupPhoneCtrl.text.trim();
|
||||||
|
final pass = _signupPassCtrl.text;
|
||||||
|
final confirm = _signupConfirmCtrl.text;
|
||||||
|
|
||||||
|
if (email.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phone.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pass.length < 6) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Password must be at least 6 characters')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pass != confirm) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
setState(() => _loading = true);
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final screenW = MediaQuery.of(context).size.width;
|
|
||||||
|
|
||||||
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
|
try {
|
||||||
final bool animAvailable = _controller != null && _leftWidthAnim != null;
|
await _auth.register(
|
||||||
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
|
email: email,
|
||||||
|
phoneNumber: phone,
|
||||||
|
password: pass,
|
||||||
|
district: _signupDistrict,
|
||||||
|
);
|
||||||
|
await _startCollapseAnimation(initialLeftWidth);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
||||||
|
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
||||||
|
transitionDuration: Duration.zero,
|
||||||
|
reverseTransitionDuration: Duration.zero,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
|
setState(() => _isAnimating = false);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
Future<void> _openForgotPasswordDialog() async {
|
||||||
body: SafeArea(
|
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
|
||||||
child: AnimatedBuilder(
|
bool submitting = false;
|
||||||
animation: animation,
|
|
||||||
builder: (context, child) {
|
|
||||||
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
|
|
||||||
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
|
|
||||||
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
|
|
||||||
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
|
|
||||||
|
|
||||||
return Row(
|
await showDialog<void>(
|
||||||
children: [
|
context: context,
|
||||||
Container(
|
builder: (ctx) {
|
||||||
width: leftWidth,
|
return StatefulBuilder(
|
||||||
height: double.infinity,
|
builder: (ctx, setDialog) {
|
||||||
// color: const Color(0xFF0B63D6),
|
return AlertDialog(
|
||||||
decoration: AppDecoration.blueGradient,
|
title: const Text('Forgot Password'),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
|
content: SizedBox(
|
||||||
child: Opacity(
|
width: 360,
|
||||||
opacity: leftTextOpacity,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const Text("Enter your email and we'll send reset instructions."),
|
||||||
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
|
|
||||||
const Spacer(),
|
|
||||||
const Text('Welcome Back!', style: TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold)),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text(
|
TextField(
|
||||||
'Sign in to access your dashboard, manage events, and stay connected.',
|
controller: emailCtrl,
|
||||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
decoration: InputDecoration(
|
||||||
),
|
prefixIcon: const Icon(Icons.email),
|
||||||
const Spacer(flex: 2),
|
labelText: 'Email',
|
||||||
Opacity(
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: 12.0),
|
|
||||||
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
|
|
||||||
),
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: submitting
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final email = emailCtrl.text.trim();
|
||||||
|
if (email.isEmpty) return;
|
||||||
|
setDialog(() => submitting = true);
|
||||||
|
try {
|
||||||
|
await _auth.forgotPassword(email);
|
||||||
|
} catch (_) {
|
||||||
|
// safe-degrade
|
||||||
|
}
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text("If that email is registered, we've sent reset instructions."),
|
||||||
|
duration: Duration(seconds: 4),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: submitting
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('Send reset link'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Expanded(
|
emailCtrl.dispose();
|
||||||
child: Transform.translate(
|
}
|
||||||
offset: Offset(formOffset, 0),
|
|
||||||
child: Opacity(
|
Widget _buildLoginFields(double safeInitialWidth) {
|
||||||
opacity: formOpacity,
|
return Column(
|
||||||
child: Container(
|
key: const ValueKey('login'),
|
||||||
color: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
|
|
||||||
child: Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
|
||||||
child: Card(
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -215,21 +273,16 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
children: [
|
children: [
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Checkbox(value: true, onChanged: (_) {}),
|
Checkbox(value: true, onChanged: (_) {}),
|
||||||
const Text('Remember me')
|
const Text('Remember me'),
|
||||||
]),
|
]),
|
||||||
TextButton(onPressed: () {}, child: const Text('Forgot Password?'))
|
TextButton(onPressed: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: (_isAnimating || _loading)
|
onPressed: (_isAnimating || _loading) ? null : () => _performLoginFlow(safeInitialWidth),
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
final double initial = safeInitialWidth;
|
|
||||||
_performLoginFlow(initial);
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
child: (_isAnimating || _loading)
|
child: (_isAnimating || _loading)
|
||||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
@@ -237,14 +290,190 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
alignment: WrapAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
|
TextButton(
|
||||||
TextButton(onPressed: () {}, child: const Text('Contact support'))
|
onPressed: () => setState(() => _isSignupMode = true),
|
||||||
|
child: const Text("Don't have an account? Register"),
|
||||||
|
),
|
||||||
|
TextButton(onPressed: () {}, child: const Text('Contact support')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
AuthGuard.setGuest(true);
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Continue as Guest'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignupFields(double safeInitialWidth) {
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey('signup'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Text('Create Account', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text('Fill in your details to get started', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
TextField(
|
||||||
|
controller: _signupEmailCtrl,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.email),
|
||||||
|
labelText: 'Email Address',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _signupPhoneCtrl,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.phone),
|
||||||
|
labelText: 'Phone Number',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _signupDistrict,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.location_on),
|
||||||
|
labelText: 'District (optional)',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
|
||||||
|
onChanged: (v) => setState(() => _signupDistrict = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _signupPassCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.lock),
|
||||||
|
labelText: 'Password',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _signupConfirmCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
|
labelText: 'Confirm Password',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: (_isAnimating || _loading) ? null : () => _performSignupFlow(safeInitialWidth),
|
||||||
|
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||||
|
child: (_isAnimating || _loading)
|
||||||
|
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
|
: const Text('Create Account', style: TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => setState(() => _isSignupMode = false),
|
||||||
|
child: const Text('Already have an account? Sign in'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
|
||||||
|
final bool animAvailable = _controller != null && _leftWidthAnim != null;
|
||||||
|
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
|
||||||
|
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
|
||||||
|
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
|
||||||
|
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: leftWidth,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: AppDecoration.blueGradient,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: leftTextOpacity,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
_isSignupMode ? 'Join Eventify!' : 'Welcome Back!',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_isSignupMode
|
||||||
|
? 'Create your account to discover events, book tickets, and connect with your community.'
|
||||||
|
: 'Sign in to access your dashboard, manage events, and stay connected.',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
|
),
|
||||||
|
const Spacer(flex: 2),
|
||||||
|
Opacity(
|
||||||
|
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 12.0),
|
||||||
|
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(formOffset, 0),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: formOpacity,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
|
child: Card(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
child: _isSignupMode
|
||||||
|
? _buildSignupFields(safeInitialWidth)
|
||||||
|
: _buildLoginFields(safeInitialWidth),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -262,113 +491,3 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DesktopRegisterScreen extends StatefulWidget {
|
|
||||||
const DesktopRegisterScreen({Key? key}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DesktopRegisterScreen> createState() => _DesktopRegisterScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DesktopRegisterScreenState extends State<DesktopRegisterScreen> {
|
|
||||||
final TextEditingController _emailCtrl = TextEditingController();
|
|
||||||
final TextEditingController _phoneCtrl = TextEditingController();
|
|
||||||
final TextEditingController _passCtrl = TextEditingController();
|
|
||||||
final TextEditingController _confirmCtrl = TextEditingController();
|
|
||||||
final AuthService _auth = AuthService();
|
|
||||||
|
|
||||||
bool _loading = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_emailCtrl.dispose();
|
|
||||||
_phoneCtrl.dispose();
|
|
||||||
_passCtrl.dispose();
|
|
||||||
_confirmCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _performRegister() async {
|
|
||||||
final email = _emailCtrl.text.trim();
|
|
||||||
final phone = _phoneCtrl.text.trim();
|
|
||||||
final pass = _passCtrl.text;
|
|
||||||
final confirm = _confirmCtrl.text;
|
|
||||||
|
|
||||||
if (email.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (phone.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pass.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter password')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pass != confirm) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _loading = true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _auth.register(
|
|
||||||
email: email,
|
|
||||||
phoneNumber: phone,
|
|
||||||
password: pass,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)));
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
final message = e.toString().replaceAll('Exception: ', '');
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Register')),
|
|
||||||
body: SafeArea(
|
|
||||||
child: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(18.0),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email')),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone')),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password')),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
ElevatedButton(onPressed: _loading ? null : _performRegister, child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register')),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
OutlinedButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ class ResponsiveLayout extends StatelessWidget {
|
|||||||
Key? key,
|
Key? key,
|
||||||
required this.mobile,
|
required this.mobile,
|
||||||
required this.desktop,
|
required this.desktop,
|
||||||
this.mobileBreakpoint = 700, // tune this value if you prefer different breakpoint
|
this.mobileBreakpoint = 820, // consistent with MyApp.desktopBreakpoint
|
||||||
}) : assert(mobileBreakpoint > 0),
|
}) : assert(mobileBreakpoint > 0),
|
||||||
super(key: key);
|
super(key: key);
|
||||||
|
|
||||||
@@ -35,12 +35,18 @@ class ResponsiveLayout extends StatelessWidget {
|
|||||||
bool _chooseMobile(BuildContext context) {
|
bool _chooseMobile(BuildContext context) {
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
|
// On web, use width to determine mobile vs desktop so narrow browser
|
||||||
|
// windows (or mobile-sized preview) get the mobile UI.
|
||||||
|
if (kIsWeb) {
|
||||||
|
return width < mobileBreakpoint;
|
||||||
|
}
|
||||||
|
|
||||||
// If running on Android/iOS, allow width to determine mobile vs desktop.
|
// If running on Android/iOS, allow width to determine mobile vs desktop.
|
||||||
if (_isMobilePlatform()) {
|
if (_isMobilePlatform()) {
|
||||||
return width < mobileBreakpoint;
|
return width < mobileBreakpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
// On desktop platforms (Windows/macOS/Linux) and on web, always use desktop UI.
|
// On native desktop platforms (Windows/macOS/Linux) always use desktop UI.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,91 @@
|
|||||||
// 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 (add to pubspec.yaml)
|
// Location packages
|
||||||
// geolocator -> for permission & coordinates
|
|
||||||
// geocoding -> for reverse geocoding coordinates to a placemark
|
|
||||||
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 + optional coords).
|
||||||
|
class _LocationItem {
|
||||||
|
final String city;
|
||||||
|
final String? district;
|
||||||
|
final String? pincode;
|
||||||
|
final double? lat;
|
||||||
|
final double? lng;
|
||||||
|
|
||||||
|
const _LocationItem({required this.city, this.district, this.pincode, this.lat, this.lng});
|
||||||
|
|
||||||
|
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
|
||||||
|
String get displaySubtitle => pincode ?? '';
|
||||||
|
|
||||||
|
/// What gets returned to the caller (city name + optional district for display in pill).
|
||||||
|
String get returnValue => displayTitle;
|
||||||
|
}
|
||||||
|
|
||||||
class SearchScreen extends StatefulWidget {
|
class SearchScreen extends StatefulWidget {
|
||||||
const SearchScreen({Key? key}) : super(key: key);
|
const SearchScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
/// Returns a String to the caller via Navigator.pop(string).
|
|
||||||
/// Could be:
|
|
||||||
/// - a city name (e.g. "Bengaluru")
|
|
||||||
/// - 'Current Location' or a resolved locality like "Whitefield, Bengaluru"
|
|
||||||
@override
|
@override
|
||||||
State<SearchScreen> createState() => _SearchScreenState();
|
State<SearchScreen> createState() => _SearchScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchScreenState extends State<SearchScreen> {
|
class _SearchScreenState extends State<SearchScreen> {
|
||||||
final TextEditingController _ctrl = TextEditingController();
|
final TextEditingController _ctrl = TextEditingController();
|
||||||
final List<String> _popularCities = const [
|
|
||||||
'Delhi NCR',
|
/// Popular Kerala cities shown as chips.
|
||||||
'Mumbai',
|
static const List<String> _popularCities = [
|
||||||
'Kolkata',
|
'Thiruvananthapuram',
|
||||||
'Bengaluru',
|
'Kochi',
|
||||||
'Hyderabad',
|
'Kozhikode',
|
||||||
'Chandigarh',
|
'Kollam',
|
||||||
'Pune',
|
'Thrissur',
|
||||||
'Chennai',
|
'Kannur',
|
||||||
'Ahmedabad',
|
'Alappuzha',
|
||||||
'Jaipur',
|
'Palakkad',
|
||||||
|
'Malappuram',
|
||||||
|
'Kottayam',
|
||||||
];
|
];
|
||||||
|
|
||||||
List<String> _filtered = [];
|
/// Searchable location database – loaded from assets/data/kerala_pincodes.json.
|
||||||
|
List<_LocationItem> _locationDb = [];
|
||||||
|
bool _pinsLoaded = false;
|
||||||
|
|
||||||
|
List<_LocationItem> _searchResults = [];
|
||||||
|
bool _showSearchResults = false;
|
||||||
bool _loadingLocation = false;
|
bool _loadingLocation = false;
|
||||||
|
bool _isSearching = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_filtered = List.from(_popularCities);
|
_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
|
||||||
@@ -53,38 +98,97 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
final ql = q.trim().toLowerCase();
|
final ql = q.trim().toLowerCase();
|
||||||
setState(() {
|
setState(() {
|
||||||
if (ql.isEmpty) {
|
if (ql.isEmpty) {
|
||||||
_filtered = List.from(_popularCities);
|
_showSearchResults = false;
|
||||||
|
_searchResults = [];
|
||||||
} else {
|
} else {
|
||||||
_filtered = _popularCities.where((c) => c.toLowerCase().contains(ql)).toList();
|
_showSearchResults = true;
|
||||||
|
_searchResults = _locationDb.where((loc) {
|
||||||
|
return loc.city.toLowerCase().contains(ql) ||
|
||||||
|
(loc.district?.toLowerCase().contains(ql) ?? false) ||
|
||||||
|
(loc.pincode?.contains(ql) ?? false);
|
||||||
|
}).toList();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _selectAndClose(String city) {
|
/// Pop with a structured result so home_screen can update the display label,
|
||||||
Navigator.of(context).pop(city);
|
/// pincode, and GPS coordinates used for haversine filtering.
|
||||||
|
void _selectWithPincode(String label, {String? pincode, double? lat, double? lng}) {
|
||||||
|
final result = <String, dynamic>{
|
||||||
|
'label': label,
|
||||||
|
'pincode': pincode ?? 'all',
|
||||||
|
};
|
||||||
|
if (lat != null && lng != null) {
|
||||||
|
result['lat'] = lat;
|
||||||
|
result['lng'] = lng;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectAndClose(String location) async {
|
||||||
|
// Looks up pincode + coordinates from the database for the given city name.
|
||||||
|
final match = _locationDb.cast<_LocationItem?>().firstWhere(
|
||||||
|
(loc) => loc != null && (loc.city.toLowerCase() == location.toLowerCase() ||
|
||||||
|
loc.displayTitle.toLowerCase() == location.toLowerCase()),
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match != null) {
|
||||||
|
_selectWithPincode(location, pincode: match.pincode, lat: match.lat, lng: match.lng);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Geocode the location name
|
||||||
|
setState(() => _isSearching = true);
|
||||||
|
try {
|
||||||
|
final placemarksByAddress = await locationFromAddress(location);
|
||||||
|
if (placemarksByAddress.isNotEmpty) {
|
||||||
|
final loc = placemarksByAddress.first;
|
||||||
|
final placemarks = await placemarkFromCoordinates(loc.latitude, loc.longitude);
|
||||||
|
String label = location;
|
||||||
|
String? pincode;
|
||||||
|
if (placemarks.isNotEmpty) {
|
||||||
|
final p = placemarks.first;
|
||||||
|
final parts = <String>[];
|
||||||
|
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||||||
|
if ((p.subAdministrativeArea ?? '').isNotEmpty && p.subAdministrativeArea != p.locality) parts.add(p.subAdministrativeArea!);
|
||||||
|
if (parts.isNotEmpty) label = parts.join(', ');
|
||||||
|
pincode = p.postalCode;
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
_selectWithPincode(label, pincode: pincode ?? 'all', lat: loc.latitude, lng: loc.longitude);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Geocoding failed, proceed with just the text label
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSearching = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectWithPincode(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _useCurrentLocation() async {
|
Future<void> _useCurrentLocation() async {
|
||||||
setState(() => _loadingLocation = true);
|
setState(() => _loadingLocation = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check / request permission
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
LocationPermission permission = await Geolocator.checkPermission();
|
||||||
if (permission == LocationPermission.denied) {
|
if (permission == LocationPermission.denied) {
|
||||||
permission = await Geolocator.requestPermission();
|
permission = await Geolocator.requestPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
||||||
// Can't get permission — inform user and return a fallback label
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current position
|
|
||||||
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
||||||
|
|
||||||
// Try reverse geocoding to get a readable place name
|
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) {
|
||||||
@@ -93,21 +197,25 @@ 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!);
|
||||||
if ((p.administrativeArea ?? '').isNotEmpty) parts.add(p.administrativeArea!);
|
if (parts.isNotEmpty) label = parts.join(', ');
|
||||||
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
|
||||||
Navigator.of(context).pop(label);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore reverse geocode failures and fallback to coordinates or simple label
|
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// fallback: return lat,lng string or simple label
|
if (mounted) {
|
||||||
Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
|
// 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 any error, fallback to simple label
|
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);
|
||||||
}
|
}
|
||||||
@@ -115,31 +223,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Full-screen transparent Scaffold so the BackdropFilter can blur underlying UI.
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
// Tap outside sheet to dismiss
|
|
||||||
onTap: () => Navigator.of(context).pop(),
|
onTap: () => Navigator.of(context).pop(),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// BackdropFilter + dim overlay
|
RepaintBoundary(
|
||||||
BackdropFilter(
|
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 bottom: the sheet content
|
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: _SearchBottomSheet(
|
child: GestureDetector(
|
||||||
controller: _ctrl,
|
onTap: () {}, // prevent taps on sheet from closing
|
||||||
filteredCities: _filtered,
|
child: _buildSheet(context),
|
||||||
onCityTap: (city) => _selectAndClose(city),
|
|
||||||
onQueryChanged: _onQueryChanged,
|
|
||||||
onUseCurrentLocation: _useCurrentLocation,
|
|
||||||
loadingLocation: _loadingLocation,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -147,45 +248,9 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
class _SearchBottomSheet extends StatelessWidget {
|
Widget _buildSheet(BuildContext context) {
|
||||||
final TextEditingController controller;
|
return Container(
|
||||||
final List<String> filteredCities;
|
|
||||||
final void Function(String) onCityTap;
|
|
||||||
final void Function(String) onQueryChanged;
|
|
||||||
final Future<void> Function() onUseCurrentLocation;
|
|
||||||
final bool loadingLocation;
|
|
||||||
|
|
||||||
const _SearchBottomSheet({
|
|
||||||
Key? key,
|
|
||||||
required this.controller,
|
|
||||||
required this.filteredCities,
|
|
||||||
required this.onCityTap,
|
|
||||||
required this.onQueryChanged,
|
|
||||||
required this.onUseCurrentLocation,
|
|
||||||
required this.loadingLocation,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
Widget _cityChip(String name, BuildContext context, void Function() onTap) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Text(name, style: const TextStyle(color: Colors.black87)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// The bottom sheet container
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(0),
|
|
||||||
child: Container(
|
|
||||||
// limit height so it looks like a sheet
|
|
||||||
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.78, minHeight: 240),
|
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.78, minHeight: 240),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -199,109 +264,169 @@ class _SearchBottomSheet extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// center drag handle
|
// Header row
|
||||||
Center(
|
|
||||||
child: Container(width: 48, height: 6, decoration: BoxDecoration(color: Colors.grey[300], borderRadius: BorderRadius.circular(6))),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Header
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text('Set Your Location', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
const Text('Set Your Location', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A2E))),
|
||||||
// Close button (inside sheet)
|
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
onTap: () => Navigator.of(context).pop(),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(width: 40, height: 40, decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.close, color: Colors.black54)),
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(color: const Color(0xFF1A1A2E), borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: const Icon(Icons.close, color: Colors.white, size: 20),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Search field (now functional)
|
// Search field
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: _ctrl.text.isNotEmpty ? Border.all(color: const Color(0xFF2563EB).withOpacity(0.5), width: 1.5) : null,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.search, color: Colors.black38),
|
Icon(Icons.search, color: Colors.grey[500]),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: controller,
|
controller: _ctrl,
|
||||||
decoration: const InputDecoration(hintText: 'Search city, area or locality', border: InputBorder.none),
|
enabled: !_isSearching,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Search city, area or locality',
|
||||||
|
hintStyle: TextStyle(color: Color(0xFF9CA3AF)),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onChanged: onQueryChanged,
|
onChanged: _onQueryChanged,
|
||||||
onSubmitted: (v) {
|
onSubmitted: (v) {
|
||||||
final q = v.trim();
|
final q = v.trim();
|
||||||
if (q.isEmpty) return;
|
if (q.isEmpty) return;
|
||||||
// If there's an exact/first match in filteredCities, pick it; otherwise pass the raw query.
|
if (_searchResults.isNotEmpty) {
|
||||||
final match = filteredCities.isNotEmpty ? filteredCities.first : null;
|
_selectAndClose(_searchResults.first.returnValue);
|
||||||
Navigator.of(context).pop(match ?? q);
|
} else {
|
||||||
|
_selectAndClose(q);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (controller.text.isNotEmpty)
|
if (_isSearching)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(right: 8.0),
|
||||||
|
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
)
|
||||||
|
else if (_ctrl.text.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear, size: 20),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
controller.clear();
|
_ctrl.clear();
|
||||||
onQueryChanged('');
|
_onQueryChanged('');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Use current location button
|
// Use current location
|
||||||
ElevatedButton(
|
Material(
|
||||||
onPressed: loadingLocation ? null : () => onUseCurrentLocation(),
|
color: const Color(0xFF2563EB),
|
||||||
style: ElevatedButton.styleFrom(
|
borderRadius: BorderRadius.circular(14),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
child: InkWell(
|
||||||
backgroundColor: const Color(0xFF0B63D6),
|
onTap: _loadingLocation ? null : () => _useCurrentLocation(),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.my_location, color: Colors.white),
|
const Icon(Icons.my_location, color: Colors.white, size: 22),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(child: Text(loadingLocation ? 'Detecting location...' : 'Use Current Location', style: const TextStyle(color: Colors.white))),
|
Expanded(
|
||||||
if (loadingLocation)
|
child: Text(
|
||||||
const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
_loadingLocation ? 'Detecting location...' : 'Use Current Location',
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_loadingLocation)
|
||||||
|
const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||||
else
|
else
|
||||||
const Icon(Icons.chevron_right, color: Colors.white),
|
const Icon(Icons.chevron_right, color: Colors.white),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Popular cities
|
// Search results or Popular Cities
|
||||||
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold)),
|
if (_showSearchResults) ...[
|
||||||
const SizedBox(height: 12),
|
if (_searchResults.isEmpty)
|
||||||
|
|
||||||
Wrap(
|
|
||||||
spacing: 12,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: [
|
|
||||||
for (final city in filteredCities.take(8)) _cityChip(city, context, () => onCityTap(city)),
|
|
||||||
// if filteredCities is empty show empty state
|
|
||||||
if (filteredCities.isEmpty)
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||||
child: Text('No suggestions', style: TextStyle(color: Colors.grey[600])),
|
child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))),
|
||||||
)
|
)
|
||||||
|
else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 320),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: false,
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
itemCount: _searchResults.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1),
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final loc = _searchResults[idx];
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
||||||
|
leading: Icon(Icons.location_on_outlined, color: Colors.grey[400], size: 24),
|
||||||
|
title: Text(
|
||||||
|
loc.displayTitle,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15, color: Color(0xFF1A1A2E)),
|
||||||
|
),
|
||||||
|
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
|
||||||
|
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
|
||||||
|
: null,
|
||||||
|
onTap: () => _selectWithPincode(loc.displayTitle, pincode: loc.pincode, lat: loc.lat, lng: loc.lng),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: [
|
||||||
|
for (final city in _popularCities)
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _selectAndClose(city),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(color: const Color(0xFFF3F4F6), borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Text(
|
||||||
|
city.length > 16 ? '${city.substring(0, 14)}...' : city,
|
||||||
|
style: const TextStyle(color: Color(0xFF374151), fontWeight: FontWeight.w500, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '2.0.4';
|
||||||
|
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),
|
||||||
@@ -120,7 +314,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))),
|
const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))),
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help (coming soon)'))),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -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 (coming soon)'))),
|
||||||
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: 'Coming Soon',
|
||||||
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ class TicketsBookedScreen extends StatelessWidget {
|
|||||||
|
|
||||||
void _onScannerTap(BuildContext context) {
|
void _onScannerTap(BuildContext context) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Scanner tapped (demo)')),
|
SnackBar(content: Text('Scanner tapped (coming soon)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onWhatsappTap(BuildContext context) {
|
void _onWhatsappTap(BuildContext context) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')),
|
SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCallTap(BuildContext context) {
|
void _onCallTap(BuildContext context) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Call tapped (demo)')),
|
SnackBar(content: Text('Call (coming soon)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
147
lib/widgets/desktop_topbar.dart
Normal file
147
lib/widgets/desktop_topbar.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
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: CachedNetworkImageProvider(
|
||||||
|
url,
|
||||||
|
maxWidth: 80,
|
||||||
|
maxHeight: 80,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
lib/widgets/tier_avatar_ring.dart
Normal file
121
lib/widgets/tier_avatar_ring.dart
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
|
class TierAvatarRing extends StatelessWidget {
|
||||||
|
final String username;
|
||||||
|
final String tier;
|
||||||
|
final double size;
|
||||||
|
final bool showDiceBear;
|
||||||
|
final String? imageUrl;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
static const Map<String, Color> _tierColors = {
|
||||||
|
'Bronze': Color(0xFFFED7AA),
|
||||||
|
'Silver': Color(0xFFE2E8F0),
|
||||||
|
'Gold': Color(0xFFFEF3C7),
|
||||||
|
'Platinum': Color(0xFFEDE9FE),
|
||||||
|
'Diamond': Color(0xFFE0E7FF),
|
||||||
|
};
|
||||||
|
|
||||||
|
static const Color _fallbackColor = Color(0xFF475569);
|
||||||
|
|
||||||
|
const TierAvatarRing({
|
||||||
|
super.key,
|
||||||
|
required this.username,
|
||||||
|
required this.tier,
|
||||||
|
this.size = 48.0,
|
||||||
|
this.showDiceBear = true,
|
||||||
|
this.imageUrl,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
Color get _ringColor => _tierColors[tier] ?? _fallbackColor;
|
||||||
|
|
||||||
|
String get _avatarUrl {
|
||||||
|
if (imageUrl != null && imageUrl!.isNotEmpty) {
|
||||||
|
return imageUrl!;
|
||||||
|
}
|
||||||
|
return 'https://api.dicebear.com/9.x/notionists/svg?seed=$username';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar() {
|
||||||
|
final double radius = size / 2 - 5;
|
||||||
|
|
||||||
|
if (!showDiceBear) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: size * 0.5,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: _avatarUrl,
|
||||||
|
memCacheWidth: (size * 2).round(),
|
||||||
|
memCacheHeight: (size * 2).round(),
|
||||||
|
maxWidthDiskCache: (size * 4).round(),
|
||||||
|
maxHeightDiskCache: (size * 4).round(),
|
||||||
|
imageBuilder: (context, imageProvider) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundImage: imageProvider,
|
||||||
|
),
|
||||||
|
placeholder: (context, url) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: SizedBox(
|
||||||
|
width: size * 0.4,
|
||||||
|
height: size * 0.4,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: const Color(0xFF1E293B),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: size * 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final Color ringColor = _ringColor;
|
||||||
|
final double containerSize = size + 6;
|
||||||
|
|
||||||
|
final Widget ring = Container(
|
||||||
|
width: containerSize,
|
||||||
|
height: containerSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: ringColor, width: 3),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: ringColor.withOpacity(0.4),
|
||||||
|
blurRadius: 8,
|
||||||
|
spreadRadius: 1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(child: _buildAvatar()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onTap != null) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: ring,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,16 +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
|
||||||
|
|
||||||
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"))
|
||||||
}
|
}
|
||||||
|
|||||||
256
pubspec.lock
256
pubspec.lock
@@ -41,14 +41,38 @@ 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:
|
||||||
name: characters
|
name: characters
|
||||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.1"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
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
|
||||||
@@ -492,26 +588,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.17"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: material_color_utilities
|
name: material_color_utilities
|
||||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.11.1"
|
version: "0.13.0"
|
||||||
meta:
|
meta:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -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: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+2"
|
||||||
|
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:
|
||||||
@@ -769,10 +961,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.10"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -893,6 +1085,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
video_player:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: video_player
|
||||||
|
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.10.1"
|
||||||
|
video_player_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_android
|
||||||
|
sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.9.4"
|
||||||
|
video_player_avfoundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_avfoundation
|
||||||
|
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.8.9"
|
||||||
|
video_player_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_platform_interface
|
||||||
|
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.0"
|
||||||
|
video_player_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: video_player_web
|
||||||
|
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
47
pubspec.yaml
47
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: 2.0.4+24
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
@@ -19,6 +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
|
||||||
|
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:
|
||||||
@@ -31,7 +40,41 @@ 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:
|
||||||
|
- family: Gilroy
|
||||||
|
fonts:
|
||||||
|
- asset: assets/fonts/Gilroy-Light.ttf
|
||||||
|
weight: 300
|
||||||
|
- asset: assets/fonts/Gilroy-LightItalic.ttf
|
||||||
|
weight: 300
|
||||||
|
style: italic
|
||||||
|
- asset: assets/fonts/Gilroy-Regular.ttf
|
||||||
|
weight: 400
|
||||||
|
- asset: assets/fonts/Gilroy-RegularItalic.ttf
|
||||||
|
weight: 400
|
||||||
|
style: italic
|
||||||
|
- asset: assets/fonts/Gilroy-Medium.ttf
|
||||||
|
weight: 500
|
||||||
|
- asset: assets/fonts/Gilroy-MediumItalic.ttf
|
||||||
|
weight: 500
|
||||||
|
style: italic
|
||||||
|
- asset: assets/fonts/Gilroy-SemiBold.ttf
|
||||||
|
weight: 600
|
||||||
|
- asset: assets/fonts/Gilroy-SemiBoldItalic.ttf
|
||||||
|
weight: 600
|
||||||
|
style: italic
|
||||||
|
- asset: assets/fonts/Gilroy-Bold.ttf
|
||||||
|
weight: 700
|
||||||
|
- asset: assets/fonts/Gilroy-BoldItalic.ttf
|
||||||
|
weight: 700
|
||||||
|
style: italic
|
||||||
|
- asset: assets/fonts/Gilroy-ExtraBold.ttf
|
||||||
|
weight: 800
|
||||||
|
- asset: assets/fonts/Gilroy-ExtraBoldItalic.ttf
|
||||||
|
weight: 800
|
||||||
|
style: italic
|
||||||
|
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
|
|||||||
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