Compare commits
40 Commits
main
...
1bb25b026c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bb25b026c | |||
| 924c80da00 | |||
| d275324205 | |||
| 7b9873a80a | |||
| 195509abb6 | |||
| 6c2efbccc6 | |||
| 34d6586afa | |||
| 692f96bfce | |||
| ac95a44a07 | |||
| 9676ede50b | |||
| c6121d7754 | |||
| b4c4c4bd53 | |||
| 64e7323213 | |||
| 632754415d | |||
| fe8af7cfe6 | |||
| 29e326b8fc | |||
| 847577c09d | |||
| e63e9daa0c | |||
| 605c9277a5 | |||
| cd7864f4fb | |||
| 760fc920fc | |||
| 7df6bb6c56 | |||
| 2366d25478 | |||
| d2b49d4eb5 | |||
| b55f02e057 | |||
| 87cc56dc64 | |||
| 6503d9bc1b | |||
| dd7268cd98 | |||
| 04af387945 | |||
| cac2671fd6 | |||
| cf21e0a58c | |||
| a26b7544f5 | |||
| 9dcd5bae16 | |||
| 48f143399d | |||
| 378d054dc4 | |||
| f98e0fe617 | |||
| e8e2e7ac28 | |||
| ee0151efe5 | |||
| 5b373e8694 | |||
| 97245e01c4 |
44
CHANGELOG.md
44
CHANGELOG.md
@@ -6,50 +6,6 @@ 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.
@@ -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 = flutter.versionCode
|
versionCode = 17
|
||||||
versionName = flutter.versionName
|
versionName = "1.6.1(p)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- SIGNING CONFIG ----------
|
// ---------- SIGNING CONFIG ----------
|
||||||
|
|||||||
59
android/app/proguard-rules.pro
vendored
59
android/app/proguard-rules.pro
vendored
@@ -26,62 +26,3 @@
|
|||||||
-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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,18 +18,6 @@
|
|||||||
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"
|
||||||
@@ -43,6 +31,11 @@
|
|||||||
<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. -->
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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.
@@ -47,18 +47,5 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
// 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: 25);
|
static const Duration _timeout = Duration(seconds: 10);
|
||||||
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;
|
||||||
|
|
||||||
@@ -31,7 +27,13 @@ class ApiClient {
|
|||||||
|
|
||||||
late http.Response response;
|
late http.Response response;
|
||||||
try {
|
try {
|
||||||
response = await _postWithRetry(url, headers, finalBody);
|
response = await http
|
||||||
|
.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');
|
||||||
|
|
||||||
@@ -97,82 +99,6 @@ 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.
|
||||||
@@ -183,24 +109,21 @@ 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 originalUri = Uri.parse(url);
|
final Map<String, dynamic> finalParams = {};
|
||||||
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) {
|
||||||
queryParams['token'] = token;
|
finalParams['token'] = token;
|
||||||
queryParams['username'] = username;
|
finalParams['username'] = username;
|
||||||
}
|
}
|
||||||
// Guest mode: proceed without token — let backend decide
|
// Guest mode: proceed without token — let backend decide
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params != null) {
|
if (params != null) finalParams.addAll(params);
|
||||||
queryParams.addAll(params.map((k, v) => MapEntry(k, v?.toString() ?? '')));
|
|
||||||
}
|
|
||||||
|
|
||||||
final uri = originalUri.replace(queryParameters: queryParams);
|
final uri = Uri.parse(url).replace(queryParameters: finalParams.map((k, v) => MapEntry(k, v?.toString())));
|
||||||
|
|
||||||
late http.Response response;
|
late http.Response response;
|
||||||
try {
|
try {
|
||||||
@@ -210,7 +133,7 @@ class ApiClient {
|
|||||||
throw Exception('Network error: $e');
|
throw Exception('Network error: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
return _handleResponse(url, response, queryParams);
|
return _handleResponse(url, response, finalParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -227,8 +150,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': '680001',
|
'pincode': '560001',
|
||||||
'place': 'Thekkinkadu Maidanam',
|
'place': 'Bengaluru International Exhibition Centre',
|
||||||
'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',
|
||||||
@@ -237,11 +160,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': 'Maidanam Grounds',
|
'venue_name': 'BIEC Hall 2',
|
||||||
'event_status': 'active',
|
'event_status': 'active',
|
||||||
'latitude': 10.5276,
|
'latitude': 13.0147,
|
||||||
'longitude': 76.2144,
|
'longitude': 77.5636,
|
||||||
'location_name': 'Thrissur',
|
'location_name': 'Bengaluru',
|
||||||
'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'},
|
||||||
|
|||||||
@@ -2,20 +2,18 @@
|
|||||||
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"
|
||||||
// em.eventifyplus.com / uat.eventifyplus.com DNS → K8s, broken TLS. backend.eventifyplus.com → EC2 174.129.72.160, valid cert.
|
// For UAT: "https://uat.eventifyplus.com/api"
|
||||||
static const String baseUrl = "https://backend.eventifyplus.com/api";
|
static const String baseUrl = "https://em.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://backend.eventifyplus.com";
|
static const String mediaBaseUrl = "https://em.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
|
||||||
@@ -24,8 +22,6 @@ 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/";
|
||||||
@@ -42,9 +38,6 @@ 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";
|
||||||
@@ -65,5 +58,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/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,6 @@ 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 {
|
||||||
@@ -67,18 +60,6 @@ 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',
|
||||||
@@ -165,10 +146,7 @@ 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(
|
final googleSignIn = GoogleSignIn(scopes: ['email']);
|
||||||
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');
|
||||||
|
|
||||||
@@ -200,18 +178,6 @@ 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',
|
||||||
@@ -225,16 +191,6 @@ 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 {
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ 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,
|
||||||
@@ -109,8 +105,6 @@ 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)
|
||||||
@@ -173,8 +167,6 @@ 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',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,11 +109,15 @@ class EventsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get events by GPS coordinates using haversine distance filtering.
|
/// Get events by GPS coordinates using haversine distance filtering.
|
||||||
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double radiusKm = 10.0}) async {
|
/// Automatically expands radius (10 → 25 → 50 → 100 km) until ≥ 6 events found.
|
||||||
|
Future<List<EventModel>> getEventsByLocation(double lat, double lng, {double initialRadiusKm = 10}) async {
|
||||||
|
const radii = [10.0, 25.0, 50.0, 100.0];
|
||||||
|
for (final radius in radii) {
|
||||||
|
if (radius < initialRadiusKm) continue;
|
||||||
final body = {
|
final body = {
|
||||||
'latitude': lat,
|
'latitude': lat,
|
||||||
'longitude': lng,
|
'longitude': lng,
|
||||||
'radius_km': radiusKm,
|
'radius_km': radius,
|
||||||
'page': 1,
|
'page': 1,
|
||||||
'page_size': 50,
|
'page_size': 50,
|
||||||
'per_type': 5,
|
'per_type': 5,
|
||||||
@@ -126,31 +130,7 @@ 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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list;
|
if (list.length >= 6 || radius >= 100) 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 [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// 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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -70,10 +68,6 @@ 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.
|
||||||
@@ -81,10 +75,6 @@ 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,
|
||||||
@@ -92,17 +82,12 @@ class UserGamificationProfile {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
|
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
|
||||||
debugPrint('Mapping UserGamificationProfile from JSON: $json');
|
final ep = (json['lifetime_ep'] as int?) ?? 0;
|
||||||
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'] ?? json['email'] ?? json['userId'] ?? '').toString(),
|
userId: json['user_id'] as String? ?? '',
|
||||||
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?) ?? (json['monthly_points'] as int?) ?? (json['points_this_month'] as int?) ?? 0,
|
currentEp: (json['current_ep'] as int?) ?? 0,
|
||||||
currentRp: (json['current_rp'] as int?) ?? (json['reward_points'] as int?) ?? (json['rp'] as int?) ?? 0,
|
currentRp: (json['current_rp'] as int?) ?? 0,
|
||||||
tier: tierFromEp(ep),
|
tier: tierFromEp(ep),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ 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();
|
||||||
@@ -14,18 +12,16 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
UserGamificationProfile? profile;
|
UserGamificationProfile? profile;
|
||||||
List<LeaderboardEntry> leaderboard = [];
|
List<LeaderboardEntry> leaderboard = [];
|
||||||
List<ShopItem> shopItems = [];
|
List<ShopItem> shopItems = [];
|
||||||
List<AchievementBadge> achievements = GamificationService.defaultBadges; // Initialize with defaults
|
List<AchievementBadge> achievements = [];
|
||||||
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
|
||||||
@@ -36,10 +32,8 @@ 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 {
|
||||||
debugPrint('GamificationProvider.loadAll(force: $force) called');
|
// Skip if recently loaded (within 2 minutes) unless forced
|
||||||
// Skip if recently loaded (within 2 minutes) unless forced or profile is null
|
if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
||||||
if (!force && profile != null && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
|
||||||
debugPrint('GamificationProvider.loadAll skipped due to TTL');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,37 +42,11 @@ 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().catchError((e) {
|
_service.getDashboard(),
|
||||||
debugPrint('Dashboard error: $e');
|
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
||||||
return const DashboardResponse(
|
_service.getShopItems(),
|
||||||
profile: UserGamificationProfile(
|
_service.getAchievements(),
|
||||||
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;
|
||||||
@@ -86,27 +54,16 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
submissions = dashboard.submissions;
|
submissions = dashboard.submissions;
|
||||||
|
|
||||||
final lbResponse = results[1] as LeaderboardResponse;
|
final lbResponse = results[1] as LeaderboardResponse;
|
||||||
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
|
leaderboard = lbResponse.entries;
|
||||||
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 fetched or existing defaults
|
// Prefer achievements from dashboard API; fall back to getAchievements()
|
||||||
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) {
|
||||||
@@ -117,45 +74,20 @@ class GamificationProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Load leaderboard independently (decoupled from loadAll)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
Future<void> loadLeaderboard() async {
|
|
||||||
isLeaderboardLoading = true;
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
final response = await _service.getLeaderboard(
|
|
||||||
district: leaderboardDistrict,
|
|
||||||
timePeriod: leaderboardTimePeriod,
|
|
||||||
);
|
|
||||||
leaderboard = response.entries;
|
|
||||||
currentUserStats = response.currentUser;
|
|
||||||
totalParticipants = response.totalParticipants;
|
|
||||||
} catch (e) {
|
|
||||||
error = userFriendlyError(e);
|
|
||||||
} finally {
|
|
||||||
isLeaderboardLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Change district filter
|
// Change district filter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
Future<void> setDistrict(String district) async {
|
Future<void> setDistrict(String district) async {
|
||||||
if (leaderboardDistrict == district) return;
|
if (leaderboardDistrict == district) return;
|
||||||
leaderboardDistrict = district;
|
leaderboardDistrict = district;
|
||||||
isLeaderboardLoading = true;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||||
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -166,17 +98,14 @@ 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 = _filterAndReRank(response.entries, leaderboardDistrict, period);
|
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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -191,10 +120,6 @@ 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,
|
||||||
@@ -211,10 +136,6 @@ 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,
|
||||||
@@ -232,41 +153,4 @@ 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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=${Uri.encodeComponent(email)}';
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$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=${Uri.encodeComponent(userId)}';
|
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$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,28 +152,11 @@ class GamificationService {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Submit Contribution
|
// Submit Contribution
|
||||||
// 1. Upload each image to /v1/upload/file → get back { url, fileId, ... }
|
// POST /v1/gamification/submit-event body: event data
|
||||||
// 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,
|
||||||
@@ -192,17 +175,20 @@ 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: 'Newcomer', description: 'First Event Posted', iconName: 'star', isUnlocked: false, progress: 0.0),
|
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', 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-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', 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-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', 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-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', 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-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', 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-06', title: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', 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-07', title: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', 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-08', title: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
|
||||||
|
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,10 +125,6 @@ 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),
|
||||||
|
|||||||
@@ -1,547 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
197
lib/features/share/share_rank_card.dart
Normal file
197
lib/features/share/share_rank_card.dart
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import '../../widgets/tier_avatar_ring.dart';
|
||||||
|
|
||||||
|
class ShareRankCard extends StatefulWidget {
|
||||||
|
final String username;
|
||||||
|
final String tier;
|
||||||
|
final int rank;
|
||||||
|
final int ep;
|
||||||
|
final int rewardPoints;
|
||||||
|
|
||||||
|
const ShareRankCard({
|
||||||
|
super.key,
|
||||||
|
required this.username,
|
||||||
|
required this.tier,
|
||||||
|
required this.rank,
|
||||||
|
required this.ep,
|
||||||
|
this.rewardPoints = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShareRankCard> createState() => _ShareRankCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShareRankCardState extends State<ShareRankCard> {
|
||||||
|
final GlobalKey _boundaryKey = GlobalKey();
|
||||||
|
bool _sharing = false;
|
||||||
|
|
||||||
|
static const _tierGradients = {
|
||||||
|
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
|
||||||
|
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
|
||||||
|
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
|
||||||
|
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
|
||||||
|
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
|
||||||
|
};
|
||||||
|
|
||||||
|
List<Color> get _gradient {
|
||||||
|
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _share() async {
|
||||||
|
if (_sharing) return;
|
||||||
|
setState(() => _sharing = true);
|
||||||
|
try {
|
||||||
|
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
|
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
if (byteData == null) return;
|
||||||
|
final bytes = byteData.buffer.asUint8List();
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(file.path)],
|
||||||
|
text: 'I\'m ranked #${widget.rank} on Eventify with ${widget.ep} EP! 🏆 #Eventify #Kerala',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not share rank card')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _sharing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
|
key: _boundaryKey,
|
||||||
|
child: Container(
|
||||||
|
width: 320,
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Tier gradient header bar
|
||||||
|
Container(
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: _gradient),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Avatar
|
||||||
|
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Username
|
||||||
|
Text(
|
||||||
|
widget.username,
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Tier badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: _gradient),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.tier.isEmpty ? 'Contributor' : widget.tier,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Stats row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_stat('Rank', '#${widget.rank}'),
|
||||||
|
Container(width: 1, height: 40, color: Colors.white12),
|
||||||
|
_stat('EP', '${widget.ep}'),
|
||||||
|
Container(width: 1, height: 40, color: Colors.white12),
|
||||||
|
_stat('RP', '${widget.rewardPoints}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Branding
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'EVENTIFY',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _sharing ? null : _share,
|
||||||
|
icon: _sharing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.share, size: 18),
|
||||||
|
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1D4ED8),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stat(String label, String value) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (coming soon)')),
|
const SnackBar(content: Text('Tickets booked (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 (coming soon)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Scanner tapped (demo)')));
|
||||||
}),
|
}),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
_iconSquare(primary, Icons.chat, onTap: () {
|
_iconSquare(primary, Icons.chat, onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat (coming soon)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Chat tapped (demo)')));
|
||||||
}),
|
}),
|
||||||
SizedBox(width: 12),
|
SizedBox(width: 12),
|
||||||
_iconSquare(primary, Icons.call, onTap: () {
|
_iconSquare(primary, Icons.call, onTap: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call (coming soon)')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Call tapped (demo)')));
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -519,8 +519,6 @@ 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,
|
||||||
@@ -584,8 +582,6 @@ 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,
|
||||||
@@ -842,7 +838,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 (coming soon)'))),
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Notifications (demo)'))),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// 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';
|
||||||
@@ -12,7 +11,6 @@ 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';
|
||||||
@@ -46,7 +44,7 @@ const _districts = [
|
|||||||
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', 'Other',
|
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', 'Other',
|
||||||
];
|
];
|
||||||
|
|
||||||
const _categories_fallback = [
|
const _categories = [
|
||||||
'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
|
'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
|
||||||
'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
|
'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
|
||||||
];
|
];
|
||||||
@@ -90,7 +88,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
|
|
||||||
DateTime? _selectedDate;
|
DateTime? _selectedDate;
|
||||||
TimeOfDay? _selectedTime;
|
TimeOfDay? _selectedTime;
|
||||||
String _selectedCategory = 'Music';
|
String _selectedCategory = _categories.first;
|
||||||
String _selectedDistrict = _districts.first;
|
String _selectedDistrict = _districts.first;
|
||||||
List<XFile> _images = [];
|
List<XFile> _images = [];
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
@@ -106,12 +104,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
PostHogService.instance.screen('Contribute');
|
PostHogService.instance.screen('Contribute');
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Gamification endpoints are authed — guests would hit 401 and pollute logs.
|
context.read<GamificationProvider>().loadAll();
|
||||||
// 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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,63 +121,26 @@ 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: _blue,
|
backgroundColor: _pageBg,
|
||||||
body: Center(child: BouncingLoader(color: Colors.white)),
|
body: Center(child: BouncingLoader(color: _blue)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 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: Colors.white, // Changed from _blue
|
backgroundColor: _pageBg,
|
||||||
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(),
|
||||||
_buildTabContent(provider),
|
Expanded(child: _buildTabContent(provider)),
|
||||||
] else if (_mainTab == 1) ...[
|
|
||||||
_buildLeaderboardTab(provider),
|
|
||||||
] else if (_mainTab == 2) ...[
|
|
||||||
_buildAchievementsTab(provider),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 100),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -192,561 +148,6 @@ 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
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -757,14 +158,12 @@ 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(20, 24, 20, 8),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
|
// Tier pill
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _blue,
|
color: _blue,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -772,49 +171,51 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(tierIcon, color: Colors.white, size: 14),
|
Icon(tierIcon, color: tierColor, size: 16),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
tierLabel(tier).toUpperCase(),
|
tierLabel(tier),
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.normal, fontSize: 13, letterSpacing: 0.5),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Liquid EP
|
// Liquid EP
|
||||||
Icon(Icons.bolt_outlined, color: _blue, size: 18),
|
Icon(Icons.bolt, color: _blue, size: 18),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${profile?.currentEp ?? 0}', style: const TextStyle(color: _darkText, fontWeight: FontWeight.normal, fontSize: 15)),
|
Text(
|
||||||
|
'${profile?.currentEp ?? 0}',
|
||||||
|
style: const TextStyle(color: _darkText, fontWeight: FontWeight.w700, fontSize: 15),
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
const Text('Liquid EP', style: TextStyle(color: _subText, fontSize: 12)),
|
const Text('EP', style: TextStyle(color: _subText, fontSize: 12)),
|
||||||
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// RP
|
// RP
|
||||||
Icon(Icons.card_giftcard_outlined, color: _rpOrange, size: 18),
|
Icon(Icons.card_giftcard, color: _rpOrange, size: 18),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${profile?.currentRp ?? 0}', style: TextStyle(color: _rpOrange, fontWeight: FontWeight.normal, fontSize: 15)),
|
Text(
|
||||||
|
'${profile?.currentRp ?? 0}',
|
||||||
|
style: TextStyle(color: _rpOrange, fontWeight: FontWeight.w700, 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 Rank button
|
// Share button
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => _shareRank(provider),
|
onTap: () => _shareRank(provider),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: _border),
|
color: const Color(0xFFF1F5F9),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
|
||||||
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -936,7 +337,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 ? tabWidth - 8 : 0,
|
width: tabWidth - 8,
|
||||||
height: 44,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _blue,
|
color: _blue,
|
||||||
@@ -962,22 +363,18 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
_tabIcons[i],
|
_tabIcons[i],
|
||||||
size: 16, // Slightly smaller icon
|
size: 18,
|
||||||
color: isActive ? Colors.white : const Color(0xFF64748B),
|
color: isActive ? Colors.white : const Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4), // Tighter spacing
|
const SizedBox(width: 6),
|
||||||
Flexible(
|
Text(
|
||||||
child: Text(
|
|
||||||
_tabLabels[i],
|
_tabLabels[i],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 1,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11, // Smaller font for better fit
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: isActive ? Colors.white : const Color(0xFF64748B),
|
color: isActive ? Colors.white : const Color(0xFF64748B),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1073,8 +470,6 @@ 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),
|
||||||
@@ -1220,13 +615,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
children: [
|
children: [
|
||||||
_inputLabel('Category', required: true),
|
_inputLabel('Category', required: true),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
if (provider.eventCategories.isEmpty)
|
_dropdown(_selectedCategory, _categories, (v) => setState(() => _selectedCategory = v!)),
|
||||||
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!)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1339,14 +728,12 @@ 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,
|
||||||
@@ -1370,8 +757,7 @@ 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,
|
||||||
isExpanded: true,
|
items: items.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 14)))).toList(),
|
||||||
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,
|
||||||
@@ -1480,14 +866,12 @@ 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.\-]'))]),
|
||||||
),
|
),
|
||||||
@@ -1497,9 +881,7 @@ 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(
|
||||||
@@ -1796,7 +1178,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final data = <String, dynamic>{
|
final data = <String, dynamic>{
|
||||||
'event_name': _titleCtl.text.trim(),
|
'title': _titleCtl.text.trim(),
|
||||||
'category': _selectedCategory,
|
'category': _selectedCategory,
|
||||||
'district': _selectedDistrict,
|
'district': _selectedDistrict,
|
||||||
'date': _selectedDate!.toIso8601String(),
|
'date': _selectedDate!.toIso8601String(),
|
||||||
@@ -1850,7 +1232,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
|||||||
_mapsLinkCtl.clear();
|
_mapsLinkCtl.clear();
|
||||||
_selectedDate = null;
|
_selectedDate = null;
|
||||||
_selectedTime = null;
|
_selectedTime = null;
|
||||||
_selectedCategory = _categories_fallback.first;
|
_selectedCategory = _categories.first;
|
||||||
_selectedDistrict = _districts.first;
|
_selectedDistrict = _districts.first;
|
||||||
_images.clear();
|
_images.clear();
|
||||||
_coordMessage = null;
|
_coordMessage = null;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// 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';
|
||||||
@@ -216,15 +215,10 @@ class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: SizedBox.expand(
|
child: SizedBox.expand(
|
||||||
child: CachedNetworkImage(
|
child: Image.network(
|
||||||
imageUrl: firstImage,
|
firstImage,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
memCacheWidth: 400,
|
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)),
|
||||||
memCacheHeight: 300,
|
|
||||||
maxWidthDiskCache: 800,
|
|
||||||
maxHeightDiskCache: 600,
|
|
||||||
placeholder: (_, __) => Container(color: const Color(0xFF1E293B)),
|
|
||||||
errorWidget: (_, __, ___) => Container(color: const Color(0xFF334155)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,23 +15,9 @@ 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;
|
||||||
@@ -45,18 +31,13 @@ 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;
|
bool _loading = false; // network loading flag
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +52,7 @@ 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)),
|
||||||
);
|
);
|
||||||
@@ -86,7 +68,9 @@ 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(() => _loading = true);
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
});
|
||||||
|
|
||||||
final email = _emailCtrl.text.trim();
|
final email = _emailCtrl.text.trim();
|
||||||
final password = _passCtrl.text;
|
final password = _passCtrl.text;
|
||||||
@@ -103,9 +87,14 @@ 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,
|
||||||
@@ -113,135 +102,90 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
final message = userFriendlyError(e);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
setState(() => _isAnimating = false);
|
setState(() => _isAnimating = false);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() {
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performSignupFlow(double initialLeftWidth) async {
|
void _openRegister() {
|
||||||
if (_isAnimating || _loading) return;
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const DesktopRegisterScreen()));
|
||||||
|
|
||||||
final email = _signupEmailCtrl.text.trim();
|
|
||||||
final phone = _signupPhoneCtrl.text.trim();
|
|
||||||
final pass = _signupPassCtrl.text;
|
|
||||||
final confirm = _signupConfirmCtrl.text;
|
|
||||||
|
|
||||||
if (email.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter email')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (phone.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter phone number')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pass.length < 6) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Password must be at least 6 characters')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pass != confirm) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Passwords do not match')));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _loading = true);
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
try {
|
final double safeInitialWidth = (screenW * 0.45).clamp(360.0, screenW * 0.65);
|
||||||
await _auth.register(
|
final bool animAvailable = _controller != null && _leftWidthAnim != null;
|
||||||
email: email,
|
final Listenable animation = _controller ?? AlwaysStoppedAnimation(0.0);
|
||||||
phoneNumber: phone,
|
|
||||||
password: pass,
|
|
||||||
district: _signupDistrict,
|
|
||||||
);
|
|
||||||
await _startCollapseAnimation(initialLeftWidth);
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.of(context).pushReplacement(PageRouteBuilder(
|
|
||||||
pageBuilder: (context, a1, a2) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true),
|
|
||||||
transitionDuration: Duration.zero,
|
|
||||||
reverseTransitionDuration: Duration.zero,
|
|
||||||
));
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
|
||||||
setState(() => _isAnimating = false);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _loading = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _openForgotPasswordDialog() async {
|
return Scaffold(
|
||||||
final emailCtrl = TextEditingController(text: _emailCtrl.text.trim());
|
body: SafeArea(
|
||||||
bool submitting = false;
|
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;
|
||||||
|
|
||||||
await showDialog<void>(
|
return Row(
|
||||||
context: context,
|
children: [
|
||||||
builder: (ctx) {
|
Container(
|
||||||
return StatefulBuilder(
|
width: leftWidth,
|
||||||
builder: (ctx, setDialog) {
|
height: double.infinity,
|
||||||
return AlertDialog(
|
// color: const Color(0xFF0B63D6),
|
||||||
title: const Text('Forgot Password'),
|
decoration: AppDecoration.blueGradient,
|
||||||
content: SizedBox(
|
padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 28),
|
||||||
width: 360,
|
child: Opacity(
|
||||||
|
opacity: leftTextOpacity,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text("Enter your email and we'll send reset instructions."),
|
const SizedBox(height: 4),
|
||||||
|
const Text('EVENTIFY', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 22)),
|
||||||
|
const Spacer(),
|
||||||
|
const Text('Welcome Back!', style: TextStyle(color: Colors.white, fontSize: 34, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
const Text(
|
||||||
controller: emailCtrl,
|
'Sign in to access your dashboard, manage events, and stay connected.',
|
||||||
decoration: InputDecoration(
|
style: TextStyle(color: Colors.white70, fontSize: 14),
|
||||||
prefixIcon: const Icon(Icons.email),
|
),
|
||||||
labelText: 'Email',
|
const Spacer(flex: 2),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
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)),
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel')),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: submitting
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
final email = emailCtrl.text.trim();
|
|
||||||
if (email.isEmpty) return;
|
|
||||||
setDialog(() => submitting = true);
|
|
||||||
try {
|
|
||||||
await _auth.forgotPassword(email);
|
|
||||||
} catch (_) {
|
|
||||||
// safe-degrade
|
|
||||||
}
|
|
||||||
if (!ctx.mounted) return;
|
|
||||||
Navigator.of(ctx).pop();
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text("If that email is registered, we've sent reset instructions."),
|
|
||||||
duration: Duration(seconds: 4),
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
child: submitting
|
|
||||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
: const Text('Send reset link'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
emailCtrl.dispose();
|
Expanded(
|
||||||
}
|
child: Transform.translate(
|
||||||
|
offset: Offset(formOffset, 0),
|
||||||
Widget _buildLoginFields(double safeInitialWidth) {
|
child: Opacity(
|
||||||
return Column(
|
opacity: formOpacity,
|
||||||
key: const ValueKey('login'),
|
child: Container(
|
||||||
|
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: [
|
||||||
@@ -273,16 +217,21 @@ 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: _openForgotPasswordDialog, child: const Text('Forgot Password?')),
|
TextButton(onPressed: () {}, 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) ? null : () => _performLoginFlow(safeInitialWidth),
|
onPressed: (_isAnimating || _loading)
|
||||||
|
? 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))
|
||||||
@@ -293,10 +242,7 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
Wrap(
|
Wrap(
|
||||||
alignment: WrapAlignment.spaceBetween,
|
alignment: WrapAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
|
||||||
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: () {
|
||||||
@@ -309,171 +255,8 @@ 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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -491,3 +274,113 @@ 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')),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -320,8 +320,6 @@ 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),
|
||||||
),
|
),
|
||||||
@@ -531,8 +529,6 @@ 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)),
|
||||||
@@ -786,8 +782,6 @@ 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,
|
||||||
|
|||||||
@@ -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 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
import '../core/utils/error_utils.dart';
|
||||||
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,8 +46,6 @@ 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;
|
||||||
@@ -64,7 +62,6 @@ 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');
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,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') ?? 'Thrissur';
|
final storedLocation = prefs.getString('location') ?? 'Whitefield, Bengaluru';
|
||||||
// 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) {
|
||||||
@@ -115,7 +112,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
_userLng = prefs.getDouble('user_lng');
|
_userLng = prefs.getDouble('user_lng');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch types and location-based events in parallel.
|
// Fetch types and events in parallel for faster loading.
|
||||||
// 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(),
|
||||||
@@ -137,12 +134,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (kDebugMode) debugPrint('HomeScreen init unexpected error: $e');
|
if (mounted) {
|
||||||
if (mounted) setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh notification badge count (fire-and-forget, skip for guests — endpoint is authed)
|
// Refresh notification badge count (fire-and-forget)
|
||||||
if (mounted && !AuthGuard.isGuest) {
|
if (mounted) {
|
||||||
context.read<NotificationProvider>().refreshUnreadCount();
|
context.read<NotificationProvider>().refreshUnreadCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,24 +189,6 @@ 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();
|
||||||
}
|
}
|
||||||
@@ -261,8 +242,6 @@ 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,
|
||||||
@@ -475,8 +454,6 @@ 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,
|
||||||
@@ -490,7 +467,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, heroTag: 'event-hero-${ev.id}-search')));
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -606,51 +583,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Featured events for the hero carousel — from dedicated endpoint, fallback to first 6
|
// Get hero events (first 4 events for the carousel)
|
||||||
List<EventModel> get _heroEvents =>
|
List<EventModel> get _heroEvents => _events.take(6).toList();
|
||||||
_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 {
|
||||||
@@ -958,8 +892,6 @@ 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,
|
||||||
@@ -987,7 +919,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, heroTag: 'event-hero-${ev.id}-sheet')));
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id, initialEvent: ev)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1265,60 +1197,10 @@ 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: ValueListenableBuilder<int>(
|
child: Column(
|
||||||
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
|
||||||
@@ -1439,10 +1321,6 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1512,13 +1390,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, heroTag: 'event-hero-${event.id}-carousel')));
|
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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}-carousel',
|
tag: 'event-hero-${event.id}',
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1530,8 +1408,6 @@ 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: (_, __, ___) =>
|
||||||
@@ -1713,12 +1589,12 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: _topEventsFiltered.isEmpty && _loading
|
child: _allFilteredByDate.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()))),
|
||||||
)
|
)
|
||||||
: _topEventsFiltered.isEmpty
|
: _allFilteredByDate.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)),
|
||||||
@@ -1726,10 +1602,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: _topEventsFiltered.length,
|
itemCount: _allFilteredByDate.length,
|
||||||
itemBuilder: (context, index) => Padding(
|
itemBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.only(right: 12),
|
padding: const EdgeInsets.only(right: 12),
|
||||||
child: _buildTopEventCard(_topEventsFiltered[index]),
|
child: _buildTopEventCard(_allFilteredByDate[index]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1939,11 +1815,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, heroTag: 'event-hero-${event.id}-top')));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id, initialEvent: event)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: 'event-hero-${event.id}-top',
|
tag: 'event-hero-${event.id}',
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 150,
|
width: 150,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1959,8 +1835,6 @@ 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,
|
||||||
@@ -2157,8 +2031,6 @@ 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,
|
||||||
@@ -2226,8 +2098,6 @@ 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,
|
||||||
@@ -2401,8 +2271,6 @@ 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,
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ 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;
|
||||||
final String? heroTag;
|
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent}) : super(key: key);
|
||||||
const LearnMoreScreen({Key? key, required this.eventId, this.initialEvent, this.heroTag}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
|
State<LearnMoreScreen> createState() => _LearnMoreScreenState();
|
||||||
@@ -225,7 +224,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://app.eventifyplus.com/event/${widget.eventId}';
|
'https://uat.eventifyplus.com/events/${widget.eventId}';
|
||||||
await Share.share('$title\n$url', subject: title);
|
await Share.share('$title\n$url', subject: title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +301,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.52;
|
final imageHeight = screenHeight * 0.45;
|
||||||
final topPadding = mediaQuery.padding.top;
|
final topPadding = mediaQuery.padding.top;
|
||||||
|
|
||||||
// ── DESKTOP layout ──────────────────────────────────────────────────
|
// ── DESKTOP layout ──────────────────────────────────────────────────
|
||||||
@@ -332,8 +331,6 @@ 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(
|
||||||
@@ -546,8 +543,6 @@ 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,
|
||||||
@@ -851,8 +846,6 @@ 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(
|
||||||
@@ -898,12 +891,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 + 70, // safely below the icon row
|
top: topPad + 56, // below the icon row
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 40, // clear from the bottom card's -28 overlap
|
bottom: 16,
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: widget.heroTag ?? 'event-hero-${widget.eventId}',
|
tag: 'event-hero-${widget.eventId}',
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
@@ -915,8 +908,6 @@ 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,
|
||||||
@@ -935,10 +926,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
|
|||||||
// ---- No-image placeholder ----
|
// ---- No-image placeholder ----
|
||||||
if (images.isEmpty)
|
if (images.isEmpty)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: topPad + 70,
|
top: topPad + 56,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 40,
|
bottom: 16,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withOpacity(0.15),
|
||||||
@@ -1575,10 +1566,6 @@ 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,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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';
|
||||||
@@ -21,36 +22,18 @@ 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;
|
||||||
|
|
||||||
VideoPlayerController? _videoController;
|
late VideoPlayerController _videoController;
|
||||||
bool _videoInitialized = false;
|
bool _videoInitialized = false;
|
||||||
|
|
||||||
// Glassmorphism color palette
|
// Glassmorphism color palette
|
||||||
@@ -67,35 +50,24 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,11 +119,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openRegister() {
|
void _openRegister() {
|
||||||
setState(() => _isSignupMode = true);
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const RegisterScreen(isDesktop: false)));
|
||||||
}
|
|
||||||
|
|
||||||
void _openLogin() {
|
|
||||||
setState(() => _isSignupMode = false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showComingSoon() {
|
void _showComingSoon() {
|
||||||
@@ -160,182 +128,6 @@ 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);
|
||||||
@@ -448,14 +240,14 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Video background
|
// Video background
|
||||||
if (_videoInitialized && _videoController != null)
|
if (_videoInitialized)
|
||||||
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -485,14 +277,6 @@ 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(
|
||||||
@@ -622,7 +406,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
// Forgot Password
|
// Forgot Password
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: _openForgotPasswordSheet,
|
onTap: _showComingSoon,
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Forgot Password?',
|
'Forgot Password?',
|
||||||
style: TextStyle(color: _textMuted, fontSize: 12),
|
style: TextStyle(color: _textMuted, fontSize: 12),
|
||||||
@@ -787,240 +571,149 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSignupForm(BuildContext context) {
|
|
||||||
return Form(
|
|
||||||
key: _signupFormKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Center(
|
|
||||||
child: Text(
|
|
||||||
'Eventify',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _textWhite.withOpacity(0.7),
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
letterSpacing: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Center(
|
|
||||||
child: Text(
|
|
||||||
'Create Your\nAccount',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: _textWhite,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
height: 1.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
|
|
||||||
// Email
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
|
||||||
child: Text('Email', style: TextStyle(color: _textMuted, fontSize: 13)),
|
|
||||||
),
|
|
||||||
TextFormField(
|
|
||||||
controller: _signupEmailCtrl,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
autofillHints: const [AutofillHints.email],
|
|
||||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
|
||||||
cursorColor: Colors.white54,
|
|
||||||
decoration: _glassInputDecoration(
|
|
||||||
hint: 'Enter your email',
|
|
||||||
prefixIcon: Icons.mail_outline_rounded,
|
|
||||||
),
|
|
||||||
validator: _emailValidator,
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Phone
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: 4, bottom: 8),
|
|
||||||
child: Text('Phone', style: TextStyle(color: _textMuted, fontSize: 13)),
|
|
||||||
),
|
|
||||||
TextFormField(
|
|
||||||
controller: _signupPhoneCtrl,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
style: const TextStyle(color: _textWhite, fontSize: 14),
|
|
||||||
cursorColor: Colors.white54,
|
|
||||||
decoration: _glassInputDecoration(
|
|
||||||
hint: 'Enter your phone number',
|
|
||||||
prefixIcon: Icons.phone_outlined,
|
|
||||||
),
|
|
||||||
validator: (v) {
|
|
||||||
if (v == null || v.trim().isEmpty) return 'Enter phone number';
|
|
||||||
if (v.trim().length < 7) return 'Enter a valid phone number';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 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(
|
|
||||||
width: double.infinity,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
colors: [Color(0xFF2A2A2A), Color(0xFF1A1A1A)],
|
|
||||||
),
|
|
||||||
border: Border.all(color: const Color(0x33FFFFFF)),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.4),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
onTap: _loading ? null : _performSignup,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Center(
|
|
||||||
child: _loading
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: _textWhite),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Create Account',
|
|
||||||
style: TextStyle(color: _textWhite, fontSize: 16, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Back to Sign in
|
|
||||||
Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Already have an account? ',
|
|
||||||
style: TextStyle(color: _textMuted, fontSize: 13),
|
|
||||||
),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _openLogin,
|
|
||||||
child: const Text(
|
|
||||||
'Sign in',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _textWhite,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: _textWhite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Register screen calls backend register endpoint via AuthService.register
|
||||||
|
class RegisterScreen extends StatefulWidget {
|
||||||
|
final bool isDesktop;
|
||||||
|
const RegisterScreen({Key? key, this.isDesktop = false}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegisterScreenState extends State<RegisterScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final TextEditingController _emailCtrl = TextEditingController();
|
||||||
|
final TextEditingController _phoneCtrl = TextEditingController();
|
||||||
|
final TextEditingController _passCtrl = TextEditingController();
|
||||||
|
final TextEditingController _confirmCtrl = TextEditingController();
|
||||||
|
final AuthService _auth = AuthService();
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
String? _selectedDistrict;
|
||||||
|
|
||||||
|
static const _districts = [
|
||||||
|
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||||
|
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||||
|
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||||
|
];
|
||||||
|
|
||||||
|
@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.trim().length < 7) return 'Enter a valid phone number';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _passwordValidator(String? v) {
|
||||||
|
if (v == null || v.isEmpty) return 'Enter password';
|
||||||
|
if (v.length < 6) return 'Password must be at least 6 characters';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Register')),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(18.0),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email'), validator: _emailValidator, keyboardType: TextInputType.emailAddress),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(controller: _phoneCtrl, decoration: const InputDecoration(labelText: 'Phone'), validator: _phoneValidator, keyboardType: TextInputType.phone),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
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),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _performRegister,
|
||||||
|
child: _loading ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) : const Text('Register'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,6 @@ 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() {
|
||||||
@@ -125,48 +124,14 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
Navigator.of(context).pop(result);
|
Navigator.of(context).pop(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _selectAndClose(String location) async {
|
void _selectAndClose(String location) {
|
||||||
// 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 != null && (loc.city.toLowerCase() == location.toLowerCase() ||
|
(loc) => 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 {
|
||||||
@@ -298,7 +263,6 @@ 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)),
|
||||||
@@ -318,12 +282,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isSearching)
|
if (_ctrl.text.isNotEmpty)
|
||||||
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: () {
|
||||||
|
|||||||
@@ -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 = '2.0.4';
|
String _appVersion = '1.6(p)';
|
||||||
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 (coming soon)'))),
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Help tapped (demo)'))),
|
||||||
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 (coming soon)'))),
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
|
||||||
),
|
),
|
||||||
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: 'Coming Soon',
|
subtitle: 'Demo app',
|
||||||
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),
|
||||||
|
|||||||
@@ -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 (coming soon)')),
|
SnackBar(content: Text('Scanner tapped (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onWhatsappTap(BuildContext context) {
|
void _onWhatsappTap(BuildContext context) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Chat/WhatsApp (coming soon)')),
|
SnackBar(content: Text('Chat/WhatsApp tapped (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCallTap(BuildContext context) {
|
void _onCallTap(BuildContext context) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Call (coming soon)')),
|
SnackBar(content: Text('Call tapped (demo)')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
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 {
|
||||||
@@ -109,11 +108,7 @@ class DesktopTopBar extends StatelessWidget {
|
|||||||
return CircleAvatar(
|
return CircleAvatar(
|
||||||
radius: 20,
|
radius: 20,
|
||||||
backgroundColor: Colors.grey.shade200,
|
backgroundColor: Colors.grey.shade200,
|
||||||
backgroundImage: CachedNetworkImageProvider(
|
backgroundImage: NetworkImage(url),
|
||||||
url,
|
|
||||||
maxWidth: 80,
|
|
||||||
maxHeight: 80,
|
|
||||||
),
|
|
||||||
onBackgroundImageError: (_, __) {},
|
onBackgroundImageError: (_, __) {},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ 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,
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -873,10 +873,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2+2"
|
version: "2.4.2+3"
|
||||||
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: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
|
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.10.1"
|
version: "2.11.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: d1eb970495a76abb35e5fa93ee3c58bd76fb6839e2ddf2fbb636674f2b971dd4
|
sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.8.9"
|
version: "2.9.4"
|
||||||
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.9.0 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.35.0"
|
flutter: ">=3.38.0"
|
||||||
|
|||||||
@@ -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: 2.0.4+24
|
version: 1.6.1+17
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user