Compare commits
62 Commits
b55f02e057
...
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 |
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "flutter-web",
|
||||
"runtimeExecutable": "flutter",
|
||||
"runtimeArgs": ["run", "-d", "chrome", "--web-port", "8080", "--web-browser-flag", "--disable-web-security"],
|
||||
"port": 8080
|
||||
}
|
||||
]
|
||||
}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -52,3 +52,13 @@ web/assets/login-bg.mp4
|
||||
*.keystore
|
||||
# large binary assets — keep local only, not tracked in git
|
||||
assets/login-bg.mp4
|
||||
|
||||
# Claude Code / MCP / vibe-coding tool artifacts — keep local only
|
||||
.claude/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
_notes/
|
||||
.obsidian/
|
||||
hero_section_improvements.csv
|
||||
security_audit_report.csv
|
||||
feature_gap_analysis.csv
|
||||
|
||||
125
CHANGELOG.md
125
CHANGELOG.md
@@ -6,6 +6,131 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [2.0.1] - 2026-04-10
|
||||
|
||||
Patch release — hotfix for Google Sign-In broken in 2.0.0.
|
||||
|
||||
### Fixed
|
||||
- **Sign in with Google** (`lib/screens/login_screen.dart`): Resolved authentication failure introduced in 2.0.0. Google OAuth flow now completes correctly and exchanges tokens with Django `POST /accounts/google-auth/` as expected.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-04-10
|
||||
|
||||
Public release milestone. Full backend integration, real image upload pipeline, complete personal profile system, and production Android build infrastructure.
|
||||
|
||||
### Added
|
||||
- **Real image upload pipeline** (`lib/core/api/api_client.dart`): `uploadFile()` method uses `http.MultipartRequest` with explicit MIME type detection from file extension. Supports JPEG, PNG, WebP, MP4, MOV. Files upload to Node.js `/api/v1/upload/file` → OneDrive via Microsoft Graph API, returning a shareable anonymous link.
|
||||
- **Contribute image upload to OneDrive** (`lib/features/gamification/services/gamification_service.dart`): `submitContribution()` now uploads all selected images before submitting the event form. Uploaded file metadata (including OneDrive share URL) is passed as `media` array in the contribution payload — replacing the broken device-path-as-string approach.
|
||||
- **Upload endpoint constant** (`lib/core/api/api_endpoints.dart`): `ApiEndpoints.uploadFile` pointing to `$_nodeBase/v1/upload/file`.
|
||||
- **Full personal info form in Edit Profile sheet** (`lib/screens/profile_screen.dart`):
|
||||
- First Name, Last Name fields
|
||||
- Email (read-only, locked from direct edit)
|
||||
- Phone number field
|
||||
- District picker with 183-day change cooldown — shows next-change date when locked
|
||||
- Place, Pincode, State, Country fields
|
||||
- All fields loaded from SharedPreferences cache and API on open; saved via `PATCH /api/user/update-profile/`
|
||||
|
||||
### Changed
|
||||
- **App version** displayed in Settings → About updated to `2.0(b)` (`lib/screens/settings_screen.dart`).
|
||||
- **All "(demo)" labels replaced with "(coming soon)"** across the app:
|
||||
- `settings_screen.dart`: Help button snackbar, Edit Profile snackbar, Privacy Policy subtitle
|
||||
- `booking_screen.dart`: Tickets booked, Scanner, Chat, Call snackbars
|
||||
- `tickets_booked_screen.dart`: Scanner, Chat/WhatsApp, Call snackbars
|
||||
- `calendar_screen.dart`: Notifications snackbar
|
||||
- **`pubspec.yaml` version bumped** to `2.0.0+20` (version name `2.0.0`, build code `20`).
|
||||
|
||||
### Fixed
|
||||
- **Android build version override** (`android/app/build.gradle.kts`): Removed hardcoded `versionCode = 17` and `versionName = "1.6.1(p)"` — both now read from `flutter.versionCode` / `flutter.versionName` (sourced from `pubspec.yaml`). This was causing Play Store rejections ("version code 17 already used") on every release build.
|
||||
- **`http_parser` dependency added** (`pubspec.yaml`): Required for explicit `MediaType` MIME typing in `MultipartRequest`. Without it, file uploads defaulted to `application/octet-stream` and were rejected by the Node.js multer middleware.
|
||||
|
||||
### Infrastructure
|
||||
- **Production AAB built and signed** with `upload-keystore-new.jks` — build 20, version name `2.0` — submitted to Google Play Console.
|
||||
- **`build.gradle.kts` signing config** reads `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD` from `gradle.properties` or environment variables (no secrets in source).
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-04-04
|
||||
|
||||
Phase 4 — animation polish and final feature gaps. Flutter app reaches full feature parity with Consumer Web App v1.4.9.
|
||||
|
||||
### Added
|
||||
- **BouncingLoader widget** (`lib/widgets/bouncing_loader.dart`): 3-dot bouncing animation with staggered 200 ms delays using `Curves.bounceOut`. Replaces `CircularProgressIndicator` in home, contribute, and review screens. Accepts `color`, `dotSize`, and `spacing` parameters.
|
||||
- **DiceBear Notionists avatars on review cards** (REV-001): `CachedNetworkImage` fetches `api.dicebear.com/9.x/notionists/svg?seed={username}`. Falls back to coloured initial letter `CircleAvatar` on error or while loading.
|
||||
- **Server-side event search** (HOME-007): Search modal now sends `q` param to `EventsByPincodeView`; client-side filter stays for instant `onChanged` feedback while server results load on submit. Cache is bypassed for search queries. Django backend updated with `Q(title__icontains=q) | Q(description__icontains=q)` OR filter.
|
||||
- **`flutter_staggered_animations: ^1.1.1`** added to pubspec.
|
||||
|
||||
### Changed
|
||||
- **Review list stagger animation** (REV-003): `AnimationLimiter` + `AnimationConfiguration.toStaggeredList` wraps review cards with 375 ms slide-up + fade-in per item.
|
||||
- **Review submit success spring animation** (REV-004): Checkmark icon now animates with `ScaleTransition` driven by `Curves.elasticOut` (600 ms) instead of a static icon swap.
|
||||
- **Hero transitions on event cards** (UX-005): `Hero(tag: 'event-hero-{id}')` wraps event images in home screen and matching destination in learn more screen — enabling shared-element transitions.
|
||||
- **FadeTransition on learn more screen** (UX-005): Screen body fades in with `Curves.easeIn` (350 ms) after event data loads.
|
||||
- **AnimatedList stagger on leaderboard** (UX-005): `SliverList` entries animate with `AnimationConfiguration.staggeredList` — 375 ms slide-up + fade-in per row.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-04-04
|
||||
|
||||
Phase 3 — 26 medium-priority gaps. Profile editing, contributor profiles, share cards, booking promo codes, and UX system components.
|
||||
|
||||
### Added
|
||||
- **Eventify ID badge** (AUTH-003): Verified badge displayed on profile and contributor cards for accounts with confirmed identity.
|
||||
- **DiceBear TierAvatarRing** (`lib/widgets/tier_avatar_ring.dart`): Tier-coloured ring around profile avatars using DiceBear seed — Bronze/Silver/Gold/Platinum/Diamond colours.
|
||||
- **Profile photo upload to server** (AUTH-006 / PROF-002): `PATCH /api/user/update-profile/` multipart endpoint; photo picker + crop flow integrated.
|
||||
- **District picker** (PROF-004): 14 Kerala districts selectable from a bottom sheet; stored against user profile.
|
||||
- **183-day profile cooldown lock** (AUTH-005): Username and display name locked for 183 days after last change; countdown shown in edit form.
|
||||
- **Kerala pincodes JSON** (`assets/data/kerala_pincodes.json`) (LOC-003): Full offline pincode dataset covering all 14 districts; powers location-aware event discovery without API round trips.
|
||||
- **Promo code input on booking** (BOOK-003): `POST /bookings/apply-promo/` endpoint; inline validation with success/error state in booking bar.
|
||||
- **Contributor profile screen** (`lib/screens/contributor_profile_screen.dart`) (CTR-004/005): Public view of any contributor's stats, tier, events submitted, and achievements.
|
||||
- **Share rank card** (`lib/features/share/share_rank_card.dart`) (SHARE-001/002): Generates a shareable tier/EP card image; `share_plus` used for native share sheet.
|
||||
- **Share status button on contributor dashboard** (CTR-003): `OutlinedButton.icon` with `Share.share()` near tier/EP display.
|
||||
- **GlassCard widget** (`lib/widgets/glass_card.dart`) (UX-003): Reusable frosted-glass surface used across gamification and profile screens.
|
||||
- **EventifyBottomSheet** (`lib/widgets/eventify_bottom_sheet.dart`) (UX-004): Standardised bottom sheet with drag handle, rounded corners, and safe-area inset.
|
||||
- **Featured events carousel** (HOME-004): Auto-scrolling hero carousel for featured/sponsored events on home screen.
|
||||
- **Event image gallery** (EVT-001): Full-screen `PageView` carousel inside learn more screen with dot indicator.
|
||||
|
||||
### Changed
|
||||
- **Real gamification data** (GAM-003/004/006): EP/RP transaction history, tier progression, and leaderboard all wired to live Node.js API — mock data removed.
|
||||
- **Leaderboard card district display** (LDR-003): District badge shown per rank row; district filter pill row added above leaderboard.
|
||||
- **Achievement badge display + unlock animation** (ACH-002/003): Badges rendered from API; confetti-style animation plays on first unlock.
|
||||
- **Review responses** (REV-002): Organiser reply thread displayed below each review card.
|
||||
- **Profile bio and social links** (PROF-001): Edit form includes bio textarea and links for Instagram, Twitter, LinkedIn.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.1] - 2026-04-04
|
||||
|
||||
Phase 2 — 11 high-priority gaps. Authentication hardening, location services, real gamification data hookup, skeleton loading, and booking flow fixes.
|
||||
|
||||
### Added
|
||||
- **Email OTP verification** (AUTH-004): 6-digit OTP sent on registration; verification screen blocks app access until confirmed.
|
||||
- **Password reset flow** (AUTH-002): Forgot-password → OTP → new password screens; Django `POST /accounts/password-reset/` endpoint integrated.
|
||||
- **Location permission + haversine distance sorting** (LOC-001/002): `geolocator` requests permission at startup; events sorted by straight-line distance from user's GPS coordinate.
|
||||
- **Skeleton loading with shimmer** (UX-001): `shimmer: ^3.0.0` added; event feed, leaderboard, and profile screens show shimmer placeholders while data loads.
|
||||
- **Contributor stats real API** (CTR-001): EP/RP balance and tier fetched from Node.js gamification endpoint on dashboard load.
|
||||
- **Achievement progress tracking** (ACH-001): Progress bars and completion state fetched from API; local mock removed.
|
||||
- **Profile stats row** (PROF-003): Likes / Posts / Views counts fetched from user profile API and displayed in profile header.
|
||||
|
||||
### Changed
|
||||
- **Search modal with server pincode detection** (HOME-001/002/003): Search bottom sheet auto-detects user pincode; category filter chips filter from API results.
|
||||
- **Real tier and EP display** (GAM-002/005): Contributor dashboard shows live tier and EP from Node.js API; tier badge in profile header updated to match.
|
||||
- **District filter on leaderboard** (LDR-002): Leaderboard district pills populated from API; selecting filters rank table in real time.
|
||||
- **Booking bar fixes** (EVT-003/BOOK-004): Fixed ticket-count stepper; booking confirmation screen correctly shows booking reference.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-04-04
|
||||
|
||||
Phase 1 — critical gaps. Live backend integration replacing all mock/stub data, payment checkout, and OAuth.
|
||||
|
||||
### Added
|
||||
- **Gamification API integration**: EP, RP, leaderboard, and achievements wired to live Node.js endpoints at `app.eventifyplus.com/api/v1/` — all mock `GamificationService` data replaced.
|
||||
- **Razorpay checkout** (BOOK-001): Native Razorpay SDK integrated; `POST /bookings/create/` → order creation → Razorpay payment sheet → `POST /bookings/verify/` webhook.
|
||||
- **Google OAuth login** (AUTH-001): `google_sign_in` flow; tokens exchanged with Django `POST /accounts/google-auth/` endpoint.
|
||||
- **Notification panel** (NOTIF-002/003/004): `DraggableScrollableSheet` notification drawer with 4 colour-coded notification types (booking, event, system, promo); mark-individual-read and mark-all-read actions.
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
Binary file not shown.
@@ -22,8 +22,8 @@ android {
|
||||
applicationId = "com.sicherhaven.eventify"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = 17
|
||||
versionName = "1.6.1(p)"
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
// ---------- SIGNING CONFIG ----------
|
||||
|
||||
59
android/app/proguard-rules.pro
vendored
59
android/app/proguard-rules.pro
vendored
@@ -26,3 +26,62 @@
|
||||
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||
-dontwarn com.google.android.play.core.tasks.Task
|
||||
|
||||
# Razorpay
|
||||
-keepattributes *Annotation*,Signature,*Annotation*
|
||||
-dontwarn com.razorpay.**
|
||||
-keep class com.razorpay.** { *; }
|
||||
-optimizations !method/inlining/
|
||||
-keepclasseswithmembers class * {
|
||||
public void onPayment*(...);
|
||||
}
|
||||
-keep class proguard.annotation.Keep
|
||||
-keep class proguard.annotation.KeepClassMembers
|
||||
-keep @proguard.annotation.Keep class * { *; }
|
||||
-keep @proguard.annotation.KeepClassMembers class * {
|
||||
<fields>;
|
||||
<methods>;
|
||||
}
|
||||
|
||||
# Google Sign-In / Play Services
|
||||
-keep class com.google.android.gms.** { *; }
|
||||
-keep interface com.google.android.gms.** { *; }
|
||||
-dontwarn com.google.android.gms.**
|
||||
-keep class com.google.firebase.** { *; }
|
||||
-dontwarn com.google.firebase.**
|
||||
|
||||
# Geolocator / Geocoding
|
||||
-keep class com.baseflow.** { *; }
|
||||
-dontwarn com.baseflow.**
|
||||
|
||||
# url_launcher, share_plus, image_picker, path_provider, etc.
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
-dontwarn io.flutter.plugins.**
|
||||
|
||||
# OkHttp (used by many network libs)
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
-keep class okhttp3.** { *; }
|
||||
-keep interface okhttp3.** { *; }
|
||||
|
||||
# Keep native methods
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
# Keep Parcelable classes
|
||||
-keep class * implements android.os.Parcelable {
|
||||
public static final android.os.Parcelable$Creator *;
|
||||
}
|
||||
|
||||
# Keep Serializable classes
|
||||
-keepclassmembers class * implements java.io.Serializable {
|
||||
static final long serialVersionUID;
|
||||
private static final java.io.ObjectStreamField[] serialPersistentFields;
|
||||
!static !transient <fields>;
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
java.lang.Object writeReplace();
|
||||
java.lang.Object readResolve();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,18 @@
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="YOUR_GOOGLE_MAPS_API_KEY"/>
|
||||
|
||||
<!-- Splash video plays first, then launches MainActivity -->
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
android:exported="true"
|
||||
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -31,11 +43,6 @@
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Don't delete the meta-data below. Used by Flutter tool. -->
|
||||
|
||||
@@ -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
@@ -47,5 +47,18 @@
|
||||
<true/>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>GIDClientID</key>
|
||||
<string>639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd.apps.googleusercontent.com</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>com.googleusercontent.apps.639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
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,15 @@
|
||||
// lib/core/api/api_client.dart
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show SocketException;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class ApiClient {
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
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;
|
||||
|
||||
@@ -27,13 +31,7 @@ class ApiClient {
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
response = await http
|
||||
.post(
|
||||
Uri.parse(url),
|
||||
headers: headers,
|
||||
body: jsonEncode(finalBody),
|
||||
)
|
||||
.timeout(_timeout);
|
||||
response = await _postWithRetry(url, headers, finalBody);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('ApiClient.post network error: $e');
|
||||
|
||||
@@ -99,6 +97,82 @@ class ApiClient {
|
||||
return _handleResponse(url, response, finalBody);
|
||||
}
|
||||
|
||||
/// POST with one retry on transient network errors.
|
||||
/// Retries on SocketException / TimeoutException only.
|
||||
Future<http.Response> _postWithRetry(
|
||||
String url,
|
||||
Map<String, String> headers,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
try {
|
||||
return await http
|
||||
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||
.timeout(_timeout);
|
||||
} on SocketException {
|
||||
if (kDebugMode) debugPrint('ApiClient.post retry after SocketException');
|
||||
await Future.delayed(_retryDelay);
|
||||
return await http
|
||||
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||
.timeout(_timeout);
|
||||
} on TimeoutException {
|
||||
if (kDebugMode) debugPrint('ApiClient.post retry after TimeoutException');
|
||||
await Future.delayed(_retryDelay);
|
||||
return await http
|
||||
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
|
||||
.timeout(_timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload a single file as multipart/form-data.
|
||||
///
|
||||
/// Returns the `file` object from the server response:
|
||||
/// `{ fileId, url, name, type, mimeType, size, backend }`
|
||||
Future<Map<String, dynamic>> uploadFile(String url, String filePath) async {
|
||||
final request = http.MultipartRequest('POST', Uri.parse(url));
|
||||
const _mimeMap = <String, List<String>>{
|
||||
'jpg': ['image', 'jpeg'],
|
||||
'jpeg': ['image', 'jpeg'],
|
||||
'png': ['image', 'png'],
|
||||
'webp': ['image', 'webp'],
|
||||
'mp4': ['video', 'mp4'],
|
||||
'mov': ['video', 'quicktime'],
|
||||
};
|
||||
final ext = filePath.split('.').last.toLowerCase();
|
||||
final parts = _mimeMap[ext] ?? ['image', 'jpeg'];
|
||||
request.files.add(await http.MultipartFile.fromPath(
|
||||
'file',
|
||||
filePath,
|
||||
contentType: MediaType(parts[0], parts[1]),
|
||||
));
|
||||
|
||||
late http.StreamedResponse streamed;
|
||||
try {
|
||||
streamed = await request.send().timeout(const Duration(seconds: 60));
|
||||
} catch (e) {
|
||||
throw Exception('Upload network error: $e');
|
||||
}
|
||||
|
||||
final body = await streamed.stream.bytesToString();
|
||||
dynamic decoded;
|
||||
try {
|
||||
decoded = jsonDecode(body);
|
||||
} catch (_) {
|
||||
throw Exception('Upload response parse error');
|
||||
}
|
||||
|
||||
if (streamed.statusCode >= 200 && streamed.statusCode < 300) {
|
||||
if (decoded is Map<String, dynamic> && decoded['file'] is Map) {
|
||||
return Map<String, dynamic>.from(decoded['file'] as Map);
|
||||
}
|
||||
return decoded is Map<String, dynamic> ? decoded : {};
|
||||
}
|
||||
|
||||
final msg = (decoded is Map && decoded['message'] is String)
|
||||
? decoded['message'] as String
|
||||
: 'Upload failed (${streamed.statusCode})';
|
||||
throw Exception(msg);
|
||||
}
|
||||
|
||||
/// GET request
|
||||
///
|
||||
/// - If requiresAuth==true, token & username will be attached as query parameters.
|
||||
@@ -109,21 +183,24 @@ class ApiClient {
|
||||
bool requiresAuth = true,
|
||||
}) async {
|
||||
// build final query params including auth if needed
|
||||
final Map<String, dynamic> finalParams = {};
|
||||
final originalUri = Uri.parse(url);
|
||||
final queryParams = <String, String>{...originalUri.queryParameters};
|
||||
|
||||
if (requiresAuth) {
|
||||
final token = await TokenStorage.getToken();
|
||||
final username = await TokenStorage.getUsername();
|
||||
if (token != null && username != null) {
|
||||
finalParams['token'] = token;
|
||||
finalParams['username'] = username;
|
||||
queryParams['token'] = token;
|
||||
queryParams['username'] = username;
|
||||
}
|
||||
// Guest mode: proceed without token — let backend decide
|
||||
}
|
||||
|
||||
if (params != null) finalParams.addAll(params);
|
||||
if (params != null) {
|
||||
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
|
||||
}
|
||||
|
||||
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
||||
final uri = originalUri.replace(queryParameters: queryParams);
|
||||
|
||||
late http.Response response;
|
||||
try {
|
||||
@@ -133,7 +210,7 @@ class ApiClient {
|
||||
throw Exception('Network error: $e');
|
||||
}
|
||||
|
||||
return _handleResponse(url, response, finalParams);
|
||||
return _handleResponse(url, response, queryParams);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,8 +227,8 @@ class ApiClient {
|
||||
'end_date': '2026-04-16',
|
||||
'start_time': '09:00',
|
||||
'end_time': '18:00',
|
||||
'pincode': '560001',
|
||||
'place': 'Bengaluru International Exhibition Centre',
|
||||
'pincode': '680001',
|
||||
'place': 'Thekkinkadu Maidanam',
|
||||
'is_bookable': true,
|
||||
'event_type': 5,
|
||||
'thumb_img': 'https://picsum.photos/seed/event1/600/400',
|
||||
@@ -160,11 +237,11 @@ class ApiClient {
|
||||
{'is_primary': false, 'image': 'https://picsum.photos/seed/event1b/800/500'},
|
||||
],
|
||||
'important_information': 'Please carry a valid photo ID for entry.',
|
||||
'venue_name': 'BIEC Hall 2',
|
||||
'venue_name': 'Maidanam Grounds',
|
||||
'event_status': 'active',
|
||||
'latitude': 13.0147,
|
||||
'longitude': 77.5636,
|
||||
'location_name': 'Bengaluru',
|
||||
'latitude': 10.5276,
|
||||
'longitude': 76.2144,
|
||||
'location_name': 'Thrissur',
|
||||
'important_info': [
|
||||
{'title': 'Entry', 'value': 'Free with registration'},
|
||||
{'title': 'Parking', 'value': 'Available on-site'},
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
class ApiEndpoints {
|
||||
// Change this to your desired backend base URL (local or UAT)
|
||||
// For local Django dev use: "http://127.0.0.1:8000/api"
|
||||
// For UAT: "https://uat.eventifyplus.com/api"
|
||||
static const String baseUrl = "https://em.eventifyplus.com/api";
|
||||
// em.eventifyplus.com / uat.eventifyplus.com DNS → K8s, broken TLS. backend.eventifyplus.com → EC2 174.129.72.160, valid cert.
|
||||
static const String baseUrl = "https://backend.eventifyplus.com/api";
|
||||
|
||||
/// Base URL for media files (images, icons uploaded via Django admin).
|
||||
/// Relative paths like `/media/...` are resolved against this.
|
||||
static const String mediaBaseUrl = "https://em.eventifyplus.com";
|
||||
static const String mediaBaseUrl = "https://backend.eventifyplus.com";
|
||||
|
||||
// Auth
|
||||
static const String register = "$baseUrl/user/register/";
|
||||
static const String login = "$baseUrl/user/login/";
|
||||
static const String logout = "$baseUrl/user/logout/";
|
||||
static const String status = "$baseUrl/user/status/";
|
||||
static const String updateProfile = "$baseUrl/user/update-profile/";
|
||||
static const String forgotPassword = "$baseUrl/user/forgot-password/";
|
||||
|
||||
// Events
|
||||
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
|
||||
@@ -22,17 +24,46 @@ class ApiEndpoints {
|
||||
static const String eventImages = "$baseUrl/events/event-images/"; // event-images
|
||||
static const String eventsByCategory = "$baseUrl/events/events-by-category/";
|
||||
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
|
||||
static const String featuredEvents = "$baseUrl/events/featured-events/";
|
||||
static const String topEvents = "$baseUrl/events/top-events/";
|
||||
|
||||
// Bookings
|
||||
// static const String bookEvent = "$baseUrl/events/book-event/";
|
||||
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
|
||||
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
|
||||
|
||||
// Gamification / Contributor Module (TechDocs v2)
|
||||
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
|
||||
static const String leaderboard = "$baseUrl/v1/leaderboard/";
|
||||
static const String shopItems = "$baseUrl/v1/shop/items/";
|
||||
static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
|
||||
static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
|
||||
static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
|
||||
// Reviews (served by Node.js backend via app.eventifyplus.com)
|
||||
static const String _reviewBase = "https://app.eventifyplus.com/api/reviews";
|
||||
static const String reviewSubmit = "$_reviewBase/submit";
|
||||
static const String reviewList = "$_reviewBase/list";
|
||||
static const String reviewHelpful = "$_reviewBase/helpful";
|
||||
static const String reviewFlag = "$_reviewBase/flag";
|
||||
|
||||
// Node.js gamification server (same host as reviews)
|
||||
static const String _nodeBase = "https://app.eventifyplus.com/api";
|
||||
|
||||
// File upload (Node.js — routes to OneDrive or GDrive via STORAGE_BACKEND env)
|
||||
static const String uploadFile = "$_nodeBase/v1/upload/file";
|
||||
|
||||
// Gamification / Contributor Module
|
||||
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
|
||||
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
|
||||
static const String shopItems = "$_nodeBase/v1/shop/items";
|
||||
static const String shopRedeem = "$_nodeBase/v1/shop/redeem";
|
||||
static const String contributeSubmit = "$_nodeBase/v1/gamification/submit-event";
|
||||
static const String gradeContribution = "$_nodeBase/v1/admin/contributions/"; // append {id}/grade/
|
||||
|
||||
// Bookings
|
||||
static const String ticketMetaList = "$baseUrl/bookings/ticket-meta/list/";
|
||||
static const String cartAdd = "$baseUrl/bookings/cart/add/";
|
||||
static const String checkout = "$baseUrl/bookings/checkout/";
|
||||
static const String checkIn = "$baseUrl/bookings/check-in/";
|
||||
|
||||
// Auth - Google OAuth
|
||||
static const String googleLogin = "$baseUrl/user/google-login/";
|
||||
|
||||
// Notifications
|
||||
static const String notificationList = "$baseUrl/notifications/list/";
|
||||
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
|
||||
static const String notificationCount = "$baseUrl/notifications/count";
|
||||
}
|
||||
|
||||
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 {
|
||||
await _authService.logout();
|
||||
_user = null;
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// lib/features/auth/services/auth_service.dart
|
||||
import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../../../core/auth/auth_guard.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../../../core/analytics/posthog_service.dart';
|
||||
import '../models/user_model.dart';
|
||||
|
||||
class AuthService {
|
||||
final ApiClient _api = ApiClient();
|
||||
|
||||
/// Google OAuth 2.0 Web Client ID from Google Cloud Console.
|
||||
/// Must match the `GOOGLE_CLIENT_ID` env var set on the Django backend
|
||||
/// so the server can verify the `id_token` audience.
|
||||
/// Source: Google Cloud Console → APIs & Services → Credentials → Web application.
|
||||
static const String _googleWebClientId =
|
||||
'639347358523-mtkm3i8vssuhsun80rp2llt09eou0p8g.apps.googleusercontent.com';
|
||||
|
||||
/// LOGIN → returns UserModel
|
||||
Future<UserModel> login(String username, String password) async {
|
||||
try {
|
||||
@@ -58,6 +67,24 @@ class AuthService {
|
||||
// Save phone if provided (optional)
|
||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||
|
||||
// Save profile photo from login response
|
||||
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||
if (rawPhoto.isNotEmpty) {
|
||||
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||
await prefs.setString('profileImage_$savedEmail', photoUrl);
|
||||
await prefs.setString('profileImage', photoUrl);
|
||||
}
|
||||
|
||||
// Save Eventify ID
|
||||
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||
|
||||
PostHogService.instance.identify(savedEmail, properties: {
|
||||
'username': displayCandidate,
|
||||
'login_method': 'email',
|
||||
});
|
||||
PostHogService.instance.capture('user_logged_in');
|
||||
|
||||
return UserModel.fromJson(res);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('AuthService.login error: $e');
|
||||
@@ -70,15 +97,20 @@ class AuthService {
|
||||
required String email,
|
||||
required String phoneNumber,
|
||||
required String password,
|
||||
String? district,
|
||||
}) async {
|
||||
try {
|
||||
final body = <String, dynamic>{
|
||||
"email": email,
|
||||
"phone_number": phoneNumber,
|
||||
"password": password,
|
||||
};
|
||||
if (district != null && district.isNotEmpty) {
|
||||
body["district"] = district;
|
||||
}
|
||||
final res = await _api.post(
|
||||
ApiEndpoints.register,
|
||||
body: {
|
||||
"email": email,
|
||||
"phone_number": phoneNumber,
|
||||
"password": password,
|
||||
},
|
||||
body: body,
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
@@ -130,6 +162,79 @@ class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/// GOOGLE OAUTH LOGIN → returns UserModel
|
||||
Future<UserModel> googleLogin() async {
|
||||
try {
|
||||
final googleSignIn = GoogleSignIn(
|
||||
scopes: const ['email', 'profile'],
|
||||
serverClientId: _googleWebClientId,
|
||||
);
|
||||
final account = await googleSignIn.signIn();
|
||||
if (account == null) throw Exception('Google sign-in cancelled');
|
||||
|
||||
final auth = await account.authentication;
|
||||
final idToken = auth.idToken;
|
||||
if (idToken == null) throw Exception('Failed to get Google ID token');
|
||||
|
||||
final res = await _api.post(
|
||||
ApiEndpoints.googleLogin,
|
||||
body: {'id_token': idToken},
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
final token = res['token'];
|
||||
if (token == null) throw Exception('Token missing from response');
|
||||
|
||||
final serverEmail = (res['email'] as String?) ?? account.email;
|
||||
final displayName = (res['username'] as String?) ?? account.displayName ?? serverEmail;
|
||||
|
||||
AuthGuard.setGuest(false);
|
||||
await TokenStorage.saveToken(token.toString(), serverEmail);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('current_email', serverEmail);
|
||||
await prefs.setString('email', serverEmail);
|
||||
final perKey = 'display_name_$serverEmail';
|
||||
if ((prefs.getString(perKey) ?? '').isEmpty) {
|
||||
await prefs.setString(perKey, displayName);
|
||||
}
|
||||
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
|
||||
|
||||
// Save profile photo from Google login response
|
||||
final rawPhoto = res['profile_photo']?.toString() ?? '';
|
||||
if (rawPhoto.isNotEmpty) {
|
||||
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
|
||||
await prefs.setString('profileImage_$serverEmail', photoUrl);
|
||||
await prefs.setString('profileImage', photoUrl);
|
||||
}
|
||||
|
||||
// Save Eventify ID
|
||||
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
|
||||
|
||||
PostHogService.instance.identify(serverEmail, properties: {
|
||||
'username': displayName,
|
||||
'login_method': 'google',
|
||||
});
|
||||
PostHogService.instance.capture('user_logged_in');
|
||||
|
||||
return UserModel.fromJson(res);
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('AuthService.googleLogin error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// FORGOT PASSWORD → backend sends reset instructions by email.
|
||||
/// Frontend never leaks whether the email is registered — same UX on success and 404.
|
||||
Future<void> forgotPassword(String email) async {
|
||||
await _api.post(
|
||||
ApiEndpoints.forgotPassword,
|
||||
body: {'email': email},
|
||||
requiresAuth: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Logout – clear auth token and current_email (keep per-account display_name entries so they persist)
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
@@ -141,6 +246,8 @@ class AuthService {
|
||||
// Also remove canonical 'email' pointing to current user
|
||||
await prefs.remove('email');
|
||||
// Do not delete display_name_<email> entries — they are per-account and should remain on device.
|
||||
PostHogService.instance.capture('user_logged_out');
|
||||
PostHogService.instance.reset();
|
||||
} catch (e) {
|
||||
if (kDebugMode) debugPrint('AuthService.logout warning: $e');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,19 @@ class EventModel {
|
||||
// Structured important info list [{title, value}, ...]
|
||||
final List<Map<String, String>> importantInfo;
|
||||
|
||||
// Review stats (populated when backend includes them)
|
||||
final double? averageRating;
|
||||
final int? reviewCount;
|
||||
|
||||
// Contributor fields (EVT-001)
|
||||
final String? contributorId;
|
||||
final String? contributorName;
|
||||
final String? contributorTier;
|
||||
|
||||
// Curation flags
|
||||
final bool isFeatured;
|
||||
final bool isTopEvent;
|
||||
|
||||
EventModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -91,6 +104,13 @@ class EventModel {
|
||||
this.longitude,
|
||||
this.locationName,
|
||||
this.importantInfo = const [],
|
||||
this.averageRating,
|
||||
this.reviewCount,
|
||||
this.contributorId,
|
||||
this.contributorName,
|
||||
this.contributorTier,
|
||||
this.isFeatured = false,
|
||||
this.isTopEvent = false,
|
||||
});
|
||||
|
||||
/// Safely parse a double from backend (may arrive as String or num)
|
||||
@@ -148,6 +168,13 @@ class EventModel {
|
||||
longitude: _parseDouble(j['longitude']),
|
||||
locationName: j['location_name'] as String?,
|
||||
importantInfo: _parseImportantInfo(j['important_info']),
|
||||
averageRating: (j['average_rating'] as num?)?.toDouble(),
|
||||
reviewCount: (j['review_count'] as num?)?.toInt(),
|
||||
contributorId: j['contributor_id']?.toString(),
|
||||
contributorName: j['contributor_name'] as String?,
|
||||
contributorTier: j['contributor_tier'] as String?,
|
||||
isFeatured: j['is_featured'] == true || j['is_featured']?.toString().toLowerCase() == 'true',
|
||||
isTopEvent: j['is_top_event'] == true || j['is_top_event']?.toString().toLowerCase() == 'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,11 @@ class EventsService {
|
||||
/// 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}) async {
|
||||
// Use cache for 'all' pincode queries (first page only for initial load)
|
||||
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) {
|
||||
@@ -56,6 +57,8 @@ class EventsService {
|
||||
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,
|
||||
@@ -77,12 +80,81 @@ class EventsService {
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Event details
|
||||
/// Event details — requiresAuth: false so guests can fetch full details
|
||||
Future<EventModel> getEventDetails(int eventId) async {
|
||||
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: true);
|
||||
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
|
||||
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
||||
}
|
||||
|
||||
/// Related events by event_type_id (EVT-002).
|
||||
/// Fetches events with the same category, silently returns [] on failure.
|
||||
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
|
||||
try {
|
||||
final res = await _api.post(
|
||||
ApiEndpoints.eventsByCategory,
|
||||
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
|
||||
requiresAuth: false,
|
||||
);
|
||||
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
|
||||
if (results is List) {
|
||||
return results
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// silently fail — related events are non-critical
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get events by GPS coordinates using haversine distance filtering.
|
||||
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
|
||||
final body = {
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
'radius_km': radiusKm,
|
||||
'page': 1,
|
||||
'page_size': 50,
|
||||
'per_type': 5,
|
||||
};
|
||||
final res = await _api.post(ApiEndpoints.eventsByPincode, body: body, requiresAuth: false);
|
||||
final list = <EventModel>[];
|
||||
final events = res['events'] ?? res['data'] ?? [];
|
||||
if (events is List) {
|
||||
for (final e in events) {
|
||||
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Featured events for the home screen hero carousel.
|
||||
Future<List<EventModel>> getFeaturedEvents() async {
|
||||
final res = await _api.post(ApiEndpoints.featuredEvents, requiresAuth: false);
|
||||
final events = res['events'] ?? res['data'] ?? [];
|
||||
if (events is List) {
|
||||
return events
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Top events for the home screen top events section.
|
||||
Future<List<EventModel>> getTopEvents() async {
|
||||
final res = await _api.post(ApiEndpoints.topEvents, requiresAuth: false);
|
||||
final events = res['events'] ?? res['data'] ?? [];
|
||||
if (events is List) {
|
||||
return events
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
||||
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// lib/features/gamification/models/gamification_models.dart
|
||||
// Data models matching TechDocs v2 DB schema for the Contributor Module.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,13 +70,21 @@ int tierStartEp(ContributorTier tier) {
|
||||
// ---------------------------------------------------------------------------
|
||||
class UserGamificationProfile {
|
||||
final String userId;
|
||||
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
|
||||
final int currentEp; // Liquid EP accumulated this month.
|
||||
final int currentRp; // Spendable Reward Points.
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
final String? district;
|
||||
final String? eventifyId;
|
||||
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
|
||||
final int currentEp; // Liquid EP accumulated this month.
|
||||
final int currentRp; // Spendable Reward Points.
|
||||
final ContributorTier tier;
|
||||
|
||||
const UserGamificationProfile({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
this.avatarUrl,
|
||||
this.district,
|
||||
this.eventifyId,
|
||||
required this.lifetimeEp,
|
||||
required this.currentEp,
|
||||
required this.currentRp,
|
||||
@@ -82,53 +92,181 @@ class UserGamificationProfile {
|
||||
});
|
||||
|
||||
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
|
||||
final ep = (json['lifetime_ep'] as int?) ?? 0;
|
||||
debugPrint('Mapping UserGamificationProfile from JSON: $json');
|
||||
final ep = (json['lifetime_ep'] as int?) ?? (json['points'] as int?) ?? (json['total_points'] as int?) ?? 0;
|
||||
return UserGamificationProfile(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
userId: (json['user_id'] ?? json['email'] ?? json['userId'] ?? '').toString(),
|
||||
username: (json['username'] ?? json['name'] ?? json['full_name'] ?? json['display_name'] ?? '').toString(),
|
||||
avatarUrl: json['profile_image'] as String? ?? json['avatar_url'] as String? ?? json['profile_pic'] as String?,
|
||||
district: json['district'] as String? ?? json['location'] as String?,
|
||||
eventifyId: (json['eventify_id'] ?? json['eventifyId'] ?? json['id'] ?? '').toString(),
|
||||
lifetimeEp: ep,
|
||||
currentEp: (json['current_ep'] as int?) ?? 0,
|
||||
currentRp: (json['current_rp'] as int?) ?? 0,
|
||||
currentEp: (json['current_ep'] as int?) ?? (json['monthly_points'] as int?) ?? (json['points_this_month'] as int?) ?? 0,
|
||||
currentRp: (json['current_rp'] as int?) ?? (json['reward_points'] as int?) ?? (json['rp'] as int?) ?? 0,
|
||||
tier: tierFromEp(ep),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LeaderboardEntry
|
||||
// LeaderboardEntry — maps from Node.js API response fields
|
||||
// ---------------------------------------------------------------------------
|
||||
class LeaderboardEntry {
|
||||
final int rank;
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
final int lifetimeEp;
|
||||
final int monthlyPoints;
|
||||
final ContributorTier tier;
|
||||
final int eventsCount;
|
||||
final bool isCurrentUser;
|
||||
final String? district;
|
||||
|
||||
const LeaderboardEntry({
|
||||
required this.rank,
|
||||
required this.username,
|
||||
this.avatarUrl,
|
||||
required this.lifetimeEp,
|
||||
this.monthlyPoints = 0,
|
||||
required this.tier,
|
||||
required this.eventsCount,
|
||||
this.isCurrentUser = false,
|
||||
this.district,
|
||||
});
|
||||
|
||||
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
|
||||
final ep = (json['lifetime_ep'] as int?) ?? 0;
|
||||
// Node.js API returns 'points' for lifetime EP and 'name' for username
|
||||
final ep = (json['points'] as num?)?.toInt() ?? (json['lifetime_ep'] as num?)?.toInt() ?? 0;
|
||||
final tierStr = json['level'] as String? ?? json['tier'] as String?;
|
||||
return LeaderboardEntry(
|
||||
rank: (json['rank'] as int?) ?? 0,
|
||||
username: json['username'] as String? ?? '',
|
||||
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||
username: json['name'] as String? ?? json['username'] as String? ?? '',
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
lifetimeEp: ep,
|
||||
tier: tierFromEp(ep),
|
||||
eventsCount: (json['events_count'] as int?) ?? 0,
|
||||
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
|
||||
tier: tierStr != null ? _tierFromString(tierStr) : tierFromEp(ep),
|
||||
eventsCount: (json['eventsAdded'] as num?)?.toInt() ?? (json['events_count'] as num?)?.toInt() ?? 0,
|
||||
isCurrentUser: (json['is_current_user'] as bool?) ?? false,
|
||||
district: json['district'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse tier string from API (e.g. "Gold") to enum.
|
||||
ContributorTier _tierFromString(String s) {
|
||||
switch (s.toLowerCase()) {
|
||||
case 'diamond': return ContributorTier.DIAMOND;
|
||||
case 'platinum': return ContributorTier.PLATINUM;
|
||||
case 'gold': return ContributorTier.GOLD;
|
||||
case 'silver': return ContributorTier.SILVER;
|
||||
default: return ContributorTier.BRONZE;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CurrentUserStats — from leaderboard API's currentUser field
|
||||
// ---------------------------------------------------------------------------
|
||||
class CurrentUserStats {
|
||||
final int rank;
|
||||
final int points;
|
||||
final int monthlyPoints;
|
||||
final String level;
|
||||
final int rewardCycleDays;
|
||||
final int eventsAdded;
|
||||
final String? district;
|
||||
|
||||
const CurrentUserStats({
|
||||
required this.rank,
|
||||
required this.points,
|
||||
this.monthlyPoints = 0,
|
||||
required this.level,
|
||||
this.rewardCycleDays = 0,
|
||||
this.eventsAdded = 0,
|
||||
this.district,
|
||||
});
|
||||
|
||||
factory CurrentUserStats.fromJson(Map<String, dynamic> json) {
|
||||
return CurrentUserStats(
|
||||
rank: (json['rank'] as num?)?.toInt() ?? 0,
|
||||
points: (json['points'] as num?)?.toInt() ?? 0,
|
||||
monthlyPoints: (json['monthlyPoints'] as num?)?.toInt() ?? 0,
|
||||
level: json['level'] as String? ?? 'Bronze',
|
||||
rewardCycleDays: (json['rewardCycleDays'] as num?)?.toInt() ?? 0,
|
||||
eventsAdded: (json['eventsAdded'] as num?)?.toInt() ?? 0,
|
||||
district: json['district'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LeaderboardResponse — wraps the full leaderboard API response
|
||||
// ---------------------------------------------------------------------------
|
||||
class LeaderboardResponse {
|
||||
final List<LeaderboardEntry> entries;
|
||||
final CurrentUserStats? currentUser;
|
||||
final int totalParticipants;
|
||||
|
||||
const LeaderboardResponse({
|
||||
required this.entries,
|
||||
this.currentUser,
|
||||
this.totalParticipants = 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubmissionModel — event submissions from dashboard API
|
||||
// ---------------------------------------------------------------------------
|
||||
class SubmissionModel {
|
||||
final String id;
|
||||
final String eventName;
|
||||
final String category;
|
||||
final String status; // PENDING, APPROVED, REJECTED
|
||||
final String? district;
|
||||
final int epAwarded;
|
||||
final DateTime createdAt;
|
||||
final List<String> images;
|
||||
|
||||
const SubmissionModel({
|
||||
required this.id,
|
||||
required this.eventName,
|
||||
this.category = '',
|
||||
required this.status,
|
||||
this.district,
|
||||
this.epAwarded = 0,
|
||||
required this.createdAt,
|
||||
this.images = const [],
|
||||
});
|
||||
|
||||
factory SubmissionModel.fromJson(Map<String, dynamic> json) {
|
||||
final rawImages = json['images'] as List? ?? [];
|
||||
return SubmissionModel(
|
||||
id: (json['id'] ?? json['submission_id'] ?? '').toString(),
|
||||
eventName: json['event_name'] as String? ?? '',
|
||||
category: json['category'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'PENDING',
|
||||
district: json['district'] as String?,
|
||||
epAwarded: (json['total_ep_awarded'] as num?)?.toInt() ?? (json['ep_awarded'] as num?)?.toInt() ?? 0,
|
||||
createdAt: DateTime.tryParse(json['created_at'] as String? ?? '') ?? DateTime.now(),
|
||||
images: rawImages.map((e) => e.toString()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DashboardResponse — wraps the full dashboard API response
|
||||
// ---------------------------------------------------------------------------
|
||||
class DashboardResponse {
|
||||
final UserGamificationProfile profile;
|
||||
final List<SubmissionModel> submissions;
|
||||
final List<AchievementBadge> achievements;
|
||||
|
||||
const DashboardResponse({
|
||||
required this.profile,
|
||||
this.submissions = const [],
|
||||
this.achievements = const [],
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ShopItem — mirrors `RedeemShopItem` table
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -209,4 +347,15 @@ class AchievementBadge {
|
||||
required this.isUnlocked,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
factory AchievementBadge.fromJson(Map<String, dynamic> json) {
|
||||
return AchievementBadge(
|
||||
id: (json['id'] ?? json['badge_id'] ?? '').toString(),
|
||||
title: (json['title'] ?? json['name'] ?? '').toString(),
|
||||
description: (json['description'] ?? '').toString(),
|
||||
iconName: (json['icon_name'] ?? json['icon'] ?? 'star').toString(),
|
||||
isUnlocked: json['is_unlocked'] == true || json['unlocked'] == true,
|
||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// lib/features/gamification/providers/gamification_provider.dart
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../core/utils/error_utils.dart';
|
||||
import '../models/gamification_models.dart';
|
||||
import '../services/gamification_service.dart';
|
||||
import '../../events/services/events_service.dart';
|
||||
import '../../events/models/event_models.dart';
|
||||
|
||||
class GamificationProvider extends ChangeNotifier {
|
||||
final GamificationService _service = GamificationService();
|
||||
@@ -11,54 +14,148 @@ class GamificationProvider extends ChangeNotifier {
|
||||
UserGamificationProfile? profile;
|
||||
List<LeaderboardEntry> leaderboard = [];
|
||||
List<ShopItem> shopItems = [];
|
||||
List<AchievementBadge> achievements = [];
|
||||
List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
|
||||
List<SubmissionModel> submissions = [];
|
||||
CurrentUserStats? currentUserStats;
|
||||
int totalParticipants = 0;
|
||||
List<String> eventCategories = [];
|
||||
|
||||
// Leaderboard filters — matches web version
|
||||
String leaderboardDistrict = 'Overall Kerala';
|
||||
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
|
||||
|
||||
bool isLoading = false;
|
||||
bool isLeaderboardLoading = false;
|
||||
String? error;
|
||||
|
||||
// TTL guard — prevents redundant API calls from multiple screens
|
||||
DateTime? _lastLoadTime;
|
||||
static const _loadTtl = Duration(minutes: 2);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load everything at once (called when ContributeScreen is mounted)
|
||||
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> loadAll() async {
|
||||
Future<void> loadAll({bool force = false}) async {
|
||||
debugPrint('GamificationProvider.loadAll(force: $force) called');
|
||||
// Skip if recently loaded (within 2 minutes) unless forced or profile is null
|
||||
if (!force && profile != null && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
||||
debugPrint('GamificationProvider.loadAll skipped due to TTL');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
debugPrint('GamificationProvider: Requesting dashboard, leaderboard, etc...');
|
||||
final results = await Future.wait([
|
||||
_service.getProfile(),
|
||||
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
||||
_service.getShopItems(),
|
||||
_service.getAchievements(),
|
||||
_service.getDashboard().catchError((e) {
|
||||
debugPrint('Dashboard error: $e');
|
||||
return const DashboardResponse(
|
||||
profile: UserGamificationProfile(
|
||||
userId: '',
|
||||
username: '',
|
||||
lifetimeEp: 0,
|
||||
currentEp: 0,
|
||||
currentRp: 0,
|
||||
tier: ContributorTier.BRONZE,
|
||||
),
|
||||
);
|
||||
}),
|
||||
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod).catchError((e) {
|
||||
debugPrint('Leaderboard error: $e');
|
||||
return const LeaderboardResponse(entries: []);
|
||||
}),
|
||||
_service.getShopItems().catchError((e) {
|
||||
debugPrint('Shop error: $e');
|
||||
return <ShopItem>[];
|
||||
}),
|
||||
_service.getAchievements().catchError((e) {
|
||||
debugPrint('Achievements error: $e');
|
||||
return <AchievementBadge>[];
|
||||
}),
|
||||
EventsService().getEventTypes().catchError((e) {
|
||||
debugPrint('EventTypes error: $e');
|
||||
return <EventTypeModel>[];
|
||||
}),
|
||||
]);
|
||||
|
||||
profile = results[0] as UserGamificationProfile;
|
||||
leaderboard = results[1] as List<LeaderboardEntry>;
|
||||
shopItems = results[2] as List<ShopItem>;
|
||||
achievements = results[3] as List<AchievementBadge>;
|
||||
final dashboard = results[0] as DashboardResponse;
|
||||
profile = dashboard.profile;
|
||||
submissions = dashboard.submissions;
|
||||
|
||||
final lbResponse = results[1] as LeaderboardResponse;
|
||||
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
|
||||
currentUserStats = lbResponse.currentUser;
|
||||
totalParticipants = lbResponse.totalParticipants;
|
||||
|
||||
shopItems = results[2] as List<ShopItem>;
|
||||
|
||||
// Prefer achievements from dashboard API; fall back to fetched or existing defaults
|
||||
final dashAchievements = dashboard.achievements;
|
||||
final fetchedAchievements = results[3] as List<AchievementBadge>;
|
||||
|
||||
if (dashAchievements.isNotEmpty) {
|
||||
achievements = dashAchievements;
|
||||
} else if (fetchedAchievements.isNotEmpty) {
|
||||
achievements = fetchedAchievements;
|
||||
}
|
||||
|
||||
final eventTypes = results[4] as List<EventTypeModel>;
|
||||
if (eventTypes.isNotEmpty) {
|
||||
eventCategories = eventTypes.map((e) => e.name).toList();
|
||||
}
|
||||
// Otherwise, keep current defaults
|
||||
|
||||
_lastLoadTime = DateTime.now();
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
error = userFriendlyError(e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load leaderboard independently (decoupled from loadAll)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> loadLeaderboard() async {
|
||||
isLeaderboardLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final response = await _service.getLeaderboard(
|
||||
district: leaderboardDistrict,
|
||||
timePeriod: leaderboardTimePeriod,
|
||||
);
|
||||
leaderboard = response.entries;
|
||||
currentUserStats = response.currentUser;
|
||||
totalParticipants = response.totalParticipants;
|
||||
} catch (e) {
|
||||
error = userFriendlyError(e);
|
||||
} finally {
|
||||
isLeaderboardLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Change district filter
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> setDistrict(String district) async {
|
||||
if (leaderboardDistrict == district) return;
|
||||
leaderboardDistrict = district;
|
||||
isLeaderboardLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
|
||||
currentUserStats = response.currentUser;
|
||||
totalParticipants = response.totalParticipants;
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
error = userFriendlyError(e);
|
||||
} finally {
|
||||
isLeaderboardLoading = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -69,11 +166,17 @@ class GamificationProvider extends ChangeNotifier {
|
||||
Future<void> setTimePeriod(String period) async {
|
||||
if (leaderboardTimePeriod == period) return;
|
||||
leaderboardTimePeriod = period;
|
||||
isLeaderboardLoading = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
|
||||
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
|
||||
leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
|
||||
currentUserStats = response.currentUser;
|
||||
totalParticipants = response.totalParticipants;
|
||||
} catch (e) {
|
||||
error = e.toString();
|
||||
error = userFriendlyError(e);
|
||||
} finally {
|
||||
isLeaderboardLoading = false;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -88,6 +191,10 @@ class GamificationProvider extends ChangeNotifier {
|
||||
if (profile != null) {
|
||||
profile = UserGamificationProfile(
|
||||
userId: profile!.userId,
|
||||
username: profile!.username,
|
||||
avatarUrl: profile!.avatarUrl,
|
||||
district: profile!.district,
|
||||
eventifyId: profile!.eventifyId,
|
||||
lifetimeEp: profile!.lifetimeEp,
|
||||
currentEp: profile!.currentEp,
|
||||
currentRp: profile!.currentRp - item.rpCost,
|
||||
@@ -104,6 +211,10 @@ class GamificationProvider extends ChangeNotifier {
|
||||
if (profile != null) {
|
||||
profile = UserGamificationProfile(
|
||||
userId: profile!.userId,
|
||||
username: profile!.username,
|
||||
avatarUrl: profile!.avatarUrl,
|
||||
district: profile!.district,
|
||||
eventifyId: profile!.eventifyId,
|
||||
lifetimeEp: profile!.lifetimeEp,
|
||||
currentEp: profile!.currentEp,
|
||||
currentRp: profile!.currentRp + item.rpCost,
|
||||
@@ -121,4 +232,41 @@ class GamificationProvider extends ChangeNotifier {
|
||||
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||
await _service.submitContribution(data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Filter by district and re-rank results locally.
|
||||
// This is a fallback in case the backend returns a global list for a district-specific query.
|
||||
// ---------------------------------------------------------------------------
|
||||
List<LeaderboardEntry> _filterAndReRank(List<LeaderboardEntry> entries, String district, String period) {
|
||||
if (entries.isEmpty) return [];
|
||||
|
||||
List<LeaderboardEntry> result = entries;
|
||||
if (district != 'Overall Kerala') {
|
||||
// Case-insensitive filtering to be robust
|
||||
result = entries.where((e) => e.district?.toLowerCase() == district.toLowerCase()).toList();
|
||||
}
|
||||
|
||||
// Sort based on period
|
||||
if (period == 'this_month') {
|
||||
result.sort((a, b) => b.monthlyPoints.compareTo(a.monthlyPoints));
|
||||
} else {
|
||||
result.sort((a, b) => b.lifetimeEp.compareTo(a.lifetimeEp));
|
||||
}
|
||||
|
||||
// Assign new ranks based on local sort order
|
||||
return List.generate(result.length, (i) {
|
||||
final e = result[i];
|
||||
return LeaderboardEntry(
|
||||
rank: i + 1,
|
||||
username: e.username,
|
||||
avatarUrl: e.avatarUrl,
|
||||
lifetimeEp: e.lifetimeEp,
|
||||
monthlyPoints: e.monthlyPoints,
|
||||
tier: e.tier,
|
||||
eventsCount: e.eventsCount,
|
||||
isCurrentUser: e.isCurrentUser,
|
||||
district: e.district,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,180 +1,208 @@
|
||||
// lib/features/gamification/services/gamification_service.dart
|
||||
//
|
||||
// Stub service using the real API contract from TechDocs v2.
|
||||
// All methods currently return mock data.
|
||||
// TODO: replace each mock block with a real ApiClient call once
|
||||
// the backend endpoints are live on uat.eventifyplus.com.
|
||||
// Real API service for the Contributor / Gamification module.
|
||||
// Calls the Node.js gamification server at app.eventifyplus.com.
|
||||
|
||||
import 'dart:math';
|
||||
import '../../../core/api/api_client.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../models/gamification_models.dart';
|
||||
|
||||
class GamificationService {
|
||||
final ApiClient _api = ApiClient();
|
||||
|
||||
/// Helper: get current user's email for API calls.
|
||||
Future<String> _getUserEmail() async {
|
||||
final email = await TokenStorage.getUsername();
|
||||
return email ?? '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User Gamification Profile
|
||||
// TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
|
||||
// Dashboard (profile + submissions)
|
||||
// GET /v1/gamification/dashboard?user_id={email}
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<UserGamificationProfile> getProfile() async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
return const UserGamificationProfile(
|
||||
userId: 'mock-user-001',
|
||||
lifetimeEp: 320,
|
||||
currentEp: 70,
|
||||
currentRp: 45,
|
||||
tier: ContributorTier.SILVER,
|
||||
Future<DashboardResponse> getDashboard() async {
|
||||
final email = await _getUserEmail();
|
||||
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(email)}';
|
||||
final res = await _api.get(url, requiresAuth: false);
|
||||
|
||||
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||
final rawSubs = res['submissions'] as List? ?? [];
|
||||
final rawAchievements = res['achievements'] as List? ?? [];
|
||||
|
||||
final submissions = rawSubs
|
||||
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||
.toList();
|
||||
|
||||
final achievements = rawAchievements
|
||||
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||
.toList();
|
||||
|
||||
return DashboardResponse(
|
||||
profile: UserGamificationProfile.fromJson(profileJson),
|
||||
submissions: submissions,
|
||||
achievements: achievements,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public contributor profile (any user by userId / email)
|
||||
// GET /v1/gamification/dashboard?user_id={userId}
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<DashboardResponse> getDashboardForUser(String userId) async {
|
||||
final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(userId)}';
|
||||
final res = await _api.get(url, requiresAuth: false);
|
||||
|
||||
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||
final rawSubs = res['submissions'] as List? ?? [];
|
||||
final rawAchievements = res['achievements'] as List? ?? [];
|
||||
|
||||
final submissions = rawSubs
|
||||
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||
.toList();
|
||||
|
||||
final achievements = rawAchievements
|
||||
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||
.toList();
|
||||
|
||||
return DashboardResponse(
|
||||
profile: UserGamificationProfile.fromJson(profileJson),
|
||||
submissions: submissions,
|
||||
achievements: achievements,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience — returns just the profile (backward-compatible with provider).
|
||||
Future<UserGamificationProfile> getProfile() async {
|
||||
final dashboard = await getDashboard();
|
||||
return dashboard.profile;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaderboard
|
||||
// district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
|
||||
// timePeriod: 'all_time' | 'this_month'
|
||||
// TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
|
||||
// GET /v1/gamification/leaderboard?period=all|month&district=X&user_id=Y&limit=50
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<List<LeaderboardEntry>> getLeaderboard({
|
||||
Future<LeaderboardResponse> getLeaderboard({
|
||||
required String district,
|
||||
required String timePeriod,
|
||||
}) async {
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
final email = await _getUserEmail();
|
||||
|
||||
// Realistic mock names per district
|
||||
final names = [
|
||||
'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
|
||||
'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
|
||||
'Kristin Watson', 'Guy Hawkins',
|
||||
];
|
||||
// Map Flutter filter values to API params
|
||||
final period = timePeriod == 'this_month' ? 'month' : 'all';
|
||||
|
||||
final rng = Random(district.hashCode ^ timePeriod.hashCode);
|
||||
final baseEp = timePeriod == 'this_month' ? 800 : 4500;
|
||||
final params = <String, String>{
|
||||
'period': period,
|
||||
'user_id': email,
|
||||
'limit': '50',
|
||||
};
|
||||
if (district != 'Overall Kerala') {
|
||||
params['district'] = district;
|
||||
}
|
||||
|
||||
final entries = List.generate(10, (i) {
|
||||
final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
|
||||
return LeaderboardEntry(
|
||||
rank: i + 1,
|
||||
username: names[i],
|
||||
lifetimeEp: ep,
|
||||
tier: tierFromEp(ep),
|
||||
eventsCount: 149 - i * 12,
|
||||
isCurrentUser: i == 7, // mock: current user is rank 8
|
||||
final query = Uri(queryParameters: params).query;
|
||||
final url = '${ApiEndpoints.leaderboard}?$query';
|
||||
final res = await _api.get(url, requiresAuth: false);
|
||||
|
||||
final rawList = res['leaderboard'] as List? ?? [];
|
||||
final entries = rawList
|
||||
.map((e) => LeaderboardEntry.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
|
||||
CurrentUserStats? currentUser;
|
||||
if (res['currentUser'] != null && res['currentUser'] is Map) {
|
||||
currentUser = CurrentUserStats.fromJson(
|
||||
Map<String, dynamic>.from(res['currentUser'] as Map),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redeem Shop Items
|
||||
// TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<List<ShopItem>> getShopItems() async {
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
return const [
|
||||
ShopItem(
|
||||
id: 'item-001',
|
||||
name: 'Amazon ₹500 Voucher',
|
||||
description: 'Redeem for any purchase on Amazon India.',
|
||||
rpCost: 50,
|
||||
stockQuantity: 20,
|
||||
),
|
||||
ShopItem(
|
||||
id: 'item-002',
|
||||
name: 'Swiggy ₹200 Voucher',
|
||||
description: 'Free food delivery credit on Swiggy.',
|
||||
rpCost: 20,
|
||||
stockQuantity: 35,
|
||||
),
|
||||
ShopItem(
|
||||
id: 'item-003',
|
||||
name: 'Eventify Pro — 1 Month',
|
||||
description: 'Premium access to Eventify.Plus features.',
|
||||
rpCost: 30,
|
||||
stockQuantity: 100,
|
||||
),
|
||||
ShopItem(
|
||||
id: 'item-004',
|
||||
name: 'Zomato ₹150 Voucher',
|
||||
description: 'Discount on your next Zomato order.',
|
||||
rpCost: 15,
|
||||
stockQuantity: 50,
|
||||
),
|
||||
ShopItem(
|
||||
id: 'item-005',
|
||||
name: 'BookMyShow ₹300 Voucher',
|
||||
description: 'Movie & event ticket credit on BookMyShow.',
|
||||
rpCost: 30,
|
||||
stockQuantity: 15,
|
||||
),
|
||||
ShopItem(
|
||||
id: 'item-006',
|
||||
name: 'Exclusive Badge',
|
||||
description: 'Rare "Pioneer" badge for your profile.',
|
||||
rpCost: 5,
|
||||
stockQuantity: 0, // out of stock
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redeem an item
|
||||
// TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<RedemptionRecord> redeemItem(String itemId) async {
|
||||
await Future.delayed(const Duration(milliseconds: 600));
|
||||
// Generate a fake voucher code
|
||||
final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
|
||||
return RedemptionRecord(
|
||||
id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
|
||||
itemId: itemId,
|
||||
rpSpent: 0, // provider will look up cost
|
||||
voucherCode: code,
|
||||
timestamp: DateTime.now(),
|
||||
return LeaderboardResponse(
|
||||
entries: entries,
|
||||
currentUser: currentUser,
|
||||
totalParticipants: (res['totalParticipants'] as num?)?.toInt() ?? entries.length,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit Contribution
|
||||
// TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
|
||||
// Shop Items
|
||||
// GET /v1/shop/items
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
// Mock always succeeds
|
||||
Future<List<ShopItem>> getShopItems() async {
|
||||
final res = await _api.get(ApiEndpoints.shopItems, requiresAuth: false);
|
||||
final rawItems = res['items'] as List? ?? [];
|
||||
return rawItems
|
||||
.map((e) => ShopItem.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements
|
||||
// Redeem Item
|
||||
// POST /v1/shop/redeem body: { user_id, item_id }
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<RedemptionRecord> redeemItem(String itemId) async {
|
||||
final email = await _getUserEmail();
|
||||
final res = await _api.post(
|
||||
ApiEndpoints.shopRedeem,
|
||||
body: {'user_id': email, 'item_id': itemId},
|
||||
requiresAuth: false,
|
||||
);
|
||||
final voucher = res['voucher'] as Map<String, dynamic>? ?? res;
|
||||
return RedemptionRecord.fromJson(Map<String, dynamic>.from(voucher));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit Contribution
|
||||
// 1. Upload each image to /v1/upload/file → get back { url, fileId, ... }
|
||||
// 2. POST /v1/gamification/submit-event with `media` (uploaded objects)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||
final email = await _getUserEmail();
|
||||
|
||||
// Upload images if present
|
||||
final rawPaths = (data['images'] as List?)?.cast<String>() ?? [];
|
||||
final List<Map<String, dynamic>> uploadedMedia = [];
|
||||
|
||||
for (final path in rawPaths) {
|
||||
final result = await _api.uploadFile(ApiEndpoints.uploadFile, path);
|
||||
uploadedMedia.add(result);
|
||||
}
|
||||
|
||||
// Build submission body — use `media` (server canonical field)
|
||||
final body = <String, dynamic>{
|
||||
'user_id': email,
|
||||
...Map.from(data)..remove('images'),
|
||||
if (uploadedMedia.isNotEmpty) 'media': uploadedMedia,
|
||||
};
|
||||
|
||||
await _api.post(
|
||||
ApiEndpoints.contributeSubmit,
|
||||
body: body,
|
||||
requiresAuth: false,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements — sourced from dashboard API `achievements` array.
|
||||
// Falls back to default badges if API doesn't return achievements yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<List<AchievementBadge>> getAchievements() async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
return const [
|
||||
AchievementBadge(
|
||||
id: 'badge-01', title: 'First Submission',
|
||||
description: 'Submitted your first event.',
|
||||
iconName: 'edit', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-02', title: 'Silver Streak',
|
||||
description: 'Reached Silver tier.',
|
||||
iconName: 'star', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-03', title: 'Gold Rush',
|
||||
description: 'Reach Gold tier (500 EP).',
|
||||
iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-04', title: 'Top 10',
|
||||
description: 'Appear in the district leaderboard top 10.',
|
||||
iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-05', title: 'Image Pro',
|
||||
description: 'Submit 10 events with 3+ images.',
|
||||
iconName: 'photo_library', isUnlocked: false, progress: 0.3,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-06', title: 'Pioneer',
|
||||
description: 'One of the first 100 contributors.',
|
||||
iconName: 'verified', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
];
|
||||
try {
|
||||
final dashboard = await getDashboard();
|
||||
if (dashboard.achievements.isNotEmpty) return dashboard.achievements;
|
||||
} catch (_) {
|
||||
// Fall through to defaults
|
||||
}
|
||||
return defaultBadges;
|
||||
}
|
||||
|
||||
static const defaultBadges = [
|
||||
AchievementBadge(id: 'badge-01', title: 'Newcomer', description: 'First Event Posted', iconName: 'star', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-02', title: 'Contributor', description: '10th Event Posted within a month', iconName: 'crown', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-03', title: 'On Fire!', description: '3 Day Streak of logging in', iconName: 'fire', isUnlocked: false, progress: 0.67),
|
||||
AchievementBadge(id: 'badge-04', title: 'Verified', description: 'Identity Verified successfully', iconName: 'verified', isUnlocked: true, progress: 1.0),
|
||||
AchievementBadge(id: 'badge-05', title: 'Quality', description: '5 Star Event Rating received', iconName: 'star', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-06', title: 'Community', description: 'Referred 5 Friends to the platform', iconName: 'community', isUnlocked: false, progress: 0.4),
|
||||
AchievementBadge(id: 'badge-07', title: 'Expert', description: 'Level 10 Reached in 3 months', iconName: 'expert', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-08', title: 'Precision', description: '100% Data Accuracy on all events', iconName: 'precision', isUnlocked: false, progress: 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
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,24 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/home_desktop_screen.dart';
|
||||
import 'screens/login_screen.dart';
|
||||
import 'screens/desktop_login_screen.dart';
|
||||
import 'screens/responsive_layout.dart'; // keep this path if your file is under lib/screens/
|
||||
import 'screens/responsive_layout.dart';
|
||||
import 'core/theme_manager.dart';
|
||||
import 'core/analytics/posthog_service.dart';
|
||||
import 'features/auth/providers/auth_provider.dart';
|
||||
import 'features/gamification/providers/gamification_provider.dart';
|
||||
import 'features/booking/providers/checkout_provider.dart';
|
||||
import 'features/notifications/providers/notification_provider.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await ThemeManager.init(); // load saved theme preference
|
||||
await PostHogService.instance.init();
|
||||
|
||||
// Increase image cache for smoother scrolling and faster re-renders
|
||||
PaintingBinding.instance.imageCache.maximumSize = 500;
|
||||
@@ -90,18 +97,26 @@ class MyApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
valueListenable: ThemeManager.themeMode,
|
||||
builder: (context, mode, _) {
|
||||
return MaterialApp(
|
||||
title: 'Event App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: _lightTheme(),
|
||||
darkTheme: _darkTheme(),
|
||||
themeMode: mode,
|
||||
home: const StartupScreen(),
|
||||
);
|
||||
},
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||
ChangeNotifierProvider(create: (_) => GamificationProvider()),
|
||||
ChangeNotifierProvider(create: (_) => CheckoutProvider()),
|
||||
ChangeNotifierProvider(create: (_) => NotificationProvider()),
|
||||
],
|
||||
child: ValueListenableBuilder<ThemeMode>(
|
||||
valueListenable: ThemeManager.themeMode,
|
||||
builder: (context, mode, _) {
|
||||
return MaterialApp(
|
||||
title: 'Event App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: _lightTheme(),
|
||||
darkTheme: _darkTheme(),
|
||||
themeMode: mode,
|
||||
home: const StartupScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// lib/screens/booking_screen.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'checkout_screen.dart';
|
||||
|
||||
class BookingScreen extends StatefulWidget {
|
||||
// Keep onBook in the constructor if you want to use it later, but we won't call it here.
|
||||
final VoidCallback? onBook;
|
||||
final String image;
|
||||
final int? eventId;
|
||||
final String? eventName;
|
||||
|
||||
const BookingScreen({
|
||||
Key? key,
|
||||
this.onBook,
|
||||
this.image = 'assets/images/event1.jpg',
|
||||
this.eventId,
|
||||
this.eventName,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -39,11 +43,22 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
|
||||
bool _booked = false;
|
||||
|
||||
void _performLocalBooking() {
|
||||
// mark locally booked (do NOT call widget.onBook())
|
||||
// If event data is available, navigate to real checkout
|
||||
if (widget.eventId != null) {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => CheckoutScreen(
|
||||
eventId: widget.eventId!,
|
||||
eventName: widget.eventName ?? 'Event',
|
||||
eventImage: widget.image,
|
||||
),
|
||||
));
|
||||
return;
|
||||
}
|
||||
// Fallback: demo booking for events without IDs
|
||||
if (!_booked) {
|
||||
setState(() => _booked = true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Tickets booked (demo)')),
|
||||
const SnackBar(content: Text('Tickets booked (coming soon)')),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -205,15 +220,15 @@ Whether you're a longtime fan or hearing him live for the first time, this eveni
|
||||
|
||||
// action icons (scanner / chat / call)
|
||||
_iconSquare(primary, Icons.qr_code_scanner, onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner tapped (demo)')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner (coming soon)')));
|
||||
}),
|
||||
SizedBox(width: 12),
|
||||
_iconSquare(primary, Icons.chat, onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat tapped (demo)')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat (coming soon)')));
|
||||
}),
|
||||
SizedBox(width: 12),
|
||||
_iconSquare(primary, Icons.call, onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call tapped (demo)')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call (coming soon)')));
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// lib/screens/calendar_screen.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../features/events/services/events_service.dart';
|
||||
@@ -94,7 +95,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingMonth = false);
|
||||
}
|
||||
@@ -117,7 +118,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
final events = await _service.getEventsForDate(yyyyMMdd);
|
||||
if (mounted) setState(() => _eventsOfDay = events);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingDay = false);
|
||||
}
|
||||
@@ -518,6 +519,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
imageUrl: imgUrl,
|
||||
memCacheWidth: 400,
|
||||
memCacheHeight: 300,
|
||||
maxWidthDiskCache: 800,
|
||||
maxHeightDiskCache: 600,
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
@@ -581,6 +584,8 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
imageUrl: imgUrl,
|
||||
memCacheWidth: 300,
|
||||
memCacheHeight: 300,
|
||||
maxWidthDiskCache: 600,
|
||||
maxHeightDiskCache: 600,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fit: BoxFit.cover,
|
||||
@@ -837,7 +842,7 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: InkWell(
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (coming soon)'))),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
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,6 +1,7 @@
|
||||
// lib/screens/desktop_login_screen.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
import '../features/auth/services/auth_service.dart';
|
||||
import '../core/auth/auth_guard.dart';
|
||||
import 'home_desktop_screen.dart';
|
||||
@@ -14,9 +15,23 @@ class DesktopLoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin {
|
||||
// Login controllers
|
||||
final TextEditingController _emailCtrl = TextEditingController();
|
||||
final TextEditingController _passCtrl = TextEditingController();
|
||||
|
||||
// Signup controllers
|
||||
final TextEditingController _signupEmailCtrl = TextEditingController();
|
||||
final TextEditingController _signupPhoneCtrl = TextEditingController();
|
||||
final TextEditingController _signupPassCtrl = TextEditingController();
|
||||
final TextEditingController _signupConfirmCtrl = TextEditingController();
|
||||
String? _signupDistrict;
|
||||
|
||||
static const _districts = [
|
||||
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||
];
|
||||
|
||||
final AuthService _auth = AuthService();
|
||||
|
||||
AnimationController? _controller;
|
||||
@@ -30,13 +45,18 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
final Curve _curve = Curves.easeInOutCubic;
|
||||
|
||||
bool _isAnimating = false;
|
||||
bool _loading = false; // network loading flag
|
||||
bool _loading = false;
|
||||
bool _isSignupMode = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_signupEmailCtrl.dispose();
|
||||
_signupPhoneCtrl.dispose();
|
||||
_signupPassCtrl.dispose();
|
||||
_signupConfirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -51,7 +71,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
_leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)),
|
||||
);
|
||||
|
||||
_formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
|
||||
);
|
||||
@@ -67,9 +86,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
Future<void> _performLoginFlow(double initialLeftWidth) async {
|
||||
if (_isAnimating || _loading) return;
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
});
|
||||
setState(() => _loading = true);
|
||||
|
||||
final email = _emailCtrl.text.trim();
|
||||
final password = _passCtrl.text;
|
||||
@@ -86,14 +103,9 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
}
|
||||
|
||||
try {
|
||||
// Capture user model returned by AuthService (AuthService already saves prefs)
|
||||
await _auth.login(email, password);
|
||||
|
||||
// on success run opening animation
|
||||
await _startCollapseAnimation(initialLeftWidth);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
||||
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
||||
transitionDuration: Duration.zero,
|
||||
@@ -101,24 +113,292 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message = e.toString().replaceAll('Exception: ', '');
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
setState(() => _isAnimating = false);
|
||||
} finally {
|
||||
if (mounted) setState(() {
|
||||
_loading = false;
|
||||
});
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _openRegister() {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen()));
|
||||
Future<void> _performSignupFlow(double initialLeftWidth) async {
|
||||
if (_isAnimating || _loading) return;
|
||||
|
||||
final email = _signupEmailCtrl.text.trim();
|
||||
final phone = _signupPhoneCtrl.text.trim();
|
||||
final pass = _signupPassCtrl.text;
|
||||
final confirm = _signupConfirmCtrl.text;
|
||||
|
||||
if (email.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
|
||||
return;
|
||||
}
|
||||
if (phone.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
|
||||
return;
|
||||
}
|
||||
if (pass.length < 6) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Password must be at least 6 characters')));
|
||||
return;
|
||||
}
|
||||
if (pass != confirm) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
await _auth.register(
|
||||
email: email,
|
||||
phoneNumber: phone,
|
||||
password: pass,
|
||||
district: _signupDistrict,
|
||||
);
|
||||
await _startCollapseAnimation(initialLeftWidth);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
||||
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
||||
transitionDuration: Duration.zero,
|
||||
reverseTransitionDuration: Duration.zero,
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
setState(() => _isAnimating = false);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openForgotPasswordDialog() async {
|
||||
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
|
||||
bool submitting = false;
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setDialog) {
|
||||
return AlertDialog(
|
||||
title: const Text('Forgot Password'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text("Enter your email and we'll send reset instructions."),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: emailCtrl,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.email),
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
|
||||
ElevatedButton(
|
||||
onPressed: submitting
|
||||
? null
|
||||
: () async {
|
||||
final email = emailCtrl.text.trim();
|
||||
if (email.isEmpty) return;
|
||||
setDialog(() => submitting = true);
|
||||
try {
|
||||
await _auth.forgotPassword(email);
|
||||
} catch (_) {
|
||||
// safe-degrade
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.of(ctx).pop();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("If that email is registered, we've sent reset instructions."),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: submitting
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('Send reset link'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
emailCtrl.dispose();
|
||||
}
|
||||
|
||||
Widget _buildLoginFields(double safeInitialWidth) {
|
||||
return Column(
|
||||
key: const ValueKey('login'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Sign In', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 6),
|
||||
const Text('Please enter your details to continue', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
|
||||
const SizedBox(height: 22),
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.email),
|
||||
labelText: 'Email Address',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(children: [
|
||||
Checkbox(value: true, onChanged: (_) {}),
|
||||
const Text('Remember me'),
|
||||
]),
|
||||
TextButton(onPressed: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isAnimating || _loading) ? null : () => _performLoginFlow(safeInitialWidth),
|
||||
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
child: (_isAnimating || _loading)
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text('Sign In', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => setState(() => _isSignupMode = true),
|
||||
child: const Text("Don't have an account? Register"),
|
||||
),
|
||||
TextButton(onPressed: () {}, child: const Text('Contact support')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
AuthGuard.setGuest(true);
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
child: const Text('Continue as Guest'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignupFields(double safeInitialWidth) {
|
||||
return Column(
|
||||
key: const ValueKey('signup'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Create Account', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 6),
|
||||
const Text('Fill in your details to get started', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
|
||||
const SizedBox(height: 22),
|
||||
TextField(
|
||||
controller: _signupEmailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.email),
|
||||
labelText: 'Email Address',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _signupPhoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.phone),
|
||||
labelText: 'Phone Number',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _signupDistrict,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
labelText: 'District (optional)',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
|
||||
onChanged: (v) => setState(() => _signupDistrict = v),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _signupPassCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _signupConfirmCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
labelText: 'Confirm Password',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isAnimating || _loading) ? null : () => _performSignupFlow(safeInitialWidth),
|
||||
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
child: (_isAnimating || _loading)
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text('Create Account', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() => _isSignupMode = false),
|
||||
child: const Text('Already have an account? Sign in'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
|
||||
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
|
||||
final bool animAvailable = _controller != null && _leftWidthAnim != null;
|
||||
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
|
||||
@@ -138,7 +418,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
Container(
|
||||
width: leftWidth,
|
||||
height: double.infinity,
|
||||
// color: const Color(0xFF0B63D6),
|
||||
decoration: AppDecoration.blueGradient,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
|
||||
child: Opacity(
|
||||
@@ -149,11 +428,16 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
const SizedBox(height: 4),
|
||||
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
|
||||
const Spacer(),
|
||||
const Text('Welcome Back!', style: TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
_isSignupMode ? 'Join Eventify!' : 'Welcome Back!',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Sign in to access your dashboard, manage events, and stay connected.',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||
Text(
|
||||
_isSignupMode
|
||||
? 'Create your account to discover events, book tickets, and connect with your community.'
|
||||
: 'Sign in to access your dashboard, manage events, and stay connected.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
),
|
||||
const Spacer(flex: 2),
|
||||
Opacity(
|
||||
@@ -167,7 +451,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Transform.translate(
|
||||
offset: Offset(formOffset, 0),
|
||||
@@ -177,85 +460,20 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('Sign In', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
|
||||
const SizedBox(height: 6),
|
||||
const Text('Please enter your details to continue', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
|
||||
const SizedBox(height: 22),
|
||||
TextField(
|
||||
controller: _emailCtrl,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.email),
|
||||
labelText: 'Email Address',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _passCtrl,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(children: [
|
||||
Checkbox(value: true, onChanged: (_) {}),
|
||||
const Text('Remember me')
|
||||
]),
|
||||
TextButton(onPressed: () {}, child: const Text('Forgot Password?'))
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isAnimating || _loading)
|
||||
? null
|
||||
: () {
|
||||
final double initial = safeInitialWidth;
|
||||
_performLoginFlow(initial);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
child: (_isAnimating || _loading)
|
||||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
|
||||
: const Text('Sign In', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
|
||||
TextButton(onPressed: () {}, child: const Text('Contact support')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
AuthGuard.setGuest(true);
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
child: const Text('Continue as Guest'),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 260),
|
||||
child: _isSignupMode
|
||||
? _buildSignupFields(safeInitialWidth)
|
||||
: _buildLoginFields(safeInitialWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -273,113 +491,3 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DesktopRegisterScreen extends StatefulWidget {
|
||||
const DesktopRegisterScreen({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DesktopRegisterScreen> createState() => _DesktopRegisterScreenState();
|
||||
}
|
||||
|
||||
class _DesktopRegisterScreenState extends State<DesktopRegisterScreen> {
|
||||
final TextEditingController _emailCtrl = TextEditingController();
|
||||
final TextEditingController _phoneCtrl = TextEditingController();
|
||||
final TextEditingController _passCtrl = TextEditingController();
|
||||
final TextEditingController _confirmCtrl = TextEditingController();
|
||||
final AuthService _auth = AuthService();
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_confirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _performRegister() async {
|
||||
final email = _emailCtrl.text.trim();
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
final pass = _passCtrl.text;
|
||||
final confirm = _confirmCtrl.text;
|
||||
|
||||
if (email.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
|
||||
return;
|
||||
}
|
||||
if (phone.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
|
||||
return;
|
||||
}
|
||||
if (pass.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter password')));
|
||||
return;
|
||||
}
|
||||
if (pass != confirm) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
await _auth.register(
|
||||
email: email,
|
||||
phoneNumber: phone,
|
||||
password: pass,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message = e.toString().replaceAll('Exception: ', '');
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Register')),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password')),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
ElevatedButton(onPressed: _loading ? null : _performRegister, child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register')),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +320,8 @@ class _HomeContentState extends State<_HomeContent>
|
||||
height: double.infinity,
|
||||
memCacheWidth: 1400,
|
||||
memCacheHeight: 800,
|
||||
maxWidthDiskCache: 1400,
|
||||
maxHeightDiskCache: 800,
|
||||
placeholder: (_, __) => Container(
|
||||
color: const Color(0xFF0A0E1A),
|
||||
),
|
||||
@@ -529,6 +531,8 @@ class _HomeContentState extends State<_HomeContent>
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 1400,
|
||||
memCacheHeight: 800,
|
||||
maxWidthDiskCache: 1400,
|
||||
maxHeightDiskCache: 800,
|
||||
)
|
||||
else
|
||||
Container(color: const Color(0xFF0A0E1A)),
|
||||
@@ -782,6 +786,8 @@ class _HomeContentState extends State<_HomeContent>
|
||||
imageUrl: img,
|
||||
memCacheWidth: 600,
|
||||
memCacheHeight: 320,
|
||||
maxWidthDiskCache: 1200,
|
||||
maxHeightDiskCache: 640,
|
||||
width: double.infinity,
|
||||
height: imageHeight,
|
||||
fit: BoxFit.cover,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
// google_maps_flutter removed — using OpenStreetMap static map preview instead
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
@@ -11,20 +12,46 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../features/events/models/event_models.dart';
|
||||
import '../features/events/services/events_service.dart';
|
||||
import '../core/auth/auth_guard.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
import '../core/constants.dart';
|
||||
import '../features/reviews/widgets/review_section.dart';
|
||||
import '../widgets/tier_avatar_ring.dart';
|
||||
import 'contributor_profile_screen.dart';
|
||||
import 'checkout_screen.dart';
|
||||
import '../core/analytics/posthog_service.dart';
|
||||
|
||||
class LearnMoreScreen extends StatefulWidget {
|
||||
final int eventId;
|
||||
final EventModel? initialEvent;
|
||||
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent}) : super(key: key);
|
||||
final String? heroTag;
|
||||
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent, this.heroTag}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
|
||||
}
|
||||
|
||||
class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProviderStateMixin {
|
||||
final EventsService _service = EventsService();
|
||||
|
||||
late final AnimationController _fadeController;
|
||||
late final Animation<double> _fade;
|
||||
|
||||
void _navigateToCheckout() {
|
||||
if (!AuthGuard.requireLogin(context, reason: 'Sign in to book this event.')) return;
|
||||
if (_event == null) return;
|
||||
PostHogService.instance.capture('book_now_tapped', properties: {
|
||||
'event_id': _event!.id,
|
||||
'event_name': _event!.name ?? _event!.title ?? '',
|
||||
});
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (_) => CheckoutScreen(
|
||||
eventId: _event!.id,
|
||||
eventName: _event!.name,
|
||||
eventImage: _event!.thumbImg,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
bool _loading = true;
|
||||
EventModel? _event;
|
||||
String? _error;
|
||||
@@ -42,17 +69,27 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
|
||||
// Google Map
|
||||
GoogleMapController? _mapController;
|
||||
MapType _mapType = MapType.normal;
|
||||
bool _showMapControls = false;
|
||||
|
||||
// Related events (EVT-002)
|
||||
List<EventModel> _relatedEvents = [];
|
||||
bool _loadingRelated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PostHogService.instance.screen('EventDetail', properties: {'event_id': widget.eventId});
|
||||
_fadeController = AnimationController(vsync: this, duration: const Duration(milliseconds: 350));
|
||||
_fade = CurvedAnimation(parent: _fadeController, curve: Curves.easeIn);
|
||||
_pageNotifier = ValueNotifier(0);
|
||||
if (widget.initialEvent != null) {
|
||||
_event = widget.initialEvent;
|
||||
_loading = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _startAutoScroll());
|
||||
_fadeController.forward();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startAutoScroll();
|
||||
// Fetch full event details in background to get important_information, images, etc.
|
||||
_loadFullDetails();
|
||||
});
|
||||
} else {
|
||||
_loadEvent();
|
||||
}
|
||||
@@ -64,6 +101,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
_pageController.dispose();
|
||||
_pageNotifier.dispose();
|
||||
_mapController?.dispose();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -71,6 +109,28 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
// Data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch full event details to fill in fields missing from the list
|
||||
/// endpoint (important_information, images, etc.).
|
||||
Future<void> _loadFullDetails() async {
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
final ev = await _service.getEventDetails(widget.eventId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_event = ev;
|
||||
});
|
||||
_startAutoScroll();
|
||||
_loadRelatedEvents();
|
||||
return; // success
|
||||
} catch (e) {
|
||||
debugPrint('_loadFullDetails attempt ${attempt + 1} failed for event ${widget.eventId}: $e');
|
||||
if (attempt == 0) {
|
||||
await Future.delayed(const Duration(seconds: 1)); // wait before retry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadEvent() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -81,11 +141,28 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() => _event = ev);
|
||||
_startAutoScroll();
|
||||
_loadRelatedEvents();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
setState(() => _error = userFriendlyError(e));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
_fadeController.forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch related events by the same event type (EVT-002).
|
||||
Future<void> _loadRelatedEvents() async {
|
||||
if (_event?.eventTypeId == null) return;
|
||||
if (mounted) setState(() => _loadingRelated = true);
|
||||
try {
|
||||
final events = await _service.getEventsByCategory(_event!.eventTypeId!, limit: 6);
|
||||
final filtered = events.where((e) => e.id != widget.eventId).take(5).toList();
|
||||
if (mounted) setState(() => _relatedEvents = filtered);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingRelated = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +225,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
Future<void> _shareEvent() async {
|
||||
final title = _event?.title ?? _event?.name ?? 'Check out this event';
|
||||
final url =
|
||||
'https://uat.eventifyplus.com/events/${widget.eventId}';
|
||||
'https://app.eventifyplus.com/event/${widget.eventId}';
|
||||
await Share.share('$title\n$url', subject: title);
|
||||
}
|
||||
|
||||
@@ -171,18 +248,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
'https://www.google.com/maps/dir/?api=1&destination=${_event!.latitude},${_event!.longitude}');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Map camera helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void _moveCamera(double latDelta, double lngDelta) {
|
||||
_mapController?.animateCamera(CameraUpdate.scrollBy(lngDelta * 80, -latDelta * 80));
|
||||
}
|
||||
|
||||
void _zoom(double amount) {
|
||||
_mapController?.animateCamera(CameraUpdate.zoomBy(amount));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BUILD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -237,7 +302,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenWidth = mediaQuery.size.width;
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final imageHeight = screenHeight * 0.45;
|
||||
final imageHeight = screenHeight * 0.52;
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
|
||||
// ── DESKTOP layout ──────────────────────────────────────────────────
|
||||
@@ -267,6 +332,8 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 800,
|
||||
memCacheHeight: 500,
|
||||
maxWidthDiskCache: 1200,
|
||||
maxHeightDiskCache: 800,
|
||||
placeholder: (_, __) => Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -374,9 +441,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
right: 32,
|
||||
bottom: 36,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: implement booking action
|
||||
},
|
||||
onPressed: _navigateToCheckout,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A56DB),
|
||||
foregroundColor: Colors.white,
|
||||
@@ -416,6 +481,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (_event!.importantInfo.isEmpty &&
|
||||
(_event!.importantInformation ?? '').isNotEmpty)
|
||||
_buildImportantInfoFallback(theme),
|
||||
// EVT-001: Contributor widget
|
||||
_buildContributorSection(theme),
|
||||
const SizedBox(height: 24),
|
||||
ReviewSection(eventId: widget.eventId),
|
||||
// EVT-002: Related events horizontal row
|
||||
_buildRelatedEventsSection(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -475,6 +546,8 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 800,
|
||||
memCacheHeight: 500,
|
||||
maxWidthDiskCache: 1200,
|
||||
maxHeightDiskCache: 800,
|
||||
placeholder: (_, __) => Container(color: theme.dividerColor),
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: theme.dividerColor,
|
||||
@@ -514,8 +587,46 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||
return Scaffold(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
bottomNavigationBar: (_event != null && _event!.isBookable)
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, -4),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _navigateToCheckout,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1A56DB),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Book Now',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
body: FadeTransition(
|
||||
opacity: _fade,
|
||||
child: Stack(
|
||||
children: [
|
||||
// ── Scrollable content (carousel + card scroll together) ──
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -556,6 +667,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (_event!.importantInfo.isEmpty &&
|
||||
(_event!.importantInformation ?? '').isNotEmpty)
|
||||
_buildImportantInfoFallback(theme),
|
||||
// EVT-001: Contributor widget
|
||||
_buildContributorSection(theme),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: ReviewSection(eventId: widget.eventId),
|
||||
),
|
||||
// EVT-002: Related events horizontal row
|
||||
_buildRelatedEventsSection(theme),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
@@ -638,6 +758,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -730,6 +851,8 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 800,
|
||||
memCacheHeight: 500,
|
||||
maxWidthDiskCache: 1200,
|
||||
maxHeightDiskCache: 800,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
placeholder: (_, __) => Container(
|
||||
@@ -775,13 +898,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
// ---- Foreground image with rounded corners ----
|
||||
if (images.isNotEmpty)
|
||||
Positioned(
|
||||
top: topPad + 56, // below the icon row
|
||||
top: topPad + 70, // safely below the icon row
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 16,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: PageView.builder(
|
||||
bottom: 40, // clear from the bottom card's -28 overlap
|
||||
child: Hero(
|
||||
tag: widget.heroTag ?? 'event-hero-${widget.eventId}',
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
onPageChanged: (i) => _pageNotifier.value = i,
|
||||
itemCount: images.length,
|
||||
@@ -790,6 +915,8 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 800,
|
||||
memCacheHeight: 500,
|
||||
maxWidthDiskCache: 1200,
|
||||
maxHeightDiskCache: 800,
|
||||
width: double.infinity,
|
||||
placeholder: (_, __) => Container(
|
||||
color: theme.dividerColor,
|
||||
@@ -803,14 +930,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ---- No-image placeholder ----
|
||||
if (images.isEmpty)
|
||||
Positioned(
|
||||
top: topPad + 56,
|
||||
top: topPad + 70,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 16,
|
||||
bottom: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
@@ -945,7 +1073,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. VENUE LOCATION (Google Map)
|
||||
// 5. VENUE LOCATION (Native Google Map on mobile, fallback on web)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildVenueSection(ThemeData theme) {
|
||||
@@ -971,45 +1099,26 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: SizedBox(
|
||||
height: 280,
|
||||
height: 250,
|
||||
width: double.infinity,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Use static map image on web (Google Maps JS SDK not configured),
|
||||
// native GoogleMap widget on mobile
|
||||
// Native Google Maps SDK on mobile, tappable fallback on web
|
||||
if (kIsWeb)
|
||||
GestureDetector(
|
||||
onTap: _viewLargerMap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
color: const Color(0xFFE8EAF6),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.map_outlined, size: 48, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap to view on Google Maps',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
color: const Color(0xFFE8EAF6),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.map_outlined, size: 48, color: theme.colorScheme.primary),
|
||||
const SizedBox(height: 8),
|
||||
Text('Tap to view on Google Maps',
|
||||
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1019,7 +1128,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
target: LatLng(lat, lng),
|
||||
zoom: 15,
|
||||
),
|
||||
mapType: _mapType,
|
||||
markers: {
|
||||
Marker(
|
||||
markerId: const MarkerId('event'),
|
||||
@@ -1028,14 +1136,14 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
),
|
||||
},
|
||||
myLocationButtonEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
zoomControlsEnabled: true,
|
||||
scrollGesturesEnabled: true,
|
||||
rotateGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
onMapCreated: (c) => _mapController = c,
|
||||
),
|
||||
|
||||
// "View larger map" – top left
|
||||
// "View larger map" overlay button — top left
|
||||
Positioned(
|
||||
top: 10,
|
||||
left: 10,
|
||||
@@ -1047,112 +1155,23 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.12),
|
||||
blurRadius: 6,
|
||||
),
|
||||
BoxShadow(color: Colors.black.withValues(alpha: 0.12), blurRadius: 6),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'View larger map',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.open_in_new, size: 14, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'View larger map',
|
||||
style: TextStyle(color: theme.colorScheme.primary, fontWeight: FontWeight.w600, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Map type toggle – bottom left (native only)
|
||||
if (!kIsWeb)
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
child: _mapControlButton(
|
||||
icon: _mapType == MapType.normal
|
||||
? Icons.satellite_alt
|
||||
: Icons.map_outlined,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_mapType = _mapType == MapType.normal
|
||||
? MapType.satellite
|
||||
: MapType.normal;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Map controls toggle – bottom right (native only)
|
||||
if (!kIsWeb)
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: _mapControlButton(
|
||||
icon: Icons.open_with_rounded,
|
||||
onTap: () => setState(() => _showMapControls = !_showMapControls),
|
||||
),
|
||||
),
|
||||
|
||||
// Directional pad overlay (native only)
|
||||
if (!kIsWeb && _showMapControls)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_mapControlButton(
|
||||
icon: Icons.keyboard_arrow_up,
|
||||
onTap: () => _moveCamera(1, 0)),
|
||||
const SizedBox(width: 16),
|
||||
_mapControlButton(
|
||||
icon: Icons.add,
|
||||
onTap: () => _zoom(1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_mapControlButton(
|
||||
icon: Icons.keyboard_arrow_left,
|
||||
onTap: () => _moveCamera(0, -1)),
|
||||
const SizedBox(width: 60),
|
||||
_mapControlButton(
|
||||
icon: Icons.keyboard_arrow_right,
|
||||
onTap: () => _moveCamera(0, 1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_mapControlButton(
|
||||
icon: Icons.keyboard_arrow_down,
|
||||
onTap: () => _moveCamera(-1, 0)),
|
||||
const SizedBox(width: 16),
|
||||
_mapControlButton(
|
||||
icon: Icons.remove,
|
||||
onTap: () => _zoom(-1)),
|
||||
const SizedBox(width: 16),
|
||||
_mapControlButton(
|
||||
icon: Icons.close,
|
||||
onTap: () =>
|
||||
setState(() => _showMapControls = false)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1169,7 +1188,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.06),
|
||||
color: theme.shadowColor.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
@@ -1178,21 +1197,11 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
venueLabel,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(venueLabel, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
if (_event!.place != null && _event!.place != venueLabel)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_event!.place!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
child: Text(_event!.place!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1202,30 +1211,6 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _mapControlButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.92),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.15),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(icon, color: Colors.grey.shade700, size: 22),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. GET DIRECTIONS BUTTON
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1350,11 +1335,20 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
|
||||
/// Parse an HTML important_information string into a list of {title, value} maps
|
||||
List<Map<String, String>> _parseHtmlImportantInfo(String raw) {
|
||||
// Strip HTML tags, preserving <br> as a newline separator first
|
||||
var text = raw
|
||||
.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n')
|
||||
.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
// Decode entities
|
||||
var text = raw;
|
||||
// 1. Remove <style>...</style> blocks entirely (content + tags)
|
||||
text = text.replaceAll(RegExp(r'<style[^>]*>.*?</style>', caseSensitive: false, dotAll: true), '');
|
||||
// 2. Remove <script>...</script> blocks
|
||||
text = text.replaceAll(RegExp(r'<script[^>]*>.*?</script>', caseSensitive: false, dotAll: true), '');
|
||||
// 3. Convert block-level closers to newlines
|
||||
text = text.replaceAll(RegExp(r'</div>', caseSensitive: false), '\n');
|
||||
text = text.replaceAll(RegExp(r'</p>', caseSensitive: false), '\n');
|
||||
text = text.replaceAll(RegExp(r'</li>', caseSensitive: false), '\n');
|
||||
// 4. Convert <br> to newlines
|
||||
text = text.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n');
|
||||
// 5. Strip all remaining HTML tags
|
||||
text = text.replaceAll(RegExp(r'<[^>]*>'), '');
|
||||
// 6. Decode HTML entities
|
||||
text = text
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
@@ -1401,6 +1395,231 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
return items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. CONTRIBUTOR WIDGET (EVT-001)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildContributorSection(ThemeData theme) {
|
||||
final name = _event?.contributorName;
|
||||
if (name == null || name.isEmpty) return const SizedBox.shrink();
|
||||
final tier = _event!.contributorTier ?? '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? const Color(0xFF1E293B)
|
||||
: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: theme.dividerColor,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TierAvatarRing(
|
||||
username: name,
|
||||
tier: tier,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Contributed by',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (tier.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
tier,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_event?.contributorId != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: theme.hintColor),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContributorProfileScreen(
|
||||
contributorId: _event!.contributorId!,
|
||||
contributorName: _event!.contributorName!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. RELATED EVENTS ROW (EVT-002)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildRelatedEventsSection(ThemeData theme) {
|
||||
if (_loadingRelated) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Related Events',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_relatedEvents.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
|
||||
child: Text(
|
||||
'Related Events',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _relatedEvents.length,
|
||||
itemBuilder: (context, i) {
|
||||
final e = _relatedEvents[i];
|
||||
final displayName = e.title ?? e.name;
|
||||
final imageUrl = e.thumbImg ?? '';
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LearnMoreScreen(eventId: e.id),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.brightness == Brightness.dark
|
||||
? const Color(0xFF1E293B)
|
||||
: theme.cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: imageUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: 100,
|
||||
width: 140,
|
||||
memCacheWidth: 280,
|
||||
memCacheHeight: 200,
|
||||
maxWidthDiskCache: 560,
|
||||
maxHeightDiskCache: 400,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
height: 100,
|
||||
width: 140,
|
||||
color: theme.dividerColor,
|
||||
child: Icon(Icons.event,
|
||||
size: 32, color: theme.hintColor),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
height: 100,
|
||||
width: 140,
|
||||
color: theme.dividerColor,
|
||||
child: Icon(Icons.event,
|
||||
size: 32, color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
displayName,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImportantInfoFallback(ThemeData theme) {
|
||||
final parsed = _parseHtmlImportantInfo(_event!.importantInformation!);
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// lib/screens/login_screen.dart
|
||||
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../core/utils/error_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../features/auth/services/auth_service.dart';
|
||||
import '../features/auth/providers/auth_provider.dart';
|
||||
import '../core/auth/auth_guard.dart';
|
||||
import 'home_screen.dart';
|
||||
import 'responsive_layout.dart';
|
||||
import 'home_desktop_screen.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({Key? key}) : super(key: key);
|
||||
@@ -17,18 +21,36 @@ class LoginScreen extends StatefulWidget {
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _signupFormKey = GlobalKey<FormState>();
|
||||
|
||||
final TextEditingController _emailCtrl = TextEditingController();
|
||||
final TextEditingController _passCtrl = TextEditingController();
|
||||
final FocusNode _emailFocus = FocusNode();
|
||||
final FocusNode _passFocus = FocusNode();
|
||||
|
||||
// Signup-specific controllers
|
||||
final TextEditingController _signupEmailCtrl = TextEditingController();
|
||||
final TextEditingController _signupPhoneCtrl = TextEditingController();
|
||||
final TextEditingController _signupPassCtrl = TextEditingController();
|
||||
final TextEditingController _signupConfirmCtrl = TextEditingController();
|
||||
String? _signupDistrict;
|
||||
bool _signupObscurePass = true;
|
||||
bool _signupObscureConfirm = true;
|
||||
|
||||
static const _districts = [
|
||||
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||
];
|
||||
|
||||
bool _isSignupMode = false;
|
||||
|
||||
final AuthService _auth = AuthService();
|
||||
bool _loading = false;
|
||||
bool _obscurePassword = true;
|
||||
bool _rememberMe = false;
|
||||
|
||||
late VideoPlayerController _videoController;
|
||||
VideoPlayerController? _videoController;
|
||||
bool _videoInitialized = false;
|
||||
|
||||
// Glassmorphism color palette
|
||||
@@ -45,24 +67,35 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initVideo();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).clearSnackBars();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initVideo() async {
|
||||
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
|
||||
await _videoController.initialize();
|
||||
_videoController.setLooping(true);
|
||||
_videoController.setVolume(0);
|
||||
_videoController.play();
|
||||
if (mounted) setState(() => _videoInitialized = true);
|
||||
try {
|
||||
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
|
||||
await _videoController!.initialize();
|
||||
_videoController!.setLooping(true);
|
||||
_videoController!.setVolume(0);
|
||||
_videoController!.play();
|
||||
if (mounted) setState(() => _videoInitialized = true);
|
||||
} catch (_) {
|
||||
// Video asset not available — skip background video
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_videoController.dispose();
|
||||
_videoController?.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_emailFocus.dispose();
|
||||
_passFocus.dispose();
|
||||
_signupEmailCtrl.dispose();
|
||||
_signupPhoneCtrl.dispose();
|
||||
_signupPassCtrl.dispose();
|
||||
_signupConfirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -106,7 +139,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message = e.toString().replaceAll('Exception: ', '');
|
||||
final message = userFriendlyError(e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
@@ -114,7 +147,11 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
}
|
||||
|
||||
void _openRegister() {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false)));
|
||||
setState(() => _isSignupMode = true);
|
||||
}
|
||||
|
||||
void _openLogin() {
|
||||
setState(() => _isSignupMode = false);
|
||||
}
|
||||
|
||||
void _showComingSoon() {
|
||||
@@ -123,6 +160,203 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _performSignup() async {
|
||||
if (!(_signupFormKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final email = _signupEmailCtrl.text.trim();
|
||||
final phone = _signupPhoneCtrl.text.trim();
|
||||
final pass = _signupPassCtrl.text;
|
||||
final confirm = _signupConfirmCtrl.text;
|
||||
|
||||
if (pass != confirm) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
await _auth.register(
|
||||
email: email,
|
||||
phoneNumber: phone,
|
||||
password: pass,
|
||||
district: _signupDistrict,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
||||
pageBuilder: (context, a1, a2) => const HomeScreen(),
|
||||
transitionDuration: const Duration(milliseconds: 650),
|
||||
transitionsBuilder: (context, animation, _, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openForgotPasswordSheet() async {
|
||||
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
|
||||
final sheetFormKey = GlobalKey<FormState>();
|
||||
bool submitting = false;
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setSheetState) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.75),
|
||||
border: Border.all(color: _glassBorder, width: 0.8),
|
||||
),
|
||||
child: Form(
|
||||
key: sheetFormKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'Forgot Password',
|
||||
style: TextStyle(color: _textWhite, fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
"Enter your email and we'll send you reset instructions.",
|
||||
style: TextStyle(color: _textMuted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: emailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
cursorColor: Colors.white54,
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Enter your email',
|
||||
prefixIcon: Icons.mail_outline_rounded,
|
||||
),
|
||||
validator: _emailValidator,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
|
||||
),
|
||||
border: Border.all(color: const Color(0x33FFFFFF)),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: submitting
|
||||
? null
|
||||
: () async {
|
||||
if (!(sheetFormKey.currentState?.validate() ?? false)) return;
|
||||
setSheetState(() => submitting = true);
|
||||
final email = emailCtrl.text.trim();
|
||||
try {
|
||||
await _auth.forgotPassword(email);
|
||||
} catch (_) {
|
||||
// safe-degrade: don't leak whether email exists or backend status
|
||||
}
|
||||
if (!ctx.mounted) return;
|
||||
Navigator.of(ctx).pop();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("If that email is registered, we've sent reset instructions."),
|
||||
duration: Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: submitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: _textWhite),
|
||||
)
|
||||
: const Text(
|
||||
'Send reset link',
|
||||
style: TextStyle(color: _textWhite, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('Cancel', style: TextStyle(color: _textMuted)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
emailCtrl.dispose();
|
||||
}
|
||||
|
||||
Future<void> _performGoogleLogin() async {
|
||||
try {
|
||||
setState(() => _loading = true);
|
||||
await Provider.of<AuthProvider>(context, listen: false).googleLogin();
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => ResponsiveLayout(mobile: HomeScreen(), desktop: const HomeDesktopScreen())),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(userFriendlyError(e))),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Glassmorphism pill-shaped input decoration
|
||||
InputDecoration _glassInputDecoration({
|
||||
required String hint,
|
||||
@@ -214,14 +448,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
body: Stack(
|
||||
children: [
|
||||
// Video background
|
||||
if (_videoInitialized)
|
||||
if (_videoInitialized && _videoController != null)
|
||||
Positioned.fill(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _videoController.value.size.width,
|
||||
height: _videoController.value.size.height,
|
||||
child: VideoPlayer(_videoController),
|
||||
width: _videoController!.value.size.width,
|
||||
height: _videoController!.value.size.height,
|
||||
child: VideoPlayer(_videoController!),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -251,15 +485,23 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Brand name
|
||||
Center(
|
||||
child: Text(
|
||||
'Eventify',
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
child: _isSignupMode
|
||||
? KeyedSubtree(key: const ValueKey('signup'), child: _buildSignupForm(context))
|
||||
: KeyedSubtree(
|
||||
key: const ValueKey('login'),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Brand name
|
||||
Center(
|
||||
child: Text(
|
||||
'Eventify',
|
||||
style: TextStyle(
|
||||
color: _textWhite.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
@@ -380,7 +622,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
// Forgot Password
|
||||
GestureDetector(
|
||||
onTap: _showComingSoon,
|
||||
onTap: _openForgotPasswordSheet,
|
||||
child: const Text(
|
||||
'Forgot Password?',
|
||||
style: TextStyle(color: _textMuted, fontSize: 12),
|
||||
@@ -473,7 +715,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
color: Color(0xFF4285F4),
|
||||
),
|
||||
),
|
||||
onTap: _showComingSoon,
|
||||
onTap: _performGoogleLogin,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_socialButton(
|
||||
@@ -538,8 +780,10 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -549,129 +793,233 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register screen calls backend register endpoint via AuthService.register
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
final bool isDesktop;
|
||||
const RegisterScreen({Key? key, this.isDesktop = false}) : super(key: key);
|
||||
Widget _buildSignupForm(BuildContext context) {
|
||||
return Form(
|
||||
key: _signupFormKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'Eventify',
|
||||
style: TextStyle(
|
||||
color: _textWhite.withOpacity(0.7),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: Text(
|
||||
'Create Your\nAccount',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _textWhite,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontStyle: FontStyle.italic,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
// Email
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('Email', style: TextStyle(color: _textMuted, fontSize: 13)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _signupEmailCtrl,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [AutofillHints.email],
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
cursorColor: Colors.white54,
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Enter your email',
|
||||
prefixIcon: Icons.mail_outline_rounded,
|
||||
),
|
||||
validator: _emailValidator,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailCtrl = TextEditingController();
|
||||
final TextEditingController _phoneCtrl = TextEditingController();
|
||||
final TextEditingController _passCtrl = TextEditingController();
|
||||
final TextEditingController _confirmCtrl = TextEditingController();
|
||||
final AuthService _auth = AuthService();
|
||||
// Phone
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('Phone', style: TextStyle(color: _textMuted, fontSize: 13)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _signupPhoneCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
cursorColor: Colors.white54,
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Enter your phone number',
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
),
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Enter phone number';
|
||||
if (v.trim().length < 7) return 'Enter a valid phone number';
|
||||
return null;
|
||||
},
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
bool _loading = false;
|
||||
// District
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('District (optional)', style: TextStyle(color: _textMuted, fontSize: 13)),
|
||||
),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _signupDistrict,
|
||||
dropdownColor: const Color(0xFF1A1A1A),
|
||||
iconEnabledColor: _textMuted,
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Select your district',
|
||||
prefixIcon: Icons.location_on_outlined,
|
||||
),
|
||||
items: _districts
|
||||
.map((d) => DropdownMenuItem(
|
||||
value: d,
|
||||
child: Text(d, style: const TextStyle(color: _textWhite)),
|
||||
))
|
||||
.toList(),
|
||||
onChanged: (v) => setState(() => _signupDistrict = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_passCtrl.dispose();
|
||||
_confirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
// Password
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('Password', style: TextStyle(color: _textMuted, fontSize: 13)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _signupPassCtrl,
|
||||
obscureText: _signupObscurePass,
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
cursorColor: Colors.white54,
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Create a password',
|
||||
prefixIcon: Icons.lock_outline_rounded,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_signupObscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
||||
color: _textMuted,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => setState(() => _signupObscurePass = !_signupObscurePass),
|
||||
),
|
||||
),
|
||||
validator: _passwordValidator,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
Future<void> _performRegister() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
// Confirm password
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
||||
child: Text('Confirm password', style: TextStyle(color: _textMuted, fontSize: 13)),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _signupConfirmCtrl,
|
||||
obscureText: _signupObscureConfirm,
|
||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
||||
cursorColor: Colors.white54,
|
||||
decoration: _glassInputDecoration(
|
||||
hint: 'Re-enter your password',
|
||||
prefixIcon: Icons.lock_outline_rounded,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_signupObscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined,
|
||||
color: _textMuted,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => setState(() => _signupObscureConfirm = !_signupObscureConfirm),
|
||||
),
|
||||
),
|
||||
validator: _passwordValidator,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _performSignup(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
final email = _emailCtrl.text.trim();
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
final pass = _passCtrl.text;
|
||||
final confirm = _confirmCtrl.text;
|
||||
|
||||
if (pass != confirm) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
|
||||
try {
|
||||
await _auth.register(
|
||||
email: email,
|
||||
phoneNumber: phone,
|
||||
password: pass,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final message = e.toString().replaceAll('Exception: ', '');
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
String? _emailValidator(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Enter email';
|
||||
final emailRegex = RegExp(r"^[^@]+@[^@]+\.[^@]+");
|
||||
if (!emailRegex.hasMatch(v.trim())) return 'Enter a valid email';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _phoneValidator(String? v) {
|
||||
if (v == null || v.trim().isEmpty) return 'Enter phone number';
|
||||
if (v.trim().length < 7) return 'Enter a valid phone number';
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _passwordValidator(String? v) {
|
||||
if (v == null || v.isEmpty) return 'Enter password';
|
||||
if (v.length < 6) return 'Password must be at least 6 characters';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Register')),
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email'), validator: _emailValidator, keyboardType: TextInputType.emailAddress),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
|
||||
const SizedBox(height: 8),
|
||||
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _performRegister,
|
||||
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Create Account button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
|
||||
),
|
||||
border: Border.all(color: const Color(0x33FFFFFF)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
onTap: _loading ? null : _performSignup,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: _textWhite),
|
||||
)
|
||||
: const Text(
|
||||
'Create Account',
|
||||
style: TextStyle(color: _textWhite, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Back to Sign in
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Already have an account? ',
|
||||
style: TextStyle(color: _textMuted, fontSize: 13),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: _openLogin,
|
||||
child: const Text(
|
||||
'Sign in',
|
||||
style: TextStyle(
|
||||
color: _textWhite,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: _textWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,23 @@
|
||||
// lib/screens/search_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
|
||||
// Location packages
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
|
||||
/// Data model for a location suggestion (city + optional pincode).
|
||||
/// Data model for a location suggestion (city + optional pincode + optional coords).
|
||||
class _LocationItem {
|
||||
final String city;
|
||||
final String? district;
|
||||
final String? pincode;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
const _LocationItem({required this.city, this.district, this.pincode});
|
||||
const _LocationItem({required this.city, this.district, this.pincode, this.lat, this.lng});
|
||||
|
||||
String get displayTitle => district != null && district!.isNotEmpty ? '$city, $district' : city;
|
||||
String get displaySubtitle => pincode ?? '';
|
||||
@@ -45,49 +50,43 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
'Kottayam',
|
||||
];
|
||||
|
||||
/// Searchable location database – Kerala towns/cities with pincodes.
|
||||
static const List<_LocationItem> _locationDb = [
|
||||
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
|
||||
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
|
||||
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
|
||||
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
|
||||
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
|
||||
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
|
||||
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
|
||||
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
|
||||
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
|
||||
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
|
||||
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
|
||||
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
|
||||
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
|
||||
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
|
||||
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
|
||||
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
|
||||
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
|
||||
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
|
||||
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
|
||||
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
|
||||
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
|
||||
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
|
||||
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
|
||||
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
|
||||
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
|
||||
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
|
||||
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
|
||||
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
|
||||
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
|
||||
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
|
||||
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
|
||||
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
|
||||
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
|
||||
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
|
||||
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
|
||||
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
|
||||
];
|
||||
/// Searchable location database – loaded from assets/data/kerala_pincodes.json.
|
||||
List<_LocationItem> _locationDb = [];
|
||||
bool _pinsLoaded = false;
|
||||
|
||||
List<_LocationItem> _searchResults = [];
|
||||
bool _showSearchResults = false;
|
||||
bool _loadingLocation = false;
|
||||
bool _isSearching = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadKeralaData();
|
||||
}
|
||||
|
||||
Future<void> _loadKeralaData() async {
|
||||
if (_pinsLoaded) return;
|
||||
try {
|
||||
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
|
||||
final List<dynamic> list = jsonDecode(jsonStr);
|
||||
final loaded = list.map((e) => _LocationItem(
|
||||
city: e['city'] as String,
|
||||
district: e['district'] as String?,
|
||||
pincode: e['pincode'] as String?,
|
||||
lat: (e['lat'] as num?)?.toDouble(),
|
||||
lng: (e['lng'] as num?)?.toDouble(),
|
||||
)).toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_locationDb = loaded;
|
||||
_pinsLoaded = true;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// fallback: keep empty list, search won't crash
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -112,8 +111,62 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _selectAndClose(String location) {
|
||||
Navigator.of(context).pop(location);
|
||||
/// Pop with a structured result so home_screen can update the display label,
|
||||
/// pincode, and GPS coordinates used for haversine filtering.
|
||||
void _selectWithPincode(String label, {String? pincode, double? lat, double? lng}) {
|
||||
final result = <String, dynamic>{
|
||||
'label': label,
|
||||
'pincode': pincode ?? 'all',
|
||||
};
|
||||
if (lat != null && lng != null) {
|
||||
result['lat'] = lat;
|
||||
result['lng'] = lng;
|
||||
}
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
Future<void> _selectAndClose(String location) async {
|
||||
// Looks up pincode + coordinates from the database for the given city name.
|
||||
final match = _locationDb.cast<_LocationItem?>().firstWhere(
|
||||
(loc) => loc != null && (loc.city.toLowerCase() == location.toLowerCase() ||
|
||||
loc.displayTitle.toLowerCase() == location.toLowerCase()),
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (match != null) {
|
||||
_selectWithPincode(location, pincode: match.pincode, lat: match.lat, lng: match.lng);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Geocode the location name
|
||||
setState(() => _isSearching = true);
|
||||
try {
|
||||
final placemarksByAddress = await locationFromAddress(location);
|
||||
if (placemarksByAddress.isNotEmpty) {
|
||||
final loc = placemarksByAddress.first;
|
||||
final placemarks = await placemarkFromCoordinates(loc.latitude, loc.longitude);
|
||||
String label = location;
|
||||
String? pincode;
|
||||
if (placemarks.isNotEmpty) {
|
||||
final p = placemarks.first;
|
||||
final parts = <String>[];
|
||||
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||||
if ((p.subAdministrativeArea ?? '').isNotEmpty && p.subAdministrativeArea != p.locality) parts.add(p.subAdministrativeArea!);
|
||||
if (parts.isNotEmpty) label = parts.join(', ');
|
||||
pincode = p.postalCode;
|
||||
}
|
||||
if (mounted) {
|
||||
_selectWithPincode(label, pincode: pincode ?? 'all', lat: loc.latitude, lng: loc.longitude);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Geocoding failed, proceed with just the text label
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSearching = false);
|
||||
}
|
||||
|
||||
_selectWithPincode(location);
|
||||
}
|
||||
|
||||
Future<void> _useCurrentLocation() async {
|
||||
@@ -128,13 +181,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
if (permission == LocationPermission.deniedForever || permission == LocationPermission.denied) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Location permission denied')));
|
||||
Navigator.of(context).pop('Current Location');
|
||||
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final pos = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.best);
|
||||
|
||||
String label = 'Current Location';
|
||||
try {
|
||||
final placemarks = await placemarkFromCoordinates(pos.latitude, pos.longitude);
|
||||
if (placemarks.isNotEmpty) {
|
||||
@@ -143,17 +197,24 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
|
||||
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||||
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
|
||||
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
||||
if (mounted) Navigator.of(context).pop(label);
|
||||
return;
|
||||
if (parts.isNotEmpty) label = parts.join(', ');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (mounted) Navigator.of(context).pop('Current Location');
|
||||
if (mounted) {
|
||||
// Return lat/lng so home_screen can use haversine filtering
|
||||
Navigator.of(context).pop(<String, dynamic>{
|
||||
'label': label,
|
||||
'pincode': 'all',
|
||||
'lat': pos.latitude,
|
||||
'lng': pos.longitude,
|
||||
});
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
|
||||
Navigator.of(context).pop('Current Location');
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
Navigator.of(context).pop(<String, dynamic>{'label': 'Current Location', 'pincode': 'all'});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingLocation = false);
|
||||
@@ -237,6 +298,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
enabled: !_isSearching,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search city, area or locality',
|
||||
hintStyle: TextStyle(color: Color(0xFF9CA3AF)),
|
||||
@@ -256,7 +318,12 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (_ctrl.text.isNotEmpty)
|
||||
if (_isSearching)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8.0),
|
||||
child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
else if (_ctrl.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
@@ -326,7 +393,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
subtitle: loc.pincode != null && loc.pincode!.isNotEmpty
|
||||
? Text(loc.pincode!, style: TextStyle(color: Colors.grey[500], fontSize: 13))
|
||||
: null,
|
||||
onTap: () => _selectAndClose(loc.returnValue),
|
||||
onTap: () => _selectWithPincode(loc.displayTitle, pincode: loc.pincode, lat: loc.lat, lng: loc.lng),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _notifications = true;
|
||||
String _appVersion = '1.6(p)';
|
||||
String _appVersion = '2.0.4';
|
||||
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
|
||||
|
||||
@override
|
||||
@@ -314,7 +314,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
children: [
|
||||
const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))),
|
||||
InkWell(
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))),
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help (coming soon)'))),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
@@ -338,7 +338,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
icon: Icons.person,
|
||||
title: 'Edit Profile',
|
||||
subtitle: 'Change username, email or photo',
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
|
||||
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (coming soon)'))),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
@@ -379,7 +379,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_buildTile(
|
||||
icon: Icons.privacy_tip_outlined,
|
||||
title: 'Privacy Policy',
|
||||
subtitle: 'Demo app',
|
||||
subtitle: 'Coming Soon',
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -6,19 +6,19 @@ class TicketsBookedScreen extends StatelessWidget {
|
||||
|
||||
void _onScannerTap(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Scanner tapped (demo)')),
|
||||
SnackBar(content: Text('Scanner tapped (coming soon)')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onWhatsappTap(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')),
|
||||
SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCallTap(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Call tapped (demo)')),
|
||||
SnackBar(content: Text('Call (coming soon)')),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DesktopTopBar extends StatelessWidget {
|
||||
@@ -108,7 +109,11 @@ class DesktopTopBar extends StatelessWidget {
|
||||
return CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
backgroundImage: NetworkImage(url),
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
url,
|
||||
maxWidth: 80,
|
||||
maxHeight: 80,
|
||||
),
|
||||
onBackgroundImageError: (_, __) {},
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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,6 +7,7 @@ import Foundation
|
||||
|
||||
import file_selector_macos
|
||||
import geolocator_apple
|
||||
import google_sign_in_ios
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -17,6 +18,7 @@ import video_player_avfoundation
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
100
pubspec.lock
100
pubspec.lock
@@ -137,6 +137,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
eventify:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: eventify
|
||||
sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -238,6 +246,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.30"
|
||||
flutter_staggered_animations:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_staggered_animations
|
||||
sha256: "81d3c816c9bb0dca9e8a5d5454610e21ffb068aedb2bde49d2f8d04f75538351"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter_svg:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -336,6 +352,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -384,6 +408,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.14+3"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
google_sign_in_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4+4"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -393,7 +457,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||
@@ -585,7 +649,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -672,6 +736,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
razorpay_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: razorpay_flutter
|
||||
sha256: "8d985b769808cb6c8d3f2fbcc25f9ab78e29191965c31c98e2d69d55d9d20ff1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.3"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -760,6 +832,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shimmer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shimmer
|
||||
sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
simple_gesture_detector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -793,10 +873,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_android
|
||||
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2+3"
|
||||
version: "2.4.2+2"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1009,10 +1089,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_player
|
||||
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
|
||||
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
version: "2.10.1"
|
||||
video_player_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1025,10 +1105,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: video_player_avfoundation
|
||||
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||
sha256: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
version: "2.8.9"
|
||||
video_player_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1094,5 +1174,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
dart: ">=3.9.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: figma
|
||||
description: A Flutter event app
|
||||
publish_to: 'none'
|
||||
version: 1.6.1+17
|
||||
version: 2.0.4+24
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
@@ -19,9 +19,15 @@ dependencies:
|
||||
google_maps_flutter: ^2.5.0
|
||||
url_launcher: ^6.2.1
|
||||
share_plus: ^7.2.1
|
||||
path_provider: ^2.1.0
|
||||
provider: ^6.1.2
|
||||
video_player: ^2.8.1
|
||||
cached_network_image: ^3.3.1
|
||||
razorpay_flutter: ^1.3.7
|
||||
google_sign_in: ^6.2.2
|
||||
http: ^1.2.0
|
||||
shimmer: ^3.0.0
|
||||
flutter_staggered_animations: ^1.1.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -35,6 +41,7 @@ flutter:
|
||||
- assets/images/
|
||||
- assets/icon/hand_stop.svg
|
||||
- assets/login-bg.mp4
|
||||
- assets/data/kerala_pincodes.json
|
||||
fonts:
|
||||
- family: Gilroy
|
||||
fonts:
|
||||
|
||||
Reference in New Issue
Block a user