Compare commits

...

22 Commits

Author SHA1 Message Date
3484fa9885 fix(android): add SplashActivity with video intro — fix NPE by moving insetsController after setContentView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 11:10:50 +05:30
7867e6c728 chore: restore AndroidManifest/Info.plist changes + resolve stash conflicts
Merges in-progress manifest/plist changes (stashed before merge).
Resolves trivial comment conflicts in api_endpoints.dart and auth_service.dart —
both retained backend.eventifyplus.com URL and Google OAuth serverClientId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:44:57 +05:30
98a5d541aa merge: v2.0.4+24 login fixes + backend URL fix
- Google OAuth serverClientId wired (639347358523-mtkm...apps.googleusercontent.com)
- Timeout 10s→25s + retry on SocketException/TimeoutException
- Forgot Password glassmorphism bottom sheet with safe-degrade
- Same-page signup AnimatedSwitcher (mobile + desktop); delete old RegisterScreen classes
- Guest SnackBar removed from HomeScreen; LoginScreen clearSnackBars() guard
- baseUrl: em.eventifyplus.com → backend.eventifyplus.com (broken TLS fix — real root cause)
- Version: 2.0.4+24

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:44:10 +05:30
b9efe18669 fix: switch baseUrl to backend.eventifyplus.com (broken TLS on em.eventifyplus.com)
em.eventifyplus.com / uat.eventifyplus.com DNS points to K8s with broken TLS cert.
backend.eventifyplus.com → EC2 174.129.72.160 with valid Let's Encrypt cert.
This fixes the root cause of "Unable to connect" on all API calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:42:39 +05:30
ebe654f9c3 fix: v2.0.4+24 — login fixes, signup toggle, forgot-password, guest SnackBar, Google OAuth
- Google Sign-In: wire serverClientId (639347358523-mtkm...apps.googleusercontent.com) so idToken is returned on Android
- Email login: raise timeout 10s→25s, add single retry on SocketException/TimeoutException
- Forgot Password: real glassmorphism bottom sheet with safe-degrade SnackBar (endpoint missing on backend)
- Create Account: same-page AnimatedSwitcher toggle with glassmorphism signup form; delete old RegisterScreen
- Desktop parity: DesktopLoginScreen same-page toggle; delete DesktopRegisterScreen
- Guest mode: remove ScaffoldMessenger SnackBar from HomeScreen outer catch (inner _safe wrappers already return [])
- LoginScreen: clearSnackBars() on postFrameCallback to prevent carried-over SnackBars from prior screens
- ProGuard: add Google Sign-In + OkHttp keep rules
- Version bump: 2.0.0+20 → 2.0.4+24; settings _appVersion → 2.0.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:40:17 +05:30
f3250737bd chore: bump version to 2.0.3+23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:14:07 +05:30
754b04dc05 perf: fix image loading performance across all screens
- Replace Image.network (no cache) with CachedNetworkImage in contributor_profile_screen
- Replace NetworkImage (no cache) with CachedNetworkImageProvider in desktop_topbar and contribute_screen (leaderboard avatars)
- Add maxWidthDiskCache + maxHeightDiskCache to all 23 CachedNetworkImage calls
- Add missing memCacheWidth/Height to review_card (36x36 avatar) and learn_more related events (140x100)
- Add dynamic memCache sizing to tier_avatar_ring based on widget size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:03:03 +05:30
5e00e431e3 docs: split 2.0.0 and 2.0.1 in CHANGELOG
2.0.0 = main release (image upload, profile form, coming-soon sweep, build fix)
2.0.1 = hotfix for Sign in with Google regression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 15:39:37 +05:30
b2f0943797 docs: add v2.0.1 release notes to CHANGELOG
Documents image upload pipeline, OneDrive integration, full personal
info profile form, demo→coming-soon label sweep, and Android build
version fix (versionCode/versionName now sourced from pubspec).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 14:59:56 +05:30
6990b62645 fix(android): read versionCode/versionName from flutter pubspec instead of hardcoded values
Was hardcoded to versionCode=17, versionName="1.6.1(p)" — overriding
pubspec.yaml and causing Play Store rejection. Now reads flutter.versionCode
and flutter.versionName so pubspec.yaml is the single source of truth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:36:25 +05:30
c85564efc8 chore: bump version to 2.0.0+20 (version name 2.0, build code 20)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:31:57 +05:30
593fc9dcf9 chore: bump app version to 2.0(b) in settings screen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:29:08 +05:30
6b6f08fd26 chore: replace all '(demo)' labels with '(coming soon)'
Affects snackbar messages in booking_screen, tickets_booked_screen,
calendar_screen, settings_screen. Also updates Privacy Policy subtitle
from 'Demo app' to 'Coming Soon'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:25:07 +05:30
d0762668d6 feat(profile): full personal info form in edit profile sheet
Adds all fields to the edit profile bottom sheet:
- First Name / Last Name (side by side), Email, Phone
- Location section: Home District (locked with "Next change" date),
  Place, Pincode, State, Country
- Saves all fields via update-profile API and persists to prefs
- Loads existing values from prefs on open; refreshes from status API
  on every profile open so fields stay in sync with server

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:22:25 +05:30
9f1de2bead fix(upload): set explicit MIME type on multipart upload
Without a content type header, http package defaults to
application/octet-stream which the server rejects. Derive MIME
from file extension using a lookup map (Dart 2 compatible).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:17:55 +05:30
c40e600937 feat(contribute): upload event images to OneDrive before submission
- ApiClient.uploadFile() — multipart POST to /v1/upload/file (60s timeout)
- ApiEndpoints.uploadFile — points to Node.js upload endpoint
- GamificationService.submitContribution() now uploads each picked image
  to OneDrive via the server upload pipeline, then passes the returned
  { fileId, url, ... } objects as `media` in the submission body
  (replaces broken behaviour of sending local device paths as strings)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 21:12:49 +05:30
479fe5e119 feat(share): rebuild share rank with dart:ui Canvas generator
Replace RepaintBoundary widget capture approach with a pure
dart:ui PictureRecorder + Canvas implementation.

- Add share_card_generator.dart: generates 1080×1920 PNG via
  Canvas without embedding any widget in the tree
- Remove share_rank_card.dart (widget approach no longer needed)
- Remove GlobalKey, _buildHiddenShareCard, RepaintBoundary,
  _fmtEp from profile_screen.dart
- Simplify desktop + mobile Stacks to direct ScrollViews
- Fix Android GPU compositing timing crash (no retry needed)
- Add avatarImage.dispose() to prevent GPU memory leak
- Guard byteData null return with StateError
- Replace MaterialIcons bolt with Unicode  (tree-shake safe)
- Align tier in share text with tier rendered on card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 20:20:36 +05:30
Rishad7594
bbef5b376d ... 2026-04-08 19:25:43 +05:30
aefb381ed3 feat(share): update share rank caption and add URL
Share sheet now pre-fills:
"I'm a BRONZE Explorer on Eventify Plus! 7 EP earned.
Let's connect on the platform for more.

https://app.eventifyplus.com"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:16:33 +05:30
Rishad7594
d921ac2b78 ... 2026-04-08 08:05:29 +05:30
4c57391bbd fix: leaderboard empty on first open — decouple from loadAll()
- Add isLeaderboardLoading flag separate from isLoading
- Add loadLeaderboard() method that fires independently of loadAll TTL
- Remove leaderboard from loadAll() Future.wait (failures in dashboard/shop
  no longer silently zero-out leaderboard data)
- setDistrict / setTimePeriod now use isLeaderboardLoading
- contribute_screen calls loadLeaderboard() alongside loadAll() on mount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:35:51 +05:30
Rishad7594
7bc396bdde Update default location to Thrissur and remove Whitefield, Bengaluru 2026-04-07 20:49:40 +05:30
36 changed files with 4120 additions and 1298 deletions

View File

@@ -6,6 +6,50 @@ 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 ## [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. Phase 4 — animation polish and final feature gaps. Flutter app reaches full feature parity with Consumer Web App v1.4.9.

Binary file not shown.

View File

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

View File

@@ -26,3 +26,62 @@
-dontwarn com.google.android.play.core.tasks.OnFailureListener -dontwarn com.google.android.play.core.tasks.OnFailureListener
-dontwarn com.google.android.play.core.tasks.OnSuccessListener -dontwarn com.google.android.play.core.tasks.OnSuccessListener
-dontwarn com.google.android.play.core.tasks.Task -dontwarn com.google.android.play.core.tasks.Task
# Razorpay
-keepattributes *Annotation*,Signature,*Annotation*
-dontwarn com.razorpay.**
-keep class com.razorpay.** { *; }
-optimizations !method/inlining/
-keepclasseswithmembers class * {
public void onPayment*(...);
}
-keep class proguard.annotation.Keep
-keep class proguard.annotation.KeepClassMembers
-keep @proguard.annotation.Keep class * { *; }
-keep @proguard.annotation.KeepClassMembers class * {
<fields>;
<methods>;
}
# Google Sign-In / Play Services
-keep class com.google.android.gms.** { *; }
-keep interface com.google.android.gms.** { *; }
-dontwarn com.google.android.gms.**
-keep class com.google.firebase.** { *; }
-dontwarn com.google.firebase.**
# Geolocator / Geocoding
-keep class com.baseflow.** { *; }
-dontwarn com.baseflow.**
# url_launcher, share_plus, image_picker, path_provider, etc.
-keep class io.flutter.plugins.** { *; }
-dontwarn io.flutter.plugins.**
# OkHttp (used by many network libs)
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Parcelable classes
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
# Keep Serializable classes
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
!static !transient <fields>;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}

View File

@@ -18,6 +18,18 @@
android:name="com.google.android.geo.API_KEY" android:name="com.google.android.geo.API_KEY"
android:value="YOUR_GOOGLE_MAPS_API_KEY"/> android:value="YOUR_GOOGLE_MAPS_API_KEY"/>
<!-- Splash video plays first, then launches MainActivity -->
<activity
android:name=".SplashActivity"
android:exported="true"
android:theme="@android:style/Theme.Black.NoTitleBar.Fullscreen"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -31,11 +43,6 @@
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" /> android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. Used by Flutter tool. --> <!-- Don't delete the meta-data below. Used by Flutter tool. -->

View File

@@ -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()
}
}

Binary file not shown.

View File

@@ -47,5 +47,18 @@
<true/> <true/>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false/>
<key>GIDClientID</key>
<string>639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd.apps.googleusercontent.com</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.639347358523-1siq78p4vntem3dtr067ajdhqsv9a2jd</string>
</array>
</dict>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -1,11 +1,15 @@
// lib/core/api/api_client.dart // lib/core/api/api_client.dart
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io' show SocketException;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http_parser/http_parser.dart';
import '../storage/token_storage.dart'; import '../storage/token_storage.dart';
class ApiClient { class ApiClient {
static const Duration _timeout = Duration(seconds: 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) // Set to true to enable mock/offline development mode (useful when backend is unavailable)
static const bool _developmentMode = false; static const bool _developmentMode = false;
@@ -27,13 +31,7 @@ class ApiClient {
late http.Response response; late http.Response response;
try { try {
response = await http response = await _postWithRetry(url, headers, finalBody);
.post(
Uri.parse(url),
headers: headers,
body: jsonEncode(finalBody),
)
.timeout(_timeout);
} catch (e) { } catch (e) {
if (kDebugMode) debugPrint('ApiClient.post network error: $e'); if (kDebugMode) debugPrint('ApiClient.post network error: $e');
@@ -99,6 +97,82 @@ class ApiClient {
return _handleResponse(url, response, finalBody); return _handleResponse(url, response, finalBody);
} }
/// POST with one retry on transient network errors.
/// Retries on SocketException / TimeoutException only.
Future<http.Response> _postWithRetry(
String url,
Map<String, String> headers,
Map<String, dynamic> body,
) async {
try {
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
} on SocketException {
if (kDebugMode) debugPrint('ApiClient.post retry after SocketException');
await Future.delayed(_retryDelay);
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
} on TimeoutException {
if (kDebugMode) debugPrint('ApiClient.post retry after TimeoutException');
await Future.delayed(_retryDelay);
return await http
.post(Uri.parse(url), headers: headers, body: jsonEncode(body))
.timeout(_timeout);
}
}
/// Upload a single file as multipart/form-data.
///
/// Returns the `file` object from the server response:
/// `{ fileId, url, name, type, mimeType, size, backend }`
Future<Map<String, dynamic>> uploadFile(String url, String filePath) async {
final request = http.MultipartRequest('POST', Uri.parse(url));
const _mimeMap = <String, List<String>>{
'jpg': ['image', 'jpeg'],
'jpeg': ['image', 'jpeg'],
'png': ['image', 'png'],
'webp': ['image', 'webp'],
'mp4': ['video', 'mp4'],
'mov': ['video', 'quicktime'],
};
final ext = filePath.split('.').last.toLowerCase();
final parts = _mimeMap[ext] ?? ['image', 'jpeg'];
request.files.add(await http.MultipartFile.fromPath(
'file',
filePath,
contentType: MediaType(parts[0], parts[1]),
));
late http.StreamedResponse streamed;
try {
streamed = await request.send().timeout(const Duration(seconds: 60));
} catch (e) {
throw Exception('Upload network error: $e');
}
final body = await streamed.stream.bytesToString();
dynamic decoded;
try {
decoded = jsonDecode(body);
} catch (_) {
throw Exception('Upload response parse error');
}
if (streamed.statusCode >= 200 && streamed.statusCode < 300) {
if (decoded is Map<String, dynamic> && decoded['file'] is Map) {
return Map<String, dynamic>.from(decoded['file'] as Map);
}
return decoded is Map<String, dynamic> ? decoded : {};
}
final msg = (decoded is Map && decoded['message'] is String)
? decoded['message'] as String
: 'Upload failed (${streamed.statusCode})';
throw Exception(msg);
}
/// GET request /// GET request
/// ///
/// - If requiresAuth==true, token & username will be attached as query parameters. /// - If requiresAuth==true, token & username will be attached as query parameters.
@@ -109,21 +183,24 @@ class ApiClient {
bool requiresAuth = true, bool requiresAuth = true,
}) async { }) async {
// build final query params including auth if needed // build final query params including auth if needed
final Map<String, dynamic> finalParams = {}; final originalUri = Uri.parse(url);
final queryParams = <String, String>{...originalUri.queryParameters};
if (requiresAuth) { if (requiresAuth) {
final token = await TokenStorage.getToken(); final token = await TokenStorage.getToken();
final username = await TokenStorage.getUsername(); final username = await TokenStorage.getUsername();
if (token != null && username != null) { if (token != null && username != null) {
finalParams['token'] = token; queryParams['token'] = token;
finalParams['username'] = username; queryParams['username'] = username;
} }
// Guest mode: proceed without token — let backend decide // 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; late http.Response response;
try { try {
@@ -133,7 +210,7 @@ class ApiClient {
throw Exception('Network error: $e'); 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', 'end_date': '2026-04-16',
'start_time': '09:00', 'start_time': '09:00',
'end_time': '18:00', 'end_time': '18:00',
'pincode': '560001', 'pincode': '680001',
'place': 'Bengaluru International Exhibition Centre', 'place': 'Thekkinkadu Maidanam',
'is_bookable': true, 'is_bookable': true,
'event_type': 5, 'event_type': 5,
'thumb_img': 'https://picsum.photos/seed/event1/600/400', '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'}, {'is_primary': false, 'image': 'https://picsum.photos/seed/event1b/800/500'},
], ],
'important_information': 'Please carry a valid photo ID for entry.', 'important_information': 'Please carry a valid photo ID for entry.',
'venue_name': 'BIEC Hall 2', 'venue_name': 'Maidanam Grounds',
'event_status': 'active', 'event_status': 'active',
'latitude': 13.0147, 'latitude': 10.5276,
'longitude': 77.5636, 'longitude': 76.2144,
'location_name': 'Bengaluru', 'location_name': 'Thrissur',
'important_info': [ 'important_info': [
{'title': 'Entry', 'value': 'Free with registration'}, {'title': 'Entry', 'value': 'Free with registration'},
{'title': 'Parking', 'value': 'Available on-site'}, {'title': 'Parking', 'value': 'Available on-site'},

View File

@@ -2,18 +2,20 @@
class ApiEndpoints { class ApiEndpoints {
// Change this to your desired backend base URL (local or UAT) // Change this to your desired backend base URL (local or UAT)
// For local Django dev use: "http://127.0.0.1:8000/api" // For local Django dev use: "http://127.0.0.1:8000/api"
// For UAT: "https://uat.eventifyplus.com/api" // em.eventifyplus.com / uat.eventifyplus.com DNS → K8s, broken TLS. backend.eventifyplus.com → EC2 174.129.72.160, valid cert.
static const String baseUrl = "https://em.eventifyplus.com/api"; static const String baseUrl = "https://backend.eventifyplus.com/api";
/// Base URL for media files (images, icons uploaded via Django admin). /// Base URL for media files (images, icons uploaded via Django admin).
/// Relative paths like `/media/...` are resolved against this. /// 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 // Auth
static const String register = "$baseUrl/user/register/"; static const String register = "$baseUrl/user/register/";
static const String login = "$baseUrl/user/login/"; static const String login = "$baseUrl/user/login/";
static const String logout = "$baseUrl/user/logout/"; static const String logout = "$baseUrl/user/logout/";
static const String status = "$baseUrl/user/status/"; static const String status = "$baseUrl/user/status/";
static const String updateProfile = "$baseUrl/user/update-profile/";
static const String forgotPassword = "$baseUrl/user/forgot-password/";
// Events // Events
static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types static const String eventTypes = "$baseUrl/events/type-list/"; // list of event types
@@ -22,6 +24,8 @@ class ApiEndpoints {
static const String eventImages = "$baseUrl/events/event-images/"; // event-images static const String eventImages = "$baseUrl/events/event-images/"; // event-images
static const String eventsByCategory = "$baseUrl/events/events-by-category/"; static const String eventsByCategory = "$baseUrl/events/events-by-category/";
static const String eventsByMonth = "$baseUrl/events/events-by-month-year/"; static const String eventsByMonth = "$baseUrl/events/events-by-month-year/";
static const String featuredEvents = "$baseUrl/events/featured-events/";
static const String topEvents = "$baseUrl/events/top-events/";
// Bookings // Bookings
// static const String bookEvent = "$baseUrl/events/book-event/"; // static const String bookEvent = "$baseUrl/events/book-event/";
@@ -38,6 +42,9 @@ class ApiEndpoints {
// Node.js gamification server (same host as reviews) // Node.js gamification server (same host as reviews)
static const String _nodeBase = "https://app.eventifyplus.com/api"; 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 // Gamification / Contributor Module
static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard"; static const String gamificationDashboard = "$_nodeBase/v1/gamification/dashboard";
static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard"; static const String leaderboard = "$_nodeBase/v1/gamification/leaderboard";
@@ -58,5 +65,5 @@ class ApiEndpoints {
// Notifications // Notifications
static const String notificationList = "$baseUrl/notifications/list/"; static const String notificationList = "$baseUrl/notifications/list/";
static const String notificationMarkRead = "$baseUrl/notifications/mark-read/"; static const String notificationMarkRead = "$baseUrl/notifications/mark-read/";
static const String notificationCount = "$baseUrl/notifications/count/"; static const String notificationCount = "$baseUrl/notifications/count";
} }

View File

@@ -12,6 +12,13 @@ import '../models/user_model.dart';
class AuthService { class AuthService {
final ApiClient _api = ApiClient(); final ApiClient _api = ApiClient();
/// Google OAuth 2.0 Web Client ID from Google Cloud Console.
/// Must match the `GOOGLE_CLIENT_ID` env var set on the Django backend
/// so the server can verify the `id_token` audience.
/// Source: Google Cloud Console → APIs & Services → Credentials → Web application.
static const String _googleWebClientId =
'639347358523-mtkm3i8vssuhsun80rp2llt09eou0p8g.apps.googleusercontent.com';
/// LOGIN → returns UserModel /// LOGIN → returns UserModel
Future<UserModel> login(String username, String password) async { Future<UserModel> login(String username, String password) async {
try { try {
@@ -60,6 +67,18 @@ class AuthService {
// Save phone if provided (optional) // Save phone if provided (optional)
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString()); if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
// Save profile photo from login response
final rawPhoto = res['profile_photo']?.toString() ?? '';
if (rawPhoto.isNotEmpty) {
final photoUrl = rawPhoto.startsWith('http') ? rawPhoto : 'https://em.eventifyplus.com$rawPhoto';
await prefs.setString('profileImage_$savedEmail', photoUrl);
await prefs.setString('profileImage', photoUrl);
}
// Save Eventify ID
final eventifyId = res['eventify_id']?.toString() ?? '';
if (eventifyId.isNotEmpty) await prefs.setString('eventify_id', eventifyId);
PostHogService.instance.identify(savedEmail, properties: { PostHogService.instance.identify(savedEmail, properties: {
'username': displayCandidate, 'username': displayCandidate,
'login_method': 'email', 'login_method': 'email',
@@ -146,7 +165,10 @@ class AuthService {
/// GOOGLE OAUTH LOGIN → returns UserModel /// GOOGLE OAUTH LOGIN → returns UserModel
Future<UserModel> googleLogin() async { Future<UserModel> googleLogin() async {
try { try {
final googleSignIn = GoogleSignIn(scopes: ['email']); final googleSignIn = GoogleSignIn(
scopes: const ['email', 'profile'],
serverClientId: _googleWebClientId,
);
final account = await googleSignIn.signIn(); final account = await googleSignIn.signIn();
if (account == null) throw Exception('Google sign-in cancelled'); if (account == null) throw Exception('Google sign-in cancelled');
@@ -178,6 +200,18 @@ class AuthService {
} }
if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString()); if (res['phone_number'] != null) await prefs.setString('phone_number', res['phone_number'].toString());
// Save profile photo from 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: { PostHogService.instance.identify(serverEmail, properties: {
'username': displayName, 'username': displayName,
'login_method': 'google', 'login_method': 'google',
@@ -191,6 +225,16 @@ class AuthService {
} }
} }
/// FORGOT PASSWORD → backend sends reset instructions by email.
/// Frontend never leaks whether the email is registered — same UX on success and 404.
Future<void> forgotPassword(String email) async {
await _api.post(
ApiEndpoints.forgotPassword,
body: {'email': email},
requiresAuth: false,
);
}
/// Logout clear auth token and current_email (keep per-account display_name entries so they persist) /// Logout clear auth token and current_email (keep per-account display_name entries so they persist)
Future<void> logout() async { Future<void> logout() async {
try { try {

View File

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

View File

@@ -109,15 +109,11 @@ class EventsService {
} }
/// Get events by GPS coordinates using haversine distance filtering. /// Get events by GPS coordinates using haversine distance filtering.
/// Automatically expands radius (10 → 25 → 50 → 100 km) until ≥ 6 events found. Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double initialRadiusKm = 10}) async {
const radii = [10.0, 25.0, 50.0, 100.0];
for (final radius in radii) {
if (radius < initialRadiusKm) continue;
final body = { final body = {
'latitude': lat, 'latitude': lat,
'longitude': lng, 'longitude': lng,
'radius_km': radius, 'radius_km': radiusKm,
'page': 1, 'page': 1,
'page_size': 50, 'page_size': 50,
'per_type': 5, 'per_type': 5,
@@ -130,7 +126,31 @@ class EventsService {
if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e))); if (e is Map<String, dynamic>) list.add(EventModel.fromJson(Map<String, dynamic>.from(e)));
} }
} }
if (list.length >= 6 || radius >= 100) return list; 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 []; return [];
} }

View File

@@ -1,6 +1,8 @@
// lib/features/gamification/models/gamification_models.dart // lib/features/gamification/models/gamification_models.dart
// Data models matching TechDocs v2 DB schema for the Contributor Module. // 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) // Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -68,6 +70,10 @@ int tierStartEp(ContributorTier tier) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class UserGamificationProfile { class UserGamificationProfile {
final String userId; final String userId;
final String username;
final String? avatarUrl;
final String? district;
final String? eventifyId;
final int lifetimeEp; // Never resets. Used for tiers + leaderboard. final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
final int currentEp; // Liquid EP accumulated this month. final int currentEp; // Liquid EP accumulated this month.
final int currentRp; // Spendable Reward Points. final int currentRp; // Spendable Reward Points.
@@ -75,6 +81,10 @@ class UserGamificationProfile {
const UserGamificationProfile({ const UserGamificationProfile({
required this.userId, required this.userId,
required this.username,
this.avatarUrl,
this.district,
this.eventifyId,
required this.lifetimeEp, required this.lifetimeEp,
required this.currentEp, required this.currentEp,
required this.currentRp, required this.currentRp,
@@ -82,12 +92,17 @@ class UserGamificationProfile {
}); });
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) { 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( 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, lifetimeEp: ep,
currentEp: (json['current_ep'] 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?) ?? 0, currentRp: (json['current_rp'] as int?) ?? (json['reward_points'] as int?) ?? (json['rp'] as int?) ?? 0,
tier: tierFromEp(ep), tier: tierFromEp(ep),
); );
} }

View File

@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import '../../../core/utils/error_utils.dart'; import '../../../core/utils/error_utils.dart';
import '../models/gamification_models.dart'; import '../models/gamification_models.dart';
import '../services/gamification_service.dart'; import '../services/gamification_service.dart';
import '../../events/services/events_service.dart';
import '../../events/models/event_models.dart';
class GamificationProvider extends ChangeNotifier { class GamificationProvider extends ChangeNotifier {
final GamificationService _service = GamificationService(); final GamificationService _service = GamificationService();
@@ -12,16 +14,18 @@ class GamificationProvider extends ChangeNotifier {
UserGamificationProfile? profile; UserGamificationProfile? profile;
List<LeaderboardEntry> leaderboard = []; List<LeaderboardEntry> leaderboard = [];
List<ShopItem> shopItems = []; List<ShopItem> shopItems = [];
List<AchievementBadge> achievements = []; List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
List<SubmissionModel> submissions = []; List<SubmissionModel> submissions = [];
CurrentUserStats? currentUserStats; CurrentUserStats? currentUserStats;
int totalParticipants = 0; int totalParticipants = 0;
List<String> eventCategories = [];
// Leaderboard filters — matches web version // Leaderboard filters — matches web version
String leaderboardDistrict = 'Overall Kerala'; String leaderboardDistrict = 'Overall Kerala';
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month' String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
bool isLoading = false; bool isLoading = false;
bool isLeaderboardLoading = false;
String? error; String? error;
// TTL guard — prevents redundant API calls from multiple screens // TTL guard — prevents redundant API calls from multiple screens
@@ -32,8 +36,10 @@ class GamificationProvider extends ChangeNotifier {
// Load everything at once (called when ContributeScreen or ProfileScreen mounts) // Load everything at once (called when ContributeScreen or ProfileScreen mounts)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Future<void> loadAll({bool force = false}) async { Future<void> loadAll({bool force = false}) async {
// Skip if recently loaded (within 2 minutes) unless forced debugPrint('GamificationProvider.loadAll(force: $force) called');
if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) { // 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; return;
} }
@@ -42,11 +48,37 @@ class GamificationProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
debugPrint('GamificationProvider: Requesting dashboard, leaderboard, etc...');
final results = await Future.wait([ final results = await Future.wait([
_service.getDashboard(), _service.getDashboard().catchError((e) {
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod), debugPrint('Dashboard error: $e');
_service.getShopItems(), return const DashboardResponse(
_service.getAchievements(), profile: UserGamificationProfile(
userId: '',
username: '',
lifetimeEp: 0,
currentEp: 0,
currentRp: 0,
tier: ContributorTier.BRONZE,
),
);
}),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod).catchError((e) {
debugPrint('Leaderboard error: $e');
return const LeaderboardResponse(entries: []);
}),
_service.getShopItems().catchError((e) {
debugPrint('Shop error: $e');
return <ShopItem>[];
}),
_service.getAchievements().catchError((e) {
debugPrint('Achievements error: $e');
return <AchievementBadge>[];
}),
EventsService().getEventTypes().catchError((e) {
debugPrint('EventTypes error: $e');
return <EventTypeModel>[];
}),
]); ]);
final dashboard = results[0] as DashboardResponse; final dashboard = results[0] as DashboardResponse;
@@ -54,16 +86,27 @@ class GamificationProvider extends ChangeNotifier {
submissions = dashboard.submissions; submissions = dashboard.submissions;
final lbResponse = results[1] as LeaderboardResponse; final lbResponse = results[1] as LeaderboardResponse;
leaderboard = lbResponse.entries; leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
currentUserStats = lbResponse.currentUser; currentUserStats = lbResponse.currentUser;
totalParticipants = lbResponse.totalParticipants; totalParticipants = lbResponse.totalParticipants;
shopItems = results[2] as List<ShopItem>; shopItems = results[2] as List<ShopItem>;
// Prefer achievements from dashboard API; fall back to getAchievements() // Prefer achievements from dashboard API; fall back to fetched or existing defaults
final dashAchievements = dashboard.achievements; final dashAchievements = dashboard.achievements;
final fetchedAchievements = results[3] as List<AchievementBadge>; final fetchedAchievements = results[3] as List<AchievementBadge>;
achievements = dashAchievements.isNotEmpty ? dashAchievements : fetchedAchievements;
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(); _lastLoadTime = DateTime.now();
} catch (e) { } catch (e) {
@@ -75,19 +118,44 @@ class GamificationProvider extends ChangeNotifier {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Change district filter // Load leaderboard independently (decoupled from loadAll)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Future<void> setDistrict(String district) async { Future<void> loadLeaderboard() async {
if (leaderboardDistrict == district) return; isLeaderboardLoading = true;
leaderboardDistrict = district;
notifyListeners(); notifyListeners();
try { try {
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod); final response = await _service.getLeaderboard(
district: leaderboardDistrict,
timePeriod: leaderboardTimePeriod,
);
leaderboard = response.entries; leaderboard = response.entries;
currentUserStats = response.currentUser; currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants; totalParticipants = response.totalParticipants;
} catch (e) { } catch (e) {
error = userFriendlyError(e); error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
notifyListeners();
}
}
// ---------------------------------------------------------------------------
// Change district filter
// ---------------------------------------------------------------------------
Future<void> setDistrict(String district) async {
if (leaderboardDistrict == district) return;
leaderboardDistrict = district;
isLeaderboardLoading = true;
notifyListeners();
try {
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
} }
notifyListeners(); notifyListeners();
} }
@@ -98,14 +166,17 @@ class GamificationProvider extends ChangeNotifier {
Future<void> setTimePeriod(String period) async { Future<void> setTimePeriod(String period) async {
if (leaderboardTimePeriod == period) return; if (leaderboardTimePeriod == period) return;
leaderboardTimePeriod = period; leaderboardTimePeriod = period;
isLeaderboardLoading = true;
notifyListeners(); notifyListeners();
try { try {
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period); final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
leaderboard = response.entries; leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
currentUserStats = response.currentUser; currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants; totalParticipants = response.totalParticipants;
} catch (e) { } catch (e) {
error = userFriendlyError(e); error = userFriendlyError(e);
} finally {
isLeaderboardLoading = false;
} }
notifyListeners(); notifyListeners();
} }
@@ -120,6 +191,10 @@ class GamificationProvider extends ChangeNotifier {
if (profile != null) { if (profile != null) {
profile = UserGamificationProfile( profile = UserGamificationProfile(
userId: profile!.userId, userId: profile!.userId,
username: profile!.username,
avatarUrl: profile!.avatarUrl,
district: profile!.district,
eventifyId: profile!.eventifyId,
lifetimeEp: profile!.lifetimeEp, lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp, currentEp: profile!.currentEp,
currentRp: profile!.currentRp - item.rpCost, currentRp: profile!.currentRp - item.rpCost,
@@ -136,6 +211,10 @@ class GamificationProvider extends ChangeNotifier {
if (profile != null) { if (profile != null) {
profile = UserGamificationProfile( profile = UserGamificationProfile(
userId: profile!.userId, userId: profile!.userId,
username: profile!.username,
avatarUrl: profile!.avatarUrl,
district: profile!.district,
eventifyId: profile!.eventifyId,
lifetimeEp: profile!.lifetimeEp, lifetimeEp: profile!.lifetimeEp,
currentEp: profile!.currentEp, currentEp: profile!.currentEp,
currentRp: profile!.currentRp + item.rpCost, currentRp: profile!.currentRp + item.rpCost,
@@ -153,4 +232,41 @@ class GamificationProvider extends ChangeNotifier {
Future<void> submitContribution(Map<String, dynamic> data) async { Future<void> submitContribution(Map<String, dynamic> data) async {
await _service.submitContribution(data); await _service.submitContribution(data);
} }
// ---------------------------------------------------------------------------
// Helper: Filter by district and re-rank results locally.
// This is a fallback in case the backend returns a global list for a district-specific query.
// ---------------------------------------------------------------------------
List<LeaderboardEntry> _filterAndReRank(List<LeaderboardEntry> entries, String district, String period) {
if (entries.isEmpty) return [];
List<LeaderboardEntry> result = entries;
if (district != 'Overall Kerala') {
// Case-insensitive filtering to be robust
result = entries.where((e) => e.district?.toLowerCase() == district.toLowerCase()).toList();
}
// Sort based on period
if (period == 'this_month') {
result.sort((a, b) => b.monthlyPoints.compareTo(a.monthlyPoints));
} else {
result.sort((a, b) => b.lifetimeEp.compareTo(a.lifetimeEp));
}
// Assign new ranks based on local sort order
return List.generate(result.length, (i) {
final e = result[i];
return LeaderboardEntry(
rank: i + 1,
username: e.username,
avatarUrl: e.avatarUrl,
lifetimeEp: e.lifetimeEp,
monthlyPoints: e.monthlyPoints,
tier: e.tier,
eventsCount: e.eventsCount,
isCurrentUser: e.isCurrentUser,
district: e.district,
);
});
}
} }

View File

@@ -23,7 +23,7 @@ class GamificationService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboard() async { Future<DashboardResponse> getDashboard() async {
final email = await _getUserEmail(); final email = await _getUserEmail();
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$email'; final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(email)}';
final res = await _api.get(url, requiresAuth: false); final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {}; final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
@@ -50,7 +50,7 @@ class GamificationService {
// GET /v1/gamification/dashboard?user_id={userId} // GET /v1/gamification/dashboard?user_id={userId}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Future<DashboardResponse> getDashboardForUser(String userId) async { Future<DashboardResponse> getDashboardForUser(String userId) async {
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$userId'; final url = '${ApiEndpoints.gamificationDashboard}?user_id=${Uri.encodeComponent(userId)}';
final res = await _api.get(url, requiresAuth: false); final res = await _api.get(url, requiresAuth: false);
final profileJson = res['profile'] as Map<String, dynamic>? ?? {}; final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
@@ -152,11 +152,28 @@ class GamificationService {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Submit Contribution // Submit Contribution
// POST /v1/gamification/submit-event body: event data // 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 { Future<void> submitContribution(Map<String, dynamic> data) async {
final email = await _getUserEmail(); final email = await _getUserEmail();
final body = <String, dynamic>{'user_id': email, ...data};
// 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( await _api.post(
ApiEndpoints.contributeSubmit, ApiEndpoints.contributeSubmit,
body: body, body: body,
@@ -175,20 +192,17 @@ class GamificationService {
} catch (_) { } catch (_) {
// Fall through to defaults // Fall through to defaults
} }
return _defaultBadges; return defaultBadges;
} }
static const _defaultBadges = [ static const defaultBadges = [
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0), AchievementBadge(id: 'badge-01', title: 'Newcomer', description: 'First Event Posted', iconName: 'star', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', isUnlocked: false, progress: 0.0), AchievementBadge(id: 'badge-02', title: 'Contributor', description: '10th Event Posted within a month', iconName: 'crown', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', isUnlocked: false, progress: 0.0), AchievementBadge(id: 'badge-03', title: 'On Fire!', description: '3 Day Streak of logging in', iconName: 'fire', isUnlocked: false, progress: 0.67),
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0), AchievementBadge(id: 'badge-04', title: 'Verified', description: 'Identity Verified successfully', iconName: 'verified', isUnlocked: true, progress: 1.0),
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', isUnlocked: false, progress: 0.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: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', 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: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', isUnlocked: false, progress: 0.0), 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: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', 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),
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
]; ];
} }

View File

@@ -125,6 +125,10 @@ class _ReviewCardState extends State<ReviewCard> {
imageUrl: 'https://api.dicebear.com/9.x/notionists/svg?seed=${Uri.encodeComponent(_review.username)}', imageUrl: 'https://api.dicebear.com/9.x/notionists/svg?seed=${Uri.encodeComponent(_review.username)}',
width: 36, width: 36,
height: 36, height: 36,
memCacheWidth: 72,
memCacheHeight: 72,
maxWidthDiskCache: 144,
maxHeightDiskCache: 144,
placeholder: (_, __) => CircleAvatar( placeholder: (_, __) => CircleAvatar(
radius: 18, radius: 18,
backgroundColor: _avatarColor(_review.username), backgroundColor: _avatarColor(_review.username),

View 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();
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
// CTR-004 — Public contributor profile page. // CTR-004 — Public contributor profile page.
// Shows avatar, tier ring, EP stats, and submission grid for any contributor. // 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 'package:flutter/material.dart';
import '../features/gamification/models/gamification_models.dart'; import '../features/gamification/models/gamification_models.dart';
@@ -215,10 +216,15 @@ class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: SizedBox.expand( child: SizedBox.expand(
child: Image.network( child: CachedNetworkImage(
firstImage, imageUrl: firstImage,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)), memCacheWidth: 400,
memCacheHeight: 300,
maxWidthDiskCache: 800,
maxHeightDiskCache: 600,
placeholder: (_, __) => Container(color: const Color(0xFF1E293B)),
errorWidget: (_, __, ___) => Container(color: const Color(0xFF334155)),
), ),
), ),
), ),

View File

@@ -15,9 +15,23 @@ class DesktopLoginScreen extends StatefulWidget {
} }
class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin { class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTickerProviderStateMixin {
// Login controllers
final TextEditingController _emailCtrl = TextEditingController(); final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController(); final TextEditingController _passCtrl = TextEditingController();
// Signup controllers
final TextEditingController _signupEmailCtrl = TextEditingController();
final TextEditingController _signupPhoneCtrl = TextEditingController();
final TextEditingController _signupPassCtrl = TextEditingController();
final TextEditingController _signupConfirmCtrl = TextEditingController();
String? _signupDistrict;
static const _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
final AuthService _auth = AuthService(); final AuthService _auth = AuthService();
AnimationController? _controller; AnimationController? _controller;
@@ -31,13 +45,18 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
final Curve _curve = Curves.easeInOutCubic; final Curve _curve = Curves.easeInOutCubic;
bool _isAnimating = false; bool _isAnimating = false;
bool _loading = false; // network loading flag bool _loading = false;
bool _isSignupMode = false;
@override @override
void dispose() { void dispose() {
_controller?.dispose(); _controller?.dispose();
_emailCtrl.dispose(); _emailCtrl.dispose();
_passCtrl.dispose(); _passCtrl.dispose();
_signupEmailCtrl.dispose();
_signupPhoneCtrl.dispose();
_signupPassCtrl.dispose();
_signupConfirmCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -52,7 +71,6 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
_leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate( _leftTextOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)), CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.35, curve: Curves.easeOut)),
); );
_formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate( _formOpacityAnim = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)), CurvedAnimation(parent: _controller!, curve: const Interval(0.0, 0.55, curve: Curves.easeOut)),
); );
@@ -68,9 +86,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
Future<void> _performLoginFlow(double initialLeftWidth) async { Future<void> _performLoginFlow(double initialLeftWidth) async {
if (_isAnimating || _loading) return; if (_isAnimating || _loading) return;
setState(() { setState(() => _loading = true);
_loading = true;
});
final email = _emailCtrl.text.trim(); final email = _emailCtrl.text.trim();
final password = _passCtrl.text; final password = _passCtrl.text;
@@ -87,14 +103,9 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
} }
try { try {
// Capture user model returned by AuthService (AuthService already saves prefs)
await _auth.login(email, password); await _auth.login(email, password);
// on success run opening animation
await _startCollapseAnimation(initialLeftWidth); await _startCollapseAnimation(initialLeftWidth);
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pushReplacement(PageRouteBuilder( Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true), pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
transitionDuration: Duration.zero, transitionDuration: Duration.zero,
@@ -102,90 +113,135 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
)); ));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final message = userFriendlyError(e); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
setState(() => _isAnimating = false); setState(() => _isAnimating = false);
} finally { } finally {
if (mounted) setState(() { if (mounted) setState(() => _loading = false);
_loading = false;
});
} }
} }
void _openRegister() { Future<void> _performSignupFlow(double initialLeftWidth) async {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen())); if (_isAnimating || _loading) return;
final email = _signupEmailCtrl.text.trim();
final phone = _signupPhoneCtrl.text.trim();
final pass = _signupPassCtrl.text;
final confirm = _signupConfirmCtrl.text;
if (email.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
return;
}
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
return;
}
if (pass.length < 6) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Password must be at least 6 characters')));
return;
}
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
} }
@override setState(() => _loading = true);
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65); try {
final bool animAvailable = _controller != null && _leftWidthAnim != null; await _auth.register(
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0); email: email,
phoneNumber: phone,
password: pass,
district: _signupDistrict,
);
await _startCollapseAnimation(initialLeftWidth);
if (!mounted) return;
Navigator.of(context).pushReplacement(PageRouteBuilder(
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
));
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
setState(() => _isAnimating = false);
} finally {
if (mounted) setState(() => _loading = false);
}
}
return Scaffold( Future<void> _openForgotPasswordDialog() async {
body: SafeArea( final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
child: AnimatedBuilder( bool submitting = false;
animation: animation,
builder: (context, child) {
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
return Row( await showDialog<void>(
children: [ context: context,
Container( builder: (ctx) {
width: leftWidth, return StatefulBuilder(
height: double.infinity, builder: (ctx, setDialog) {
// color: const Color(0xFF0B63D6), return AlertDialog(
decoration: AppDecoration.blueGradient, title: const Text('Forgot Password'),
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28), content: SizedBox(
child: Opacity( width: 360,
opacity: leftTextOpacity,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4), const Text("Enter your email and we'll send reset instructions."),
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
const Spacer(),
const Text('Welcome Back!', style: TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text( TextField(
'Sign in to access your dashboard, manage events, and stay connected.', controller: emailCtrl,
style: TextStyle(color: Colors.white70, fontSize: 14), decoration: InputDecoration(
), prefixIcon: const Icon(Icons.email),
const Spacer(flex: 2), labelText: 'Email',
Opacity( border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
child: const Padding(
padding: EdgeInsets.only(bottom: 12.0),
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
), ),
keyboardType: TextInputType.emailAddress,
), ),
], ],
), ),
), ),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
ElevatedButton(
onPressed: submitting
? null
: () async {
final email = emailCtrl.text.trim();
if (email.isEmpty) return;
setDialog(() => submitting = true);
try {
await _auth.forgotPassword(email);
} catch (_) {
// safe-degrade
}
if (!ctx.mounted) return;
Navigator.of(ctx).pop();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("If that email is registered, we've sent reset instructions."),
duration: Duration(seconds: 4),
), ),
);
},
child: submitting
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Send reset link'),
),
],
);
},
);
},
);
Expanded( emailCtrl.dispose();
child: Transform.translate( }
offset: Offset(formOffset, 0),
child: Opacity( Widget _buildLoginFields(double safeInitialWidth) {
opacity: formOpacity, return Column(
child: Container( key: const ValueKey('login'),
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -217,21 +273,16 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
children: [ children: [
Row(children: [ Row(children: [
Checkbox(value: true, onChanged: (_) {}), Checkbox(value: true, onChanged: (_) {}),
const Text('Remember me') const Text('Remember me'),
]), ]),
TextButton(onPressed: () {}, child: const Text('Forgot Password?')) TextButton(onPressed: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
height: 50, height: 50,
child: ElevatedButton( child: ElevatedButton(
onPressed: (_isAnimating || _loading) onPressed: (_isAnimating || _loading) ? null : () => _performLoginFlow(safeInitialWidth),
? null
: () {
final double initial = safeInitialWidth;
_performLoginFlow(initial);
},
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: (_isAnimating || _loading) child: (_isAnimating || _loading)
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
@@ -242,7 +293,10 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
Wrap( Wrap(
alignment: WrapAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
children: [ children: [
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")), TextButton(
onPressed: () => setState(() => _isSignupMode = true),
child: const Text("Don't have an account? Register"),
),
TextButton(onPressed: () {}, child: const Text('Contact support')), TextButton(onPressed: () {}, child: const Text('Contact support')),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -255,8 +309,171 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
child: const Text('Continue as Guest'), child: const Text('Continue as Guest'),
), ),
], ],
) ),
], ],
);
}
Widget _buildSignupFields(double safeInitialWidth) {
return Column(
key: const ValueKey('signup'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('Create Account', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), textAlign: TextAlign.center),
const SizedBox(height: 6),
const Text('Fill in your details to get started', textAlign: TextAlign.center, style: TextStyle(color: Colors.black54)),
const SizedBox(height: 22),
TextField(
controller: _signupEmailCtrl,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.email),
labelText: 'Email Address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _signupPhoneCtrl,
keyboardType: TextInputType.phone,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.phone),
labelText: 'Phone Number',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _signupDistrict,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.location_on),
labelText: 'District (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
onChanged: (v) => setState(() => _signupDistrict = v),
),
const SizedBox(height: 12),
TextField(
controller: _signupPassCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock),
labelText: 'Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 12),
TextField(
controller: _signupConfirmCtrl,
obscureText: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.lock_outline),
labelText: 'Confirm Password',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
),
const SizedBox(height: 16),
SizedBox(
height: 50,
child: ElevatedButton(
onPressed: (_isAnimating || _loading) ? null : () => _performSignupFlow(safeInitialWidth),
style: ElevatedButton.styleFrom(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
child: (_isAnimating || _loading)
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Create Account', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 12),
Center(
child: TextButton(
onPressed: () => setState(() => _isSignupMode = false),
child: const Text('Already have an account? Sign in'),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final screenW = MediaQuery.of(context).size.width;
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
final bool animAvailable = _controller != null && _leftWidthAnim != null;
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
return Scaffold(
body: SafeArea(
child: AnimatedBuilder(
animation: animation,
builder: (context, child) {
final leftWidth = animAvailable ? _leftWidthAnim!.value : safeInitialWidth;
final leftTextOpacity = animAvailable && _leftTextOpacityAnim != null ? _leftTextOpacityAnim!.value : 1.0;
final formOpacity = animAvailable && _formOpacityAnim != null ? _formOpacityAnim!.value : 1.0;
final formOffset = animAvailable && _formOffsetAnim != null ? _formOffsetAnim!.value : 0.0;
return Row(
children: [
Container(
width: leftWidth,
height: double.infinity,
decoration: AppDecoration.blueGradient,
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
child: Opacity(
opacity: leftTextOpacity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
const Spacer(),
Text(
_isSignupMode ? 'Join Eventify!' : 'Welcome Back!',
style: const TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Text(
_isSignupMode
? 'Create your account to discover events, book tickets, and connect with your community.'
: 'Sign in to access your dashboard, manage events, and stay connected.',
style: const TextStyle(color: Colors.white70, fontSize: 14),
),
const Spacer(flex: 2),
Opacity(
opacity: leftWidth > (_collapsedWidth + 8) ? 1.0 : 0.0,
child: const Padding(
padding: EdgeInsets.only(bottom: 12.0),
child: Text('© Eventify', style: TextStyle(color: Colors.white54)),
),
),
],
),
),
),
Expanded(
child: Transform.translate(
offset: Offset(formOffset, 0),
child: Opacity(
opacity: formOpacity,
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 36),
child: Center(
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28.0, vertical: 28.0),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 260),
child: _isSignupMode
? _buildSignupFields(safeInitialWidth)
: _buildLoginFields(safeInitialWidth),
),
), ),
), ),
), ),
@@ -274,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 = userFriendlyError(e);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Column(
children: [
TextField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email')),
const SizedBox(height: 8),
TextField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone')),
const SizedBox(height: 8),
TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
const SizedBox(height: 8),
TextField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password')),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(onPressed: _loading ? null : _performRegister, child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register')),
const SizedBox(width: 12),
OutlinedButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
],
)
],
),
),
),
),
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
import 'dart:ui'; import 'dart:ui';
import '../core/utils/error_utils.dart'; import '../core/utils/error_utils.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -22,18 +21,36 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _signupFormKey = GlobalKey<FormState>();
final TextEditingController _emailCtrl = TextEditingController(); final TextEditingController _emailCtrl = TextEditingController();
final TextEditingController _passCtrl = TextEditingController(); final TextEditingController _passCtrl = TextEditingController();
final FocusNode _emailFocus = FocusNode(); final FocusNode _emailFocus = FocusNode();
final FocusNode _passFocus = 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(); final AuthService _auth = AuthService();
bool _loading = false; bool _loading = false;
bool _obscurePassword = true; bool _obscurePassword = true;
bool _rememberMe = false; bool _rememberMe = false;
late VideoPlayerController _videoController; VideoPlayerController? _videoController;
bool _videoInitialized = false; bool _videoInitialized = false;
// Glassmorphism color palette // Glassmorphism color palette
@@ -50,24 +67,35 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_initVideo(); _initVideo();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ScaffoldMessenger.of(context).clearSnackBars();
});
} }
Future<void> _initVideo() async { Future<void> _initVideo() async {
try {
_videoController = VideoPlayerController.asset('assets/login-bg.mp4'); _videoController = VideoPlayerController.asset('assets/login-bg.mp4');
await _videoController.initialize(); await _videoController!.initialize();
_videoController.setLooping(true); _videoController!.setLooping(true);
_videoController.setVolume(0); _videoController!.setVolume(0);
_videoController.play(); _videoController!.play();
if (mounted) setState(() => _videoInitialized = true); if (mounted) setState(() => _videoInitialized = true);
} catch (_) {
// Video asset not available — skip background video
}
} }
@override @override
void dispose() { void dispose() {
_videoController.dispose(); _videoController?.dispose();
_emailCtrl.dispose(); _emailCtrl.dispose();
_passCtrl.dispose(); _passCtrl.dispose();
_emailFocus.dispose(); _emailFocus.dispose();
_passFocus.dispose(); _passFocus.dispose();
_signupEmailCtrl.dispose();
_signupPhoneCtrl.dispose();
_signupPassCtrl.dispose();
_signupConfirmCtrl.dispose();
super.dispose(); super.dispose();
} }
@@ -119,7 +147,11 @@ class _LoginScreenState extends State<LoginScreen> {
} }
void _openRegister() { void _openRegister() {
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false))); setState(() => _isSignupMode = true);
}
void _openLogin() {
setState(() => _isSignupMode = false);
} }
void _showComingSoon() { void _showComingSoon() {
@@ -128,6 +160,182 @@ 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 { Future<void> _performGoogleLogin() async {
try { try {
setState(() => _loading = true); setState(() => _loading = true);
@@ -240,14 +448,14 @@ class _LoginScreenState extends State<LoginScreen> {
body: Stack( body: Stack(
children: [ children: [
// Video background // Video background
if (_videoInitialized) if (_videoInitialized && _videoController != null)
Positioned.fill( Positioned.fill(
child: FittedBox( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
child: SizedBox( child: SizedBox(
width: _videoController.value.size.width, width: _videoController!.value.size.width,
height: _videoController.value.size.height, height: _videoController!.value.size.height,
child: VideoPlayer(_videoController), child: VideoPlayer(_videoController!),
), ),
), ),
), ),
@@ -277,6 +485,14 @@ class _LoginScreenState extends State<LoginScreen> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400), constraints: const BoxConstraints(maxWidth: 400),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 280),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
child: _isSignupMode
? KeyedSubtree(key: const ValueKey('signup'), child: _buildSignupForm(context))
: KeyedSubtree(
key: const ValueKey('login'),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -406,7 +622,7 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
// Forgot Password // Forgot Password
GestureDetector( GestureDetector(
onTap: _showComingSoon, onTap: _openForgotPasswordSheet,
child: const Text( child: const Text(
'Forgot Password?', 'Forgot Password?',
style: TextStyle(color: _textMuted, fontSize: 12), style: TextStyle(color: _textMuted, fontSize: 12),
@@ -571,148 +787,239 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
), ),
),
),
], ],
), ),
); );
} }
}
/// Register screen calls backend register endpoint via AuthService.register Widget _buildSignupForm(BuildContext context) {
class RegisterScreen extends StatefulWidget { return Form(
final bool isDesktop; key: _signupFormKey,
const RegisterScreen({Key? key, this.isDesktop = false}) : super(key: key); 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 // Email
State<RegisterScreen> createState() => _RegisterScreenState(); 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> { // Phone
final _formKey = GlobalKey<FormState>(); const Padding(
final TextEditingController _emailCtrl = TextEditingController(); padding: EdgeInsets.only(left: 4, bottom: 8),
final TextEditingController _phoneCtrl = TextEditingController(); child: Text('Phone', style: TextStyle(color: _textMuted, fontSize: 13)),
final TextEditingController _passCtrl = TextEditingController(); ),
final TextEditingController _confirmCtrl = TextEditingController(); TextFormField(
final AuthService _auth = AuthService(); controller: _signupPhoneCtrl,
keyboardType: TextInputType.phone,
bool _loading = false; style: const TextStyle(color: _textWhite, fontSize: 14),
String? _selectedDistrict; cursorColor: Colors.white54,
decoration: _glassInputDecoration(
static const _districts = [ hint: 'Enter your phone number',
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha', prefixIcon: Icons.phone_outlined,
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad', ),
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', validator: (v) {
];
@override
void dispose() {
_emailCtrl.dispose();
_phoneCtrl.dispose();
_passCtrl.dispose();
_confirmCtrl.dispose();
super.dispose();
}
Future<void> _performRegister() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final email = _emailCtrl.text.trim();
final phone = _phoneCtrl.text.trim();
final pass = _passCtrl.text;
final confirm = _confirmCtrl.text;
if (pass != confirm) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
return;
}
setState(() => _loading = true);
try {
await _auth.register(
email: email,
phoneNumber: phone,
password: pass,
district: _selectedDistrict,
);
if (!mounted) return;
Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (_) => const HomeScreen()));
} catch (e) {
if (!mounted) return;
final message = userFriendlyError(e);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} finally {
if (mounted) setState(() => _loading = false);
}
}
String? _emailValidator(String? v) {
if (v == null || v.trim().isEmpty) return 'Enter email';
final emailRegex = RegExp(r"^[^@]+@[^@]+\.[^@]+");
if (!emailRegex.hasMatch(v.trim())) return 'Enter a valid email';
return null;
}
String? _phoneValidator(String? v) {
if (v == null || v.trim().isEmpty) return 'Enter phone number'; if (v == null || v.trim().isEmpty) return 'Enter phone number';
if (v.trim().length < 7) return 'Enter a valid phone number'; if (v.trim().length < 7) return 'Enter a valid phone number';
return null; return null;
} },
textInputAction: TextInputAction.next,
String? _passwordValidator(String? v) {
if (v == null || v.isEmpty) return 'Enter password';
if (v.length < 6) return 'Password must be at least 6 characters';
return null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email'), validator: _emailValidator, keyboardType: TextInputType.emailAddress),
const SizedBox(height: 8),
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: _selectedDistrict,
decoration: const InputDecoration(labelText: 'District (optional)'),
items: _districts.map((d) => DropdownMenuItem(value: d, child: Text(d))).toList(),
onChanged: (v) => setState(() => _selectedDistrict = v),
), ),
const SizedBox(height: 8),
TextFormField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password'), validator: _passwordValidator),
const SizedBox(height: 8),
TextFormField(controller: _confirmCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Confirm password'), validator: _passwordValidator),
const SizedBox(height: 16), const SizedBox(height: 16),
// 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),
// Password
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Password', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupPassCtrl,
obscureText: _signupObscurePass,
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Create a password',
prefixIcon: Icons.lock_outline_rounded,
suffixIcon: IconButton(
icon: Icon(
_signupObscurePass ? Icons.visibility_off_outlined : Icons.visibility_outlined,
color: _textMuted,
size: 20,
),
onPressed: () => setState(() => _signupObscurePass = !_signupObscurePass),
),
),
validator: _passwordValidator,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 16),
// Confirm password
const Padding(
padding: EdgeInsets.only(left: 4, bottom: 8),
child: Text('Confirm password', style: TextStyle(color: _textMuted, fontSize: 13)),
),
TextFormField(
controller: _signupConfirmCtrl,
obscureText: _signupObscureConfirm,
style: const TextStyle(color: _textWhite, fontSize: 14),
cursorColor: Colors.white54,
decoration: _glassInputDecoration(
hint: 'Re-enter your password',
prefixIcon: Icons.lock_outline_rounded,
suffixIcon: IconButton(
icon: Icon(
_signupObscureConfirm ? Icons.visibility_off_outlined : Icons.visibility_outlined,
color: _textMuted,
size: 20,
),
onPressed: () => setState(() => _signupObscureConfirm = !_signupObscureConfirm),
),
),
validator: _passwordValidator,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _performSignup(),
),
const SizedBox(height: 24),
// Create Account button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: Container(
onPressed: _loading ? null : _performRegister, decoration: BoxDecoration(
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'), 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

View File

@@ -57,6 +57,7 @@ class _SearchScreenState extends State<SearchScreen> {
List<_LocationItem> _searchResults = []; List<_LocationItem> _searchResults = [];
bool _showSearchResults = false; bool _showSearchResults = false;
bool _loadingLocation = false; bool _loadingLocation = false;
bool _isSearching = false;
@override @override
void initState() { void initState() {
@@ -124,14 +125,48 @@ class _SearchScreenState extends State<SearchScreen> {
Navigator.of(context).pop(result); Navigator.of(context).pop(result);
} }
void _selectAndClose(String location) { Future<void> _selectAndClose(String location) async {
// Looks up pincode + coordinates from the database for the given city name. // Looks up pincode + coordinates from the database for the given city name.
final match = _locationDb.cast<_LocationItem?>().firstWhere( final match = _locationDb.cast<_LocationItem?>().firstWhere(
(loc) => loc!.city.toLowerCase() == location.toLowerCase() || (loc) => loc != null && (loc.city.toLowerCase() == location.toLowerCase() ||
loc.displayTitle.toLowerCase() == location.toLowerCase(), loc.displayTitle.toLowerCase() == location.toLowerCase()),
orElse: () => null, orElse: () => null,
); );
_selectWithPincode(location, pincode: match?.pincode, lat: match?.lat, lng: match?.lng);
if (match != null) {
_selectWithPincode(location, pincode: match.pincode, lat: match.lat, lng: match.lng);
return;
}
// Fallback: Geocode the location name
setState(() => _isSearching = true);
try {
final placemarksByAddress = await locationFromAddress(location);
if (placemarksByAddress.isNotEmpty) {
final loc = placemarksByAddress.first;
final placemarks = await placemarkFromCoordinates(loc.latitude, loc.longitude);
String label = location;
String? pincode;
if (placemarks.isNotEmpty) {
final p = placemarks.first;
final parts = <String>[];
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
if ((p.subAdministrativeArea ?? '').isNotEmpty && p.subAdministrativeArea != p.locality) parts.add(p.subAdministrativeArea!);
if (parts.isNotEmpty) label = parts.join(', ');
pincode = p.postalCode;
}
if (mounted) {
_selectWithPincode(label, pincode: pincode ?? 'all', lat: loc.latitude, lng: loc.longitude);
}
return;
}
} catch (_) {
// Geocoding failed, proceed with just the text label
} finally {
if (mounted) setState(() => _isSearching = false);
}
_selectWithPincode(location);
} }
Future<void> _useCurrentLocation() async { Future<void> _useCurrentLocation() async {
@@ -263,6 +298,7 @@ class _SearchScreenState extends State<SearchScreen> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _ctrl, controller: _ctrl,
enabled: !_isSearching,
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: 'Search city, area or locality', hintText: 'Search city, area or locality',
hintStyle: TextStyle(color: Color(0xFF9CA3AF)), hintStyle: TextStyle(color: Color(0xFF9CA3AF)),
@@ -282,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( IconButton(
icon: const Icon(Icons.clear, size: 20), icon: const Icon(Icons.clear, size: 20),
onPressed: () { onPressed: () {

View File

@@ -15,7 +15,7 @@ class SettingsScreen extends StatefulWidget {
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true; bool _notifications = true;
String _appVersion = '1.6(p)'; String _appVersion = '2.0.4';
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
@override @override
@@ -314,7 +314,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [ children: [
const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))), const Expanded(child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600))),
InkWell( InkWell(
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))), onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help (coming soon)'))),
child: Container( child: Container(
width: 40, width: 40,
height: 40, height: 40,
@@ -338,7 +338,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
icon: Icons.person, icon: Icons.person,
title: 'Edit Profile', title: 'Edit Profile',
subtitle: 'Change username, email or photo', subtitle: 'Change username, email or photo',
onTap: () => 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 SizedBox(height: 12),
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
@@ -379,7 +379,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildTile( _buildTile(
icon: Icons.privacy_tip_outlined, icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy', title: 'Privacy Policy',
subtitle: 'Demo app', subtitle: 'Coming Soon',
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())), onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -6,19 +6,19 @@ class TicketsBookedScreen extends StatelessWidget {
void _onScannerTap(BuildContext context) { void _onScannerTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Scanner tapped (demo)')), SnackBar(content: Text('Scanner tapped (coming soon)')),
); );
} }
void _onWhatsappTap(BuildContext context) { void _onWhatsappTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')), SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
); );
} }
void _onCallTap(BuildContext context) { void _onCallTap(BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Call tapped (demo)')), SnackBar(content: Text('Call (coming soon)')),
); );
} }

View File

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

View File

@@ -55,6 +55,10 @@ class TierAvatarRing extends StatelessWidget {
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: _avatarUrl, imageUrl: _avatarUrl,
memCacheWidth: (size * 2).round(),
memCacheHeight: (size * 2).round(),
maxWidthDiskCache: (size * 4).round(),
maxHeightDiskCache: (size * 4).round(),
imageBuilder: (context, imageProvider) => CircleAvatar( imageBuilder: (context, imageProvider) => CircleAvatar(
radius: radius, radius: radius,
backgroundImage: imageProvider, backgroundImage: imageProvider,

View File

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

View File

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