Compare commits
14 Commits
main
...
6503d9bc1b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6503d9bc1b | |||
| dd7268cd98 | |||
| 04af387945 | |||
| cac2671fd6 | |||
| cf21e0a58c | |||
| a26b7544f5 | |||
| 9dcd5bae16 | |||
| 48f143399d | |||
| 378d054dc4 | |||
| f98e0fe617 | |||
| e8e2e7ac28 | |||
| ee0151efe5 | |||
| 5b373e8694 | |||
| 97245e01c4 |
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"autoPort": true,
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "flutter-web",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": ["/Users/bshtechnologies/Documents/Eventify-frontend/run_web.sh"],
|
||||||
|
"port": 8080
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -46,3 +46,9 @@ app.*.map.json
|
|||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
web/assets/login-bg.mp4
|
web/assets/login-bg.mp4
|
||||||
|
|
||||||
|
# Keystore files (signing keys)
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
# large binary assets — keep local only, not tracked in git
|
||||||
|
assets/login-bg.mp4
|
||||||
|
|||||||
71
CHANGELOG.md
Normal file
71
CHANGELOG.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Desktop Contribute Dashboard**: Full desktop layout for the Contribute screen, matching the web version at eventifyplus.com/contribute
|
||||||
|
- "Contributor Dashboard" title with 3-tab navigation (Contribute, Leaderboard, Achievements)
|
||||||
|
- Two-column submit event form — Event Title + Category side-by-side, Date + Location side-by-side
|
||||||
|
- Contributor Level gradient card with 5-tier milestone progress bar (Bronze → Silver → Gold → Platinum → Diamond)
|
||||||
|
- Sub-navigation row: My Events / Submit Event / Reward Shop
|
||||||
|
- Desktop Leaderboard with All Time / This Month toggle, district pills, podium, and full rank table
|
||||||
|
- Desktop Achievements with 3-column badge grid, progress bars, and lock icons
|
||||||
|
- Inline Reward Shop with RP balance badge and shop item cards
|
||||||
|
- **Gamification Feature Module** (`lib/features/gamification/`):
|
||||||
|
- `GamificationProvider` — ChangeNotifier-based state management
|
||||||
|
- `GamificationService` — mock data for EP, RP, leaderboard entries, achievements, and shop items
|
||||||
|
- Models: `LeaderboardUser`, `Achievement`, `ShopItem`, `ContributorStats`
|
||||||
|
- **Bottom Sheet Date Filters**: Home screen event-category filter chips now open in a modal bottom sheet on mobile
|
||||||
|
- Web runner script (`run_web.sh`) for local Flutter web development server
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Profile Screen**: Completely redesigned to match the web app layout — gradient header card, avatar, stats row (Likes / Posts / Views), and tabbed content
|
||||||
|
- **Profile Card Animations**: Smooth entrance animations matching the React web component
|
||||||
|
- **Contribute Screen (Mobile)**: Full 4-tab rebuild — Contribute, Leaderboard, Achievements, Shop — with animated glass-glider tab bar indicator
|
||||||
|
- **Login Screen**: Updated UI design aligned with the web version
|
||||||
|
- **Event Detail Screen**: Layout updates and improved API data binding
|
||||||
|
- **Theme**: Refreshed dark/light mode colour palette and surface colours
|
||||||
|
- **API Client**: Updated base URL and endpoint paths in `lib/core/api/api_endpoints.dart`
|
||||||
|
- **Fonts**: Integrated full Gilroy font family (Light, Regular, Medium, SemiBold, Bold, ExtraBold — with italic variants)
|
||||||
|
- **Responsive Layout**: Improved breakpoint handling; desktop threshold set at 820 px
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Profile card pixel-perfect alignment with the web version
|
||||||
|
- Calendar screen date-range filter and location search integration
|
||||||
|
- District dropdown naming conflict in leaderboard (`_lbDistricts` vs. `_districts`)
|
||||||
|
- Green points colour (#16A34A) on desktop leaderboard matching web (was blue #0F45CF on mobile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-02-xx
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Leaderboard tab and Achievements tab added to the Contribute screen
|
||||||
|
- Bouncy sliding glass-glider animation for Contribute tab bar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-xx
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Responsive dual-layout system with 820 px breakpoint
|
||||||
|
- Date filtering on the Home screen event feed
|
||||||
|
- Location search integration
|
||||||
|
- Calendar screen bug fixes and improvements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-12-xx
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial screens: Login, Home, Events, Profile, Calendar, Search, Booking
|
||||||
|
- Desktop variants: `DesktopLoginScreen`, `HomeDesktopScreen`
|
||||||
|
- Flutter launcher icons and native splash screen
|
||||||
|
- Gilroy font integration (initial)
|
||||||
|
- `shared_preferences` session caching
|
||||||
13
README.md
13
README.md
@@ -17,6 +17,7 @@
|
|||||||
[](https://flutter.dev/)
|
[](https://flutter.dev/)
|
||||||
[](https://dart.dev/)
|
[](https://dart.dev/)
|
||||||
[](#)
|
[](#)
|
||||||
|
[](#)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,6 +124,18 @@ The app uses an initialization check in `main.dart` that intercepts the launch v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📋 Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](./CHANGELOG.md) for a full history of changes.
|
||||||
|
|
||||||
|
### Latest (v1.4.0 — Preview)
|
||||||
|
- **Desktop Contribute Dashboard** rebuilt to match the web version (Contributor Dashboard, 3-tab nav, two-column form, leaderboard, achievements, reward shop)
|
||||||
|
- **Gamification module** — EP, RP, leaderboard, achievements, shop with Provider state management
|
||||||
|
- **Profile screen** redesigned to match the web app layout with animations
|
||||||
|
- Enhanced animations, responsive improvements, Gilroy font suite, and API updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>Built with ❤️ by the Eventify Team</sub>
|
<sub>Built with ❤️ by the Eventify Team</sub>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ android {
|
|||||||
applicationId = "com.sicherhaven.eventify"
|
applicationId = "com.sicherhaven.eventify"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = 11
|
versionCode = 17
|
||||||
versionName = "1.2(p)"
|
versionName = "1.6.1(p)"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- SIGNING CONFIG ----------
|
// ---------- SIGNING CONFIG ----------
|
||||||
@@ -51,9 +51,9 @@ android {
|
|||||||
// Use the release signing config created above
|
// Use the release signing config created above
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
isShrinkResources = false
|
isShrinkResources = true
|
||||||
// proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
android/app/proguard-rules.pro
vendored
Normal file
28
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Flutter
|
||||||
|
-keep class io.flutter.app.** { *; }
|
||||||
|
-keep class io.flutter.plugin.** { *; }
|
||||||
|
-keep class io.flutter.util.** { *; }
|
||||||
|
-keep class io.flutter.view.** { *; }
|
||||||
|
-keep class io.flutter.** { *; }
|
||||||
|
-keep class io.flutter.plugins.** { *; }
|
||||||
|
|
||||||
|
# Google Maps
|
||||||
|
-keep class com.google.android.gms.maps.** { *; }
|
||||||
|
-keep interface com.google.android.gms.maps.** { *; }
|
||||||
|
|
||||||
|
# Keep annotations
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# Play Core (deferred components)
|
||||||
|
-dontwarn com.google.android.play.core.splitcompat.SplitCompatApplication
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallException
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManager
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallManagerFactory
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest$Builder
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallRequest
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallSessionState
|
||||||
|
-dontwarn com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnFailureListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.OnSuccessListener
|
||||||
|
-dontwarn com.google.android.play.core.tasks.Task
|
||||||
15
docs/hero_slider_changes.csv
Normal file
15
docs/hero_slider_changes.csv
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Component,Old Implementation,New Implementation (matching mvnew.eventifyplus.com/home)
|
||||||
|
Hero Height,120px blue gradient banner,400px immersive full-width hero with rounded corners (20px)
|
||||||
|
Background Image,None (solid blue gradient via AppDecoration),Featured event image with CachedNetworkImage and BoxFit.cover
|
||||||
|
Ken Burns Animation,None,AnimationController 12s loop scaling 1.0 to 1.08 with easeInOut curve
|
||||||
|
Image Rotation,None,Timer.periodic every 6s cycling through first 5 events with AnimatedSwitcher crossfade
|
||||||
|
Dark Overlay,None,LinearGradient top-to-bottom rgba(0/0/0/0.3) to rgba(0/0/0/0.6)
|
||||||
|
Title Text,Welcome Back + username (14px + 22px),Featured event title (36px weight 800) or fallback Discover Amazing Events Near You
|
||||||
|
Subtitle,None,Event date + venue with calendar and location icons (14px white70)
|
||||||
|
CTA Button,None,Learn More or Explore Events button (#2563EB bg white text rounded 12px with blue glow shadow)
|
||||||
|
Indicator Dots,None,Bottom-right pill dots (active=24px white elongated / inactive=8px white40) for up to 5 events
|
||||||
|
Fallback (no events),Blue gradient with username,Blue gradient (#0F45CF to #082369) with static heading and subtitle
|
||||||
|
Featured Modal,None,showDialog with dark overlay (black87) plus 700x500 modal with event image gradient title and Learn More CTA
|
||||||
|
Close Button,None,Circular black50 button with white X icon top-right of modal
|
||||||
|
Animation Mixin,None,SingleTickerProviderStateMixin added to _HomeContentState
|
||||||
|
Disposal,None,AnimationController.dispose and Timer.cancel in dispose()
|
||||||
|
27
docs/landscape_changes.csv
Normal file
27
docs/landscape_changes.csv
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
Phase,File,Action,Priority,Description
|
||||||
|
1,lib/core/constants.dart,MODIFY,Critical,"Add desktop breakpoints: wideDesktopBreakpoint=1200, sidebarExpandedWidth=240, sidebarCollapsedWidth=72, topBarHeight=64"
|
||||||
|
1,lib/widgets/desktop_sidebar.dart,CREATE,Critical,"Collapsible sidebar with icon-only (820-1200px) and full (1200px+) modes, AnimatedContainer transitions"
|
||||||
|
1,lib/widgets/desktop_topbar.dart,CREATE,Critical,"Responsive top bar with LayoutBuilder: search field, notifications, avatar, location"
|
||||||
|
1,lib/widgets/responsive_shell.dart,CREATE,Critical,"Master responsive scaffold wrapping all desktop screens: sidebar + topbar + content"
|
||||||
|
1,lib/screens/responsive_layout.dart,MODIFY,High,"Add three-tier breakpoint support (mobile/tablet/desktop) and ScreenSize enum"
|
||||||
|
1,lib/main.dart,MODIFY,Critical,"Update StartupScreen to use ResponsiveShell for desktop routing"
|
||||||
|
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Split 1077-line monolith into thin orchestrator + focused content widget"
|
||||||
|
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Replace marquee with rich hero carousel (PageView.builder matching mobile)"
|
||||||
|
2,lib/screens/home_desktop_screen.dart,REWRITE,Critical,"Replace fixed 3-column grid with SliverGrid + SliverGridDelegateWithMaxCrossAxisExtent"
|
||||||
|
2,lib/screens/home_desktop_screen.dart,REWRITE,High,"Add MouseRegion + SystemMouseCursors.click on all tappable cards"
|
||||||
|
3,lib/screens/desktop_login_screen.dart,REWRITE,High,"Video background + glassmorphism card matching mobile login style"
|
||||||
|
3,lib/screens/desktop_login_screen.dart,REWRITE,High,"Continue as Guest button prominent"
|
||||||
|
4,lib/screens/calendar_screen.dart,MODIFY,Medium,"Remove LandscapeShell; use Row with Card-based calendar left + event list right inside ResponsiveShell"
|
||||||
|
4,lib/screens/calendar_screen.dart,MODIFY,Medium,"New _eventCardLandscape() with larger images (200px), full title, date, location"
|
||||||
|
4,lib/screens/profile_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with profile card left + tabbed events right"
|
||||||
|
4,lib/screens/profile_screen.dart,MODIFY,Medium,"DefaultTabController with Ongoing/Upcoming/Past tabs + SliverList per tab"
|
||||||
|
4,lib/screens/contribute_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with contributor info/nav left + tab content right"
|
||||||
|
4,lib/screens/contribute_screen.dart,MODIFY,Medium,"Vertical nav list for landscape tabs instead of horizontal TabBar"
|
||||||
|
4,lib/screens/settings_screen.dart,MODIFY,Medium,"Remove LandscapeShell; Row with settings nav left + content right"
|
||||||
|
4,lib/screens/settings_screen.dart,MODIFY,Medium,"Fix logout navigation to use unified approach"
|
||||||
|
4,lib/screens/search_screen.dart,MODIFY,Medium,"Add landscape layout: full-width search bar + Row(popular cities, results)"
|
||||||
|
4,lib/screens/learn_more_screen.dart,MODIFY,Low,"Replace hard-coded 820 with AppConstants; add RepaintBoundary; LayoutBuilder for height"
|
||||||
|
5,lib/widgets/landscape_shell.dart,DELETE,High,"Replaced by responsive_shell.dart"
|
||||||
|
5,lib/widgets/landscape_section_header.dart,DELETE or REWRITE,Low,"Generalize into SectionHeader or delete if unused"
|
||||||
|
5,lib/core/app_decoration.dart,MODIFY,Low,"Add desktop-specific decorations: cardShadow, sidebarDecoration, topBarShadow"
|
||||||
|
8,lib/features/events/services/events_service.dart,MODIFY,Critical,"Set requiresAuth: false on getEventTypes, getEventsByPincode, getEventDetails, getEventsByMonthYear"
|
||||||
|
17
docs/landscape_rebuild_changes.csv
Normal file
17
docs/landscape_rebuild_changes.csv
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
Phase,File,Change Type,Description
|
||||||
|
1,lib/core/constants.dart,MODIFY,Updated sidebarExpandedWidth to 262px and removed sidebarCollapsedWidth
|
||||||
|
1,lib/widgets/desktop_sidebar.dart,REWRITE,Figma match: 262px fixed width with blue gradient and white pill selection and EVENTIFY logo with sparkle icon
|
||||||
|
1,lib/widgets/desktop_topbar.dart,REWRITE,Figma match: search input field plus notification bell plus user avatar and removed location widget
|
||||||
|
1,lib/widgets/responsive_shell.dart,REWRITE,Simplified to always show 262px sidebar on desktop and removed collapsed mode
|
||||||
|
2,lib/screens/home_desktop_screen.dart,REWRITE,Website-matching hero (400px Ken Burns animation plus dark overlay plus CTA) replacing welcome banner
|
||||||
|
2,lib/screens/home_desktop_screen.dart,MODIFY,Pill-shaped category chips (border-radius 999px) with blue active state matching website
|
||||||
|
2,lib/screens/home_desktop_screen.dart,MODIFY,Event cards enhanced with date badge overlay on image and blue/green icons for date/venue
|
||||||
|
2,lib/screens/home_desktop_screen.dart,MODIFY,Background color changed to #FAFBFC (off-white) matching website
|
||||||
|
2,lib/screens/home_desktop_screen.dart,ADD,Featured event modal dialog with large image plus gradient plus Learn More CTA plus close button
|
||||||
|
3,lib/screens/calendar_screen.dart,MODIFY,Desktop layout: calendar grid 60% left plus events panel 40% right with white background
|
||||||
|
3,lib/screens/calendar_screen.dart,MODIFY,Event cards use blue and green dot indicators for date and venue
|
||||||
|
4,lib/screens/profile_screen.dart,MODIFY,Desktop layout: full-width profile banner plus 3-column event grids for Upcoming and Past
|
||||||
|
4,lib/screens/profile_screen.dart,MODIFY,Added _buildDesktopLayout and _buildDesktopEventSection and _buildDesktopEventGridCard
|
||||||
|
5,lib/screens/learn_more_screen.dart,MODIFY,Desktop layout: full-width 300px hero image plus About/Venue two-column plus gallery strip
|
||||||
|
5,lib/screens/learn_more_screen.dart,MODIFY,Added horizontal gallery with overflow count badge and Book Your Spot CTA
|
||||||
|
6,lib/features/events/services/events_service.dart,VERIFIED,Guest access confirmed working with requiresAuth false on 4 read endpoints
|
||||||
|
@@ -81,12 +81,12 @@ class ApiClient {
|
|||||||
if (requiresAuth) {
|
if (requiresAuth) {
|
||||||
final token = await TokenStorage.getToken();
|
final token = await TokenStorage.getToken();
|
||||||
final username = await TokenStorage.getUsername();
|
final username = await TokenStorage.getUsername();
|
||||||
if (token == null || username == null) {
|
if (token != null && username != null) {
|
||||||
throw Exception('Authentication required');
|
|
||||||
}
|
|
||||||
finalParams['token'] = token;
|
finalParams['token'] = token;
|
||||||
finalParams['username'] = username;
|
finalParams['username'] = username;
|
||||||
}
|
}
|
||||||
|
// Guest mode: proceed without token — let backend decide
|
||||||
|
}
|
||||||
|
|
||||||
if (params != null) finalParams.addAll(params);
|
if (params != null) finalParams.addAll(params);
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class ApiClient {
|
|||||||
return _handleResponse(url, response, finalParams);
|
return _handleResponse(url, response, finalParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build request body and attach token + username if required
|
/// Build request body and attach token + username if available
|
||||||
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
Future<Map<String, dynamic>> _buildAuthBody(Map<String, dynamic>? body, bool requiresAuth) async {
|
||||||
final Map<String, dynamic> finalBody = {};
|
final Map<String, dynamic> finalBody = {};
|
||||||
|
|
||||||
@@ -111,13 +111,12 @@ class ApiClient {
|
|||||||
final token = await TokenStorage.getToken();
|
final token = await TokenStorage.getToken();
|
||||||
final username = await TokenStorage.getUsername();
|
final username = await TokenStorage.getUsername();
|
||||||
|
|
||||||
if (token == null || username == null) {
|
if (token != null && username != null) {
|
||||||
throw Exception('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
finalBody['token'] = token;
|
finalBody['token'] = token;
|
||||||
finalBody['username'] = username;
|
finalBody['username'] = username;
|
||||||
}
|
}
|
||||||
|
// Guest mode: proceed without token — let backend decide
|
||||||
|
}
|
||||||
|
|
||||||
if (body != null) finalBody.addAll(body);
|
if (body != null) finalBody.addAll(body);
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,12 @@ class ApiEndpoints {
|
|||||||
// static const String bookEvent = "$baseUrl/events/book-event/";
|
// static const String bookEvent = "$baseUrl/events/book-event/";
|
||||||
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
|
// static const String userSuccessBookings = "$baseUrl/events/event/user-success-bookings/";
|
||||||
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
|
// static const String userCancelledBookings = "$baseUrl/events/event/user-cancelled-bookings/";
|
||||||
|
|
||||||
|
// Gamification / Contributor Module (TechDocs v2)
|
||||||
|
static const String gamificationProfile = "$baseUrl/v1/user/gamification-profile/";
|
||||||
|
static const String leaderboard = "$baseUrl/v1/leaderboard/";
|
||||||
|
static const String shopItems = "$baseUrl/v1/shop/items/";
|
||||||
|
static const String shopRedeem = "$baseUrl/v1/shop/redeem/";
|
||||||
|
static const String contributeSubmit = "$baseUrl/v1/contributions/submit/";
|
||||||
|
static const String gradeContribution = "$baseUrl/v1/admin/contributions/"; // append {id}/grade/
|
||||||
}
|
}
|
||||||
|
|||||||
71
lib/core/auth/auth_guard.dart
Normal file
71
lib/core/auth/auth_guard.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// lib/core/auth/auth_guard.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../screens/login_screen.dart';
|
||||||
|
|
||||||
|
class AuthGuard {
|
||||||
|
static bool _isGuest = false;
|
||||||
|
|
||||||
|
static bool get isGuest => _isGuest;
|
||||||
|
static bool get isLoggedIn => !_isGuest;
|
||||||
|
|
||||||
|
static void setGuest(bool value) => _isGuest = value;
|
||||||
|
|
||||||
|
/// Call before any action that requires login.
|
||||||
|
/// Returns true if logged in (proceed). Returns false if guest (shows prompt).
|
||||||
|
static bool requireLogin(BuildContext context,
|
||||||
|
{String reason = 'This feature requires an account.'}) {
|
||||||
|
if (!_isGuest) return true;
|
||||||
|
_showLoginPrompt(context, reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _showLoginPrompt(BuildContext context, String reason) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_outline, size: 48, color: Color(0xFF0B63D6)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(reason,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Sign in or create an account to continue.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 14)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0B63D6),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Sign In',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class AppConstants {
|
class AppConstants {
|
||||||
// Layout
|
// Layout — breakpoints
|
||||||
static const double desktopBreakpoint = 820;
|
static const double desktopBreakpoint = 820;
|
||||||
|
static const double wideDesktopBreakpoint = 1200;
|
||||||
static const double tabletBreakpoint = 600;
|
static const double tabletBreakpoint = 600;
|
||||||
|
|
||||||
|
// Desktop sidebar
|
||||||
|
static const double sidebarExpandedWidth = 262;
|
||||||
|
static const double topBarHeight = 64;
|
||||||
|
static const double desktopHorizontalPadding = 24;
|
||||||
|
|
||||||
// Padding & Radius
|
// Padding & Radius
|
||||||
static const double defaultPadding = 16;
|
static const double defaultPadding = 16;
|
||||||
static const double cardRadius = 14;
|
static const double cardRadius = 14;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart' show kDebugMode, debugPrint;
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../../../core/api/api_client.dart';
|
import '../../../core/api/api_client.dart';
|
||||||
import '../../../core/api/api_endpoints.dart';
|
import '../../../core/api/api_endpoints.dart';
|
||||||
|
import '../../../core/auth/auth_guard.dart';
|
||||||
import '../../../core/storage/token_storage.dart';
|
import '../../../core/storage/token_storage.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ class AuthService {
|
|||||||
// candidate display name (server username or email fallback)
|
// candidate display name (server username or email fallback)
|
||||||
final displayCandidate = serverUsername ?? savedEmail;
|
final displayCandidate = serverUsername ?? savedEmail;
|
||||||
|
|
||||||
|
// clear guest mode on successful login
|
||||||
|
AuthGuard.setGuest(false);
|
||||||
|
|
||||||
// save token (TokenStorage stays responsible for token)
|
// save token (TokenStorage stays responsible for token)
|
||||||
await TokenStorage.saveToken(token.toString(), savedEmail);
|
await TokenStorage.saveToken(token.toString(), savedEmail);
|
||||||
|
|
||||||
@@ -90,6 +94,9 @@ class AuthService {
|
|||||||
final savedRole = (res['role'] ?? 'user').toString();
|
final savedRole = (res['role'] ?? 'user').toString();
|
||||||
final savedPhone = (res['phone_number'] ?? phoneNumber)?.toString();
|
final savedPhone = (res['phone_number'] ?? phoneNumber)?.toString();
|
||||||
|
|
||||||
|
// clear guest mode on successful registration
|
||||||
|
AuthGuard.setGuest(false);
|
||||||
|
|
||||||
// Save token + canonical user id for token storage
|
// Save token + canonical user id for token storage
|
||||||
await TokenStorage.saveToken(token.toString(), savedEmail);
|
await TokenStorage.saveToken(token.toString(), savedEmail);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class EventsService {
|
|||||||
|
|
||||||
/// Get event types (POST to /events/type-list/)
|
/// Get event types (POST to /events/type-list/)
|
||||||
Future<List<EventTypeModel>> getEventTypes() async {
|
Future<List<EventTypeModel>> getEventTypes() async {
|
||||||
final res = await _api.post(ApiEndpoints.eventTypes);
|
final res = await _api.post(ApiEndpoints.eventTypes, requiresAuth: false);
|
||||||
final list = <EventTypeModel>[];
|
final list = <EventTypeModel>[];
|
||||||
final data = res['event_types'] ?? res['event_types'] ?? res;
|
final data = res['event_types'] ?? res['event_types'] ?? res;
|
||||||
if (data is List) {
|
if (data is List) {
|
||||||
@@ -27,7 +27,7 @@ class EventsService {
|
|||||||
/// Get events filtered by pincode (POST to /events/pincode-events/)
|
/// Get events filtered by pincode (POST to /events/pincode-events/)
|
||||||
/// Use pincode='all' to fetch all events.
|
/// Use pincode='all' to fetch all events.
|
||||||
Future<List<EventModel>> getEventsByPincode(String pincode) async {
|
Future<List<EventModel>> getEventsByPincode(String pincode) async {
|
||||||
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode});
|
final res = await _api.post(ApiEndpoints.eventsByPincode, body: {'pincode': pincode}, requiresAuth: false);
|
||||||
final list = <EventModel>[];
|
final list = <EventModel>[];
|
||||||
final events = res['events'] ?? res['data'] ?? [];
|
final events = res['events'] ?? res['data'] ?? [];
|
||||||
if (events is List) {
|
if (events is List) {
|
||||||
@@ -40,7 +40,7 @@ class EventsService {
|
|||||||
|
|
||||||
/// Event details
|
/// Event details
|
||||||
Future<EventModel> getEventDetails(int eventId) async {
|
Future<EventModel> getEventDetails(int eventId) async {
|
||||||
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId});
|
final res = await _api.post(ApiEndpoints.eventDetails, body: {'event_id': eventId}, requiresAuth: false);
|
||||||
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class EventsService {
|
|||||||
/// Accepts month string and year int.
|
/// Accepts month string and year int.
|
||||||
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
|
/// Returns Map with 'dates' (list of YYYY-MM-DD) and 'date_events' (list with counts).
|
||||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
||||||
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year});
|
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
||||||
// expected keys: dates, total_number_of_events, date_events
|
// expected keys: dates, total_number_of_events, date_events
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
212
lib/features/gamification/models/gamification_models.dart
Normal file
212
lib/features/gamification/models/gamification_models.dart
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// lib/features/gamification/models/gamification_models.dart
|
||||||
|
// Data models matching TechDocs v2 DB schema for the Contributor Module.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tier enum — matches PRD v3 §4.4 thresholds (based on Lifetime EP)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
enum ContributorTier { BRONZE, SILVER, GOLD, PLATINUM, DIAMOND }
|
||||||
|
|
||||||
|
/// Returns the correct tier for a given lifetime EP total.
|
||||||
|
ContributorTier tierFromEp(int lifetimeEp) {
|
||||||
|
if (lifetimeEp >= 5000) return ContributorTier.DIAMOND;
|
||||||
|
if (lifetimeEp >= 1500) return ContributorTier.PLATINUM;
|
||||||
|
if (lifetimeEp >= 500) return ContributorTier.GOLD;
|
||||||
|
if (lifetimeEp >= 100) return ContributorTier.SILVER;
|
||||||
|
return ContributorTier.BRONZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for a tier.
|
||||||
|
String tierLabel(ContributorTier tier) {
|
||||||
|
switch (tier) {
|
||||||
|
case ContributorTier.BRONZE:
|
||||||
|
return 'Bronze';
|
||||||
|
case ContributorTier.SILVER:
|
||||||
|
return 'Silver';
|
||||||
|
case ContributorTier.GOLD:
|
||||||
|
return 'Gold';
|
||||||
|
case ContributorTier.PLATINUM:
|
||||||
|
return 'Platinum';
|
||||||
|
case ContributorTier.DIAMOND:
|
||||||
|
return 'Diamond';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EP threshold for next tier (used for progress bar). Returns null at max tier.
|
||||||
|
int? nextTierThreshold(ContributorTier tier) {
|
||||||
|
switch (tier) {
|
||||||
|
case ContributorTier.BRONZE:
|
||||||
|
return 100;
|
||||||
|
case ContributorTier.SILVER:
|
||||||
|
return 500;
|
||||||
|
case ContributorTier.GOLD:
|
||||||
|
return 1500;
|
||||||
|
case ContributorTier.PLATINUM:
|
||||||
|
return 5000;
|
||||||
|
case ContributorTier.DIAMOND:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lower EP bound for current tier (used for progress bar calculation).
|
||||||
|
int tierStartEp(ContributorTier tier) {
|
||||||
|
switch (tier) {
|
||||||
|
case ContributorTier.BRONZE:
|
||||||
|
return 0;
|
||||||
|
case ContributorTier.SILVER:
|
||||||
|
return 100;
|
||||||
|
case ContributorTier.GOLD:
|
||||||
|
return 500;
|
||||||
|
case ContributorTier.PLATINUM:
|
||||||
|
return 1500;
|
||||||
|
case ContributorTier.DIAMOND:
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UserGamificationProfile — mirrors the `UserGamificationProfile` DB table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class UserGamificationProfile {
|
||||||
|
final String userId;
|
||||||
|
final int lifetimeEp; // Never resets. Used for tiers + leaderboard.
|
||||||
|
final int currentEp; // Liquid EP accumulated this month.
|
||||||
|
final int currentRp; // Spendable Reward Points.
|
||||||
|
final ContributorTier tier;
|
||||||
|
|
||||||
|
const UserGamificationProfile({
|
||||||
|
required this.userId,
|
||||||
|
required this.lifetimeEp,
|
||||||
|
required this.currentEp,
|
||||||
|
required this.currentRp,
|
||||||
|
required this.tier,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserGamificationProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
final ep = (json['lifetime_ep'] as int?) ?? 0;
|
||||||
|
return UserGamificationProfile(
|
||||||
|
userId: json['user_id'] as String? ?? '',
|
||||||
|
lifetimeEp: ep,
|
||||||
|
currentEp: (json['current_ep'] as int?) ?? 0,
|
||||||
|
currentRp: (json['current_rp'] as int?) ?? 0,
|
||||||
|
tier: tierFromEp(ep),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// LeaderboardEntry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class LeaderboardEntry {
|
||||||
|
final int rank;
|
||||||
|
final String username;
|
||||||
|
final String? avatarUrl;
|
||||||
|
final int lifetimeEp;
|
||||||
|
final ContributorTier tier;
|
||||||
|
final int eventsCount;
|
||||||
|
final bool isCurrentUser;
|
||||||
|
|
||||||
|
const LeaderboardEntry({
|
||||||
|
required this.rank,
|
||||||
|
required this.username,
|
||||||
|
this.avatarUrl,
|
||||||
|
required this.lifetimeEp,
|
||||||
|
required this.tier,
|
||||||
|
required this.eventsCount,
|
||||||
|
this.isCurrentUser = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory LeaderboardEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
final ep = (json['lifetime_ep'] as int?) ?? 0;
|
||||||
|
return LeaderboardEntry(
|
||||||
|
rank: (json['rank'] as int?) ?? 0,
|
||||||
|
username: json['username'] as String? ?? '',
|
||||||
|
avatarUrl: json['avatar_url'] as String?,
|
||||||
|
lifetimeEp: ep,
|
||||||
|
tier: tierFromEp(ep),
|
||||||
|
eventsCount: (json['events_count'] as int?) ?? 0,
|
||||||
|
isCurrentUser: (json['is_current_user'] as bool?) ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ShopItem — mirrors `RedeemShopItem` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class ShopItem {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final int rpCost;
|
||||||
|
final int stockQuantity;
|
||||||
|
final String? imageUrl;
|
||||||
|
|
||||||
|
const ShopItem({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
required this.rpCost,
|
||||||
|
required this.stockQuantity,
|
||||||
|
this.imageUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ShopItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ShopItem(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
description: json['description'] as String? ?? '',
|
||||||
|
rpCost: (json['rp_cost'] as int?) ?? 0,
|
||||||
|
stockQuantity: (json['stock_quantity'] as int?) ?? 0,
|
||||||
|
imageUrl: json['image_url'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RedemptionRecord — mirrors `RedemptionHistory` table
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class RedemptionRecord {
|
||||||
|
final String id;
|
||||||
|
final String itemId;
|
||||||
|
final int rpSpent;
|
||||||
|
final String voucherCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
const RedemptionRecord({
|
||||||
|
required this.id,
|
||||||
|
required this.itemId,
|
||||||
|
required this.rpSpent,
|
||||||
|
required this.voucherCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RedemptionRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RedemptionRecord(
|
||||||
|
id: json['id'] as String? ?? '',
|
||||||
|
itemId: json['item_id'] as String? ?? '',
|
||||||
|
rpSpent: (json['rp_spent'] as int?) ?? 0,
|
||||||
|
voucherCode: json['voucher_code_issued'] as String? ?? '',
|
||||||
|
timestamp: DateTime.tryParse(json['timestamp'] as String? ?? '') ?? DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AchievementBadge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
class AchievementBadge {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String description;
|
||||||
|
final String iconName; // maps to an IconData key
|
||||||
|
final bool isUnlocked;
|
||||||
|
final double progress; // 0.0 – 1.0
|
||||||
|
|
||||||
|
const AchievementBadge({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.description,
|
||||||
|
required this.iconName,
|
||||||
|
required this.isUnlocked,
|
||||||
|
required this.progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
124
lib/features/gamification/providers/gamification_provider.dart
Normal file
124
lib/features/gamification/providers/gamification_provider.dart
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
// lib/features/gamification/providers/gamification_provider.dart
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/gamification_models.dart';
|
||||||
|
import '../services/gamification_service.dart';
|
||||||
|
|
||||||
|
class GamificationProvider extends ChangeNotifier {
|
||||||
|
final GamificationService _service = GamificationService();
|
||||||
|
|
||||||
|
// State
|
||||||
|
UserGamificationProfile? profile;
|
||||||
|
List<LeaderboardEntry> leaderboard = [];
|
||||||
|
List<ShopItem> shopItems = [];
|
||||||
|
List<AchievementBadge> achievements = [];
|
||||||
|
|
||||||
|
// Leaderboard filters — matches web version
|
||||||
|
String leaderboardDistrict = 'Overall Kerala';
|
||||||
|
String leaderboardTimePeriod = 'all_time'; // 'all_time' | 'this_month'
|
||||||
|
|
||||||
|
bool isLoading = false;
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Load everything at once (called when ContributeScreen is mounted)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> loadAll() async {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_service.getProfile(),
|
||||||
|
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod),
|
||||||
|
_service.getShopItems(),
|
||||||
|
_service.getAchievements(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
profile = results[0] as UserGamificationProfile;
|
||||||
|
leaderboard = results[1] as List<LeaderboardEntry>;
|
||||||
|
shopItems = results[2] as List<ShopItem>;
|
||||||
|
achievements = results[3] as List<AchievementBadge>;
|
||||||
|
} catch (e) {
|
||||||
|
error = e.toString();
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change district filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setDistrict(String district) async {
|
||||||
|
if (leaderboardDistrict == district) return;
|
||||||
|
leaderboardDistrict = district;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
leaderboard = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Change time period filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> setTimePeriod(String period) async {
|
||||||
|
if (leaderboardTimePeriod == period) return;
|
||||||
|
leaderboardTimePeriod = period;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
leaderboard = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
|
||||||
|
} catch (e) {
|
||||||
|
error = e.toString();
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redeem a shop item — deducts RP locally optimistically, returns voucher code
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<String> redeemItem(String itemId) async {
|
||||||
|
final item = shopItems.firstWhere((s) => s.id == itemId);
|
||||||
|
|
||||||
|
// Optimistically deduct RP
|
||||||
|
if (profile != null) {
|
||||||
|
profile = UserGamificationProfile(
|
||||||
|
userId: profile!.userId,
|
||||||
|
lifetimeEp: profile!.lifetimeEp,
|
||||||
|
currentEp: profile!.currentEp,
|
||||||
|
currentRp: profile!.currentRp - item.rpCost,
|
||||||
|
tier: profile!.tier,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final record = await _service.redeemItem(itemId);
|
||||||
|
return record.voucherCode;
|
||||||
|
} catch (e) {
|
||||||
|
// Rollback on failure
|
||||||
|
if (profile != null) {
|
||||||
|
profile = UserGamificationProfile(
|
||||||
|
userId: profile!.userId,
|
||||||
|
lifetimeEp: profile!.lifetimeEp,
|
||||||
|
currentEp: profile!.currentEp,
|
||||||
|
currentRp: profile!.currentRp + item.rpCost,
|
||||||
|
tier: profile!.tier,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Submit a contribution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
|
await _service.submitContribution(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
lib/features/gamification/services/gamification_service.dart
Normal file
180
lib/features/gamification/services/gamification_service.dart
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
// lib/features/gamification/services/gamification_service.dart
|
||||||
|
//
|
||||||
|
// Stub service using the real API contract from TechDocs v2.
|
||||||
|
// All methods currently return mock data.
|
||||||
|
// TODO: replace each mock block with a real ApiClient call once
|
||||||
|
// the backend endpoints are live on uat.eventifyplus.com.
|
||||||
|
|
||||||
|
import 'dart:math';
|
||||||
|
import '../models/gamification_models.dart';
|
||||||
|
|
||||||
|
class GamificationService {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// User Gamification Profile
|
||||||
|
// TODO: replace with ApiClient.get(ApiEndpoints.gamificationProfile)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<UserGamificationProfile> getProfile() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
return const UserGamificationProfile(
|
||||||
|
userId: 'mock-user-001',
|
||||||
|
lifetimeEp: 320,
|
||||||
|
currentEp: 70,
|
||||||
|
currentRp: 45,
|
||||||
|
tier: ContributorTier.SILVER,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Leaderboard
|
||||||
|
// district: 'Overall Kerala' | 'Thiruvananthapuram' | 'Kollam' | ...
|
||||||
|
// timePeriod: 'all_time' | 'this_month'
|
||||||
|
// TODO: replace with ApiClient.get(ApiEndpoints.leaderboard, params: {'district': district, 'period': timePeriod})
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<LeaderboardEntry>> getLeaderboard({
|
||||||
|
required String district,
|
||||||
|
required String timePeriod,
|
||||||
|
}) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// Realistic mock names per district
|
||||||
|
final names = [
|
||||||
|
'Annette Black', 'Jerome Bell', 'Theresa Webb', 'Courtney Henry',
|
||||||
|
'Cameron Williamson', 'Dianne Russell', 'Wade Warren', 'Albert Flores',
|
||||||
|
'Kristin Watson', 'Guy Hawkins',
|
||||||
|
];
|
||||||
|
|
||||||
|
final rng = Random(district.hashCode ^ timePeriod.hashCode);
|
||||||
|
final baseEp = timePeriod == 'this_month' ? 800 : 4500;
|
||||||
|
|
||||||
|
final entries = List.generate(10, (i) {
|
||||||
|
final ep = baseEp - (i * (timePeriod == 'this_month' ? 55 : 280)) + rng.nextInt(30);
|
||||||
|
return LeaderboardEntry(
|
||||||
|
rank: i + 1,
|
||||||
|
username: names[i],
|
||||||
|
lifetimeEp: ep,
|
||||||
|
tier: tierFromEp(ep),
|
||||||
|
eventsCount: 149 - i * 12,
|
||||||
|
isCurrentUser: i == 7, // mock: current user is rank 8
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redeem Shop Items
|
||||||
|
// TODO: replace with ApiClient.get(ApiEndpoints.shopItems)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<ShopItem>> getShopItems() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
return const [
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-001',
|
||||||
|
name: 'Amazon ₹500 Voucher',
|
||||||
|
description: 'Redeem for any purchase on Amazon India.',
|
||||||
|
rpCost: 50,
|
||||||
|
stockQuantity: 20,
|
||||||
|
),
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-002',
|
||||||
|
name: 'Swiggy ₹200 Voucher',
|
||||||
|
description: 'Free food delivery credit on Swiggy.',
|
||||||
|
rpCost: 20,
|
||||||
|
stockQuantity: 35,
|
||||||
|
),
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-003',
|
||||||
|
name: 'Eventify Pro — 1 Month',
|
||||||
|
description: 'Premium access to Eventify.Plus features.',
|
||||||
|
rpCost: 30,
|
||||||
|
stockQuantity: 100,
|
||||||
|
),
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-004',
|
||||||
|
name: 'Zomato ₹150 Voucher',
|
||||||
|
description: 'Discount on your next Zomato order.',
|
||||||
|
rpCost: 15,
|
||||||
|
stockQuantity: 50,
|
||||||
|
),
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-005',
|
||||||
|
name: 'BookMyShow ₹300 Voucher',
|
||||||
|
description: 'Movie & event ticket credit on BookMyShow.',
|
||||||
|
rpCost: 30,
|
||||||
|
stockQuantity: 15,
|
||||||
|
),
|
||||||
|
ShopItem(
|
||||||
|
id: 'item-006',
|
||||||
|
name: 'Exclusive Badge',
|
||||||
|
description: 'Rare "Pioneer" badge for your profile.',
|
||||||
|
rpCost: 5,
|
||||||
|
stockQuantity: 0, // out of stock
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Redeem an item
|
||||||
|
// TODO: replace with ApiClient.post(ApiEndpoints.shopRedeem, body: {'item_id': itemId})
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<RedemptionRecord> redeemItem(String itemId) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 600));
|
||||||
|
// Generate a fake voucher code
|
||||||
|
final code = 'EVT-${itemId.toUpperCase().replaceAll('-', '').substring(0, 4)}-${Random().nextInt(9000) + 1000}';
|
||||||
|
return RedemptionRecord(
|
||||||
|
id: 'redemption-${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
itemId: itemId,
|
||||||
|
rpSpent: 0, // provider will look up cost
|
||||||
|
voucherCode: code,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Submit Contribution
|
||||||
|
// TODO: replace with ApiClient.post(ApiEndpoints.contributeSubmit, body: data)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<void> submitContribution(Map<String, dynamic> data) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
|
// Mock always succeeds
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Achievements
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
Future<List<AchievementBadge>> getAchievements() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
return const [
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-01', title: 'First Submission',
|
||||||
|
description: 'Submitted your first event.',
|
||||||
|
iconName: 'edit', isUnlocked: true, progress: 1.0,
|
||||||
|
),
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-02', title: 'Silver Streak',
|
||||||
|
description: 'Reached Silver tier.',
|
||||||
|
iconName: 'star', isUnlocked: true, progress: 1.0,
|
||||||
|
),
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-03', title: 'Gold Rush',
|
||||||
|
description: 'Reach Gold tier (500 EP).',
|
||||||
|
iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
|
||||||
|
),
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-04', title: 'Top 10',
|
||||||
|
description: 'Appear in the district leaderboard top 10.',
|
||||||
|
iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
|
||||||
|
),
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-05', title: 'Image Pro',
|
||||||
|
description: 'Submit 10 events with 3+ images.',
|
||||||
|
iconName: 'photo_library', isUnlocked: false, progress: 0.3,
|
||||||
|
),
|
||||||
|
AchievementBadge(
|
||||||
|
id: 'badge-06', title: 'Pioneer',
|
||||||
|
description: 'One of the first 100 contributors.',
|
||||||
|
iconName: 'verified', isUnlocked: true, progress: 1.0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ import 'core/theme_manager.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await ThemeManager.init(); // load saved theme preference
|
await ThemeManager.init(); // load saved theme preference
|
||||||
|
|
||||||
|
// Increase image cache for smoother scrolling and faster re-renders
|
||||||
|
PaintingBinding.instance.imageCache.maximumSize = 500;
|
||||||
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 200 * 1024 * 1024; // 200 MB
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// lib/screens/calendar_screen.dart
|
// lib/screens/calendar_screen.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../features/events/services/events_service.dart';
|
import '../features/events/services/events_service.dart';
|
||||||
import '../features/events/models/event_models.dart';
|
import '../features/events/models/event_models.dart';
|
||||||
import 'learn_more_screen.dart';
|
import 'learn_more_screen.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
|
// landscape_section_header no longer needed for this screen
|
||||||
|
|
||||||
class CalendarScreen extends StatefulWidget {
|
class CalendarScreen extends StatefulWidget {
|
||||||
const CalendarScreen({Key? key}) : super(key: key);
|
const CalendarScreen({Key? key}) : super(key: key);
|
||||||
@@ -511,7 +513,18 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
|
||||||
child: imgUrl != null ? Image.network(imgUrl, height: 150, width: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(height: 150, color: theme.dividerColor)) : Container(height: 150, color: theme.dividerColor, child: Icon(Icons.event, size: 44, color: theme.hintColor)),
|
child: imgUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imgUrl,
|
||||||
|
memCacheWidth: 400,
|
||||||
|
memCacheHeight: 300,
|
||||||
|
height: 150,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(height: 150, color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(height: 150, color: theme.dividerColor),
|
||||||
|
)
|
||||||
|
: Container(height: 150, color: theme.dividerColor, child: Icon(Icons.event, size: 44, color: theme.hintColor)),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
|
||||||
@@ -537,50 +550,266 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// ── Landscape: event card for the right panel ───────────────────────────
|
||||||
Widget build(BuildContext context) {
|
Widget _eventCardLandscape(EventModel e) {
|
||||||
final width = MediaQuery.of(context).size.width;
|
|
||||||
final isMobile = width < 700;
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final imgUrl = (e.thumbImg != null && e.thumbImg!.isNotEmpty)
|
||||||
|
? e.thumbImg!
|
||||||
|
: (e.images.isNotEmpty ? e.images.first.image : null);
|
||||||
|
final dateLabel = (e.startDate != null && e.endDate != null && e.startDate == e.endDate)
|
||||||
|
? '${e.startDate}'
|
||||||
|
: (e.startDate != null && e.endDate != null
|
||||||
|
? '${e.startDate} – ${e.endDate}'
|
||||||
|
: (e.startDate ?? ''));
|
||||||
|
|
||||||
// For non-mobile, keep original split layout
|
return GestureDetector(
|
||||||
if (!isMobile) {
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: e.id))),
|
||||||
return Scaffold(
|
child: Container(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||||
body: SafeArea(
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 10, offset: const Offset(0, 3))],
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: Padding(padding: const EdgeInsets.all(24.0), child: _calendarCard(context))),
|
// Image
|
||||||
Expanded(flex: 1, child: _detailsPanel()),
|
ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.horizontal(left: Radius.circular(14)),
|
||||||
|
child: imgUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: imgUrl,
|
||||||
|
memCacheWidth: 300,
|
||||||
|
memCacheHeight: 300,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(width: 100, height: 100, color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
e.title ?? e.name ?? '',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Date row with blue dot
|
||||||
|
Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(dateLabel, style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Venue row with green dot
|
||||||
|
Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF22C55E),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(e.place ?? '', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MOBILE layout
|
// ── Landscape: left panel content (calendar on white bg) ─────────────────
|
||||||
// Stack: extended gradient panel (below appbar) that visually extends behind the calendar.
|
Widget _landscapeLeftPanel(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Title
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Text(
|
||||||
|
"Event's Calendar",
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Calendar card — reuses the mobile _calendarCard widget
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_calendarCard(context),
|
||||||
|
if (_loadingMonth)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
backgroundColor: theme.colorScheme.primary.withOpacity(0.12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Landscape: right panel (event list for selected day) ────────────────
|
||||||
|
Widget _landscapeRightPanel(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final dayName = DateFormat('EEEE').format(selectedDate);
|
||||||
|
final dateFormatted = DateFormat('d MMMM, yyyy').format(selectedDate);
|
||||||
|
final count = _eventsOfDay.length;
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Date header matching Figma: "Monday, 16 June, 2025 — 2 Events"
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$dayName, $dateFormatted',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$count ${count == 1 ? "Event" : "Events"}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.hintColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Divider
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Divider(height: 1, color: theme.dividerColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Scrollable event list
|
||||||
|
Expanded(
|
||||||
|
child: _loadingDay
|
||||||
|
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
|
||||||
|
: _eventsOfDay.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.event_available, size: 56, color: theme.hintColor),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No events on this date',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.only(top: 4, bottom: 32),
|
||||||
|
itemCount: _eventsOfDay.length,
|
||||||
|
itemBuilder: (ctx, i) => _eventCardLandscape(_eventsOfDay[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
final isLandscape = width >= 820;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
// ── LANDSCAPE layout ──────────────────────────────────────────────────
|
||||||
|
if (isLandscape) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
// Left: Calendar panel with WHITE background (~60%)
|
||||||
|
Flexible(
|
||||||
|
flex: 3,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
color: theme.cardColor,
|
||||||
|
child: _landscapeLeftPanel(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Vertical divider between panels
|
||||||
|
VerticalDivider(width: 1, thickness: 1, color: theme.dividerColor),
|
||||||
|
// Right: Events panel (~40%)
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: _landscapeRightPanel(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||||
|
// (unchanged from original)
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Extended blue gradient panel behind calendar (matches reference)
|
// TOP APP BAR stays fixed (title + bell icon)
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Container(
|
|
||||||
height: 260, // controls how much gradient shows behind calendar
|
|
||||||
decoration: AppDecoration.blueGradient.copyWith(
|
|
||||||
borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(30), bottomRight: Radius.circular(30)),
|
|
||||||
boxShadow: [const BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
|
|
||||||
),
|
|
||||||
// leave child empty — title and bell are placed above
|
|
||||||
child: const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// TOP APP BAR (title centered + notification at top-right) - unchanged placement
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -627,32 +856,73 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// CONTENT: calendar card overlapped on gradient, then summary and list
|
// CONTENT: gradient + calendar card scroll together as one unit
|
||||||
Column(
|
CustomScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
// Gradient + calendar card in one scrollable Stack
|
||||||
|
// Gradient scrolls away with content; app bar remains fixed above
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 110), // leave space for appbar + some gradient top
|
// Blue gradient banner — scrolls with content
|
||||||
_calendarCard(context), // calendar card sits visually on top of the gradient
|
Container(
|
||||||
_selectedDateSummary(context),
|
height: 260,
|
||||||
Expanded(
|
decoration: AppDecoration.blueGradient.copyWith(
|
||||||
child: _loadingDay
|
borderRadius: const BorderRadius.only(
|
||||||
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
|
bottomLeft: Radius.circular(30),
|
||||||
: _eventsOfDay.isEmpty
|
bottomRight: Radius.circular(30),
|
||||||
? Center(
|
),
|
||||||
|
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 12, offset: Offset(0, 6))],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Calendar card starts at y=110 (after app bar), overlapping gradient
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 110),
|
||||||
|
child: _calendarCard(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Selected date summary
|
||||||
|
SliverToBoxAdapter(child: _selectedDateSummary(context)),
|
||||||
|
|
||||||
|
// Events area — loading / empty / list
|
||||||
|
if (_loadingDay)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: theme.colorScheme.primary),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_eventsOfDay.isEmpty)
|
||||||
|
SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.event_available, size: 48, color: theme.hintColor),
|
Icon(Icons.event_available, size: 48, color: theme.hintColor),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text('No events scheduled for this date', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
|
Text(
|
||||||
|
'No events scheduled for this date',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
else
|
||||||
padding: const EdgeInsets.only(top: 6, bottom: 32),
|
SliverList(
|
||||||
itemCount: _eventsOfDay.length,
|
delegate: SliverChildBuilderDelegate(
|
||||||
itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx]),
|
(context, idx) => _eventCardMobile(_eventsOfDay[idx]),
|
||||||
|
childCount: _eventsOfDay.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Bottom padding
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -660,44 +930,4 @@ class _CalendarScreenState extends State<CalendarScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _detailsPanel() {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final shortDate = DateFormat('EEE, d MMM').format(selectedDate);
|
|
||||||
final eventsCount = _eventsOfDay.length;
|
|
||||||
|
|
||||||
Widget _buildHeaderCompact() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 12.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: AppDecoration.blueGradientRounded(10),
|
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Text('${selectedDate.day}', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Text(monthNames[selectedDate.month].substring(0, 3), style: const TextStyle(color: Colors.white70, fontSize: 10))]),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(shortDate, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 4), Text('$eventsCount ${eventsCount == 1 ? "Event" : "Events"}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).hintColor))]),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(onPressed: () {}, icon: Icon(Icons.more_horiz, color: Theme.of(context).iconTheme.color)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
color: Theme.of(context).scaffoldBackgroundColor,
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
_buildHeaderCompact(),
|
|
||||||
Divider(height: 1, color: theme.dividerColor),
|
|
||||||
Expanded(
|
|
||||||
child: _loadingDay
|
|
||||||
? Center(child: CircularProgressIndicator(color: theme.colorScheme.primary))
|
|
||||||
: _eventsOfDay.isEmpty
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: ListView.builder(padding: const EdgeInsets.only(top: 6, bottom: 24), itemCount: _eventsOfDay.length, itemBuilder: (context, idx) => _eventCardMobile(_eventsOfDay[idx])),
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../features/auth/services/auth_service.dart';
|
import '../features/auth/services/auth_service.dart';
|
||||||
|
import '../core/auth/auth_guard.dart';
|
||||||
import 'home_desktop_screen.dart';
|
import 'home_desktop_screen.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
|
|
||||||
@@ -241,7 +242,17 @@ class _DesktopLoginScreenState extends State<DesktopLoginScreen> with SingleTick
|
|||||||
alignment: WrapAlignment.spaceBetween,
|
alignment: WrapAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
|
TextButton(onPressed: _openRegister, child: const Text("Don't have an account? Register")),
|
||||||
TextButton(onPressed: () {}, child: const Text('Contact support'))
|
TextButton(onPressed: () {}, child: const Text('Contact support')),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
AuthGuard.setGuest(true);
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const HomeDesktopScreen(skipSidebarEntranceAnimation: true)),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Continue as Guest'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@ import 'dart:async';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
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 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../features/events/models/event_models.dart';
|
import '../features/events/models/event_models.dart';
|
||||||
import '../features/events/services/events_service.dart';
|
import '../features/events/services/events_service.dart';
|
||||||
@@ -12,7 +15,10 @@ import 'contribute_screen.dart';
|
|||||||
import 'learn_more_screen.dart';
|
import 'learn_more_screen.dart';
|
||||||
import 'search_screen.dart';
|
import 'search_screen.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
|
import 'package:geocoding/geocoding.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../features/gamification/providers/gamification_provider.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
const HomeScreen({Key? key}) : super(key: key);
|
const HomeScreen({Key? key}) : super(key: key);
|
||||||
@@ -37,13 +43,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
// Hero carousel
|
// Hero carousel
|
||||||
final PageController _heroPageController = PageController();
|
final PageController _heroPageController = PageController(viewportFraction: 0.88);
|
||||||
int _heroCurrentPage = 0;
|
late final ValueNotifier<int> _heroPageNotifier;
|
||||||
Timer? _autoScrollTimer;
|
Timer? _autoScrollTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_heroPageNotifier = ValueNotifier(0);
|
||||||
_loadUserDataAndEvents();
|
_loadUserDataAndEvents();
|
||||||
_startAutoScroll();
|
_startAutoScroll();
|
||||||
}
|
}
|
||||||
@@ -52,18 +59,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_heroPageController.dispose();
|
_heroPageController.dispose();
|
||||||
|
_heroPageNotifier.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startAutoScroll() {
|
void _startAutoScroll({Duration delay = const Duration(seconds: 5)}) {
|
||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (timer) {
|
_autoScrollTimer = Timer.periodic(delay, (timer) {
|
||||||
if (_heroEvents.isEmpty) return;
|
if (_heroEvents.isEmpty) return;
|
||||||
final nextPage = (_heroCurrentPage + 1) % _heroEvents.length;
|
final nextPage = (_heroPageNotifier.value + 1) % _heroEvents.length;
|
||||||
if (_heroPageController.hasClients) {
|
if (_heroPageController.hasClients) {
|
||||||
_heroPageController.animateToPage(
|
_heroPageController.animateToPage(
|
||||||
nextPage,
|
nextPage,
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 400),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,17 +82,37 @@ 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') ?? '';
|
||||||
_location = prefs.getString('location') ?? 'Whitefield, Bengaluru';
|
final storedLocation = prefs.getString('location') ?? 'Whitefield, Bengaluru';
|
||||||
|
// Fix legacy lat,lng strings saved before the reverse-geocoding fix
|
||||||
|
final coordMatch = RegExp(r'^(-?\d+\.?\d*),\s*(-?\d+\.?\d*)$').firstMatch(storedLocation);
|
||||||
|
if (coordMatch != null) {
|
||||||
|
_location = 'Current Location';
|
||||||
|
setState(() {});
|
||||||
|
// Reverse geocode in background to get actual place name
|
||||||
|
_reverseGeocodeAndSave(
|
||||||
|
double.parse(coordMatch.group(1)!),
|
||||||
|
double.parse(coordMatch.group(2)!),
|
||||||
|
prefs,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_location = storedLocation;
|
||||||
|
}
|
||||||
_pincode = prefs.getString('pincode') ?? 'all';
|
_pincode = prefs.getString('pincode') ?? 'all';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final types = await _events_service_getEventTypesSafe();
|
// Fetch types and events in parallel for faster loading
|
||||||
final events = await _events_service_getEventsSafe(_pincode);
|
final results = await Future.wait([
|
||||||
|
_events_service_getEventTypesSafe(),
|
||||||
|
_events_service_getEventsSafe(_pincode),
|
||||||
|
]);
|
||||||
|
final types = results[0] as List<EventTypeModel>;
|
||||||
|
final events = results[1] as List<EventModel>;
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_types = types;
|
_types = types;
|
||||||
_events = events;
|
_events = events;
|
||||||
_selectedTypeId = -1;
|
_selectedTypeId = -1;
|
||||||
|
_cachedFilteredEvents = null; // invalidate cache
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,6 +124,24 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _reverseGeocodeAndSave(double lat, double lng, SharedPreferences prefs) async {
|
||||||
|
try {
|
||||||
|
final placemarks = await placemarkFromCoordinates(lat, lng);
|
||||||
|
if (placemarks.isNotEmpty) {
|
||||||
|
final p = placemarks.first;
|
||||||
|
final parts = <String>[];
|
||||||
|
if ((p.subLocality ?? '').isNotEmpty) parts.add(p.subLocality!);
|
||||||
|
if ((p.locality ?? '').isNotEmpty) parts.add(p.locality!);
|
||||||
|
if ((p.subAdministrativeArea ?? '').isNotEmpty) parts.add(p.subAdministrativeArea!);
|
||||||
|
final label = parts.isNotEmpty ? parts.join(', ') : 'Current Location';
|
||||||
|
await prefs.setString('location', label);
|
||||||
|
if (mounted) setState(() => _location = label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
await prefs.setString('location', 'Current Location');
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<EventTypeModel>> _events_service_getEventTypesSafe() async {
|
Future<List<EventTypeModel>> _events_service_getEventTypesSafe() async {
|
||||||
try {
|
try {
|
||||||
return await _eventsService.getEventTypes();
|
return await _eventsService.getEventTypes();
|
||||||
@@ -157,10 +203,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
child: imageUrl != null && imageUrl.isNotEmpty
|
child: imageUrl != null && imageUrl.isNotEmpty
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Image.network(
|
child: CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
memCacheWidth: 112,
|
||||||
|
memCacheHeight: 112,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
placeholder: (_, __) => Icon(
|
||||||
|
icon ?? Icons.category,
|
||||||
|
size: 36,
|
||||||
|
color: selected ? Colors.white.withOpacity(0.5) : theme.colorScheme.primary.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
icon ?? Icons.category,
|
icon ?? Icons.category,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: selected ? Colors.white : theme.colorScheme.primary,
|
color: selected ? Colors.white : theme.colorScheme.primary,
|
||||||
@@ -310,9 +363,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
|
child: Center(child: Text(query.isEmpty ? 'Type to search events' : 'No events found', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor))),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ListView.separated(
|
ConstrainedBox(
|
||||||
shrinkWrap: true,
|
constraints: const BoxConstraints(maxHeight: 400),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: ListView.separated(
|
||||||
|
shrinkWrap: false,
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
final ev = results[idx];
|
final ev = results[idx];
|
||||||
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
|
final img = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty) ? ev.thumbImg! : (ev.images.isNotEmpty ? ev.images.first.image : null);
|
||||||
@@ -321,7 +376,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
||||||
leading: img != null && img.isNotEmpty
|
leading: img != null && img.isNotEmpty
|
||||||
? ClipRRect(borderRadius: BorderRadius.circular(8), child: Image.network(img, width: 56, height: 56, fit: BoxFit.cover))
|
? ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
memCacheWidth: 112,
|
||||||
|
memCacheHeight: 112,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(width: 56, height: 56, color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(width: 56, height: 56, color: theme.dividerColor, child: Icon(Icons.event, color: theme.hintColor)),
|
||||||
|
),
|
||||||
|
)
|
||||||
: Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)),
|
: Container(width: 56, height: 56, decoration: BoxDecoration(color: theme.dividerColor, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.event, color: theme.hintColor)),
|
||||||
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge),
|
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge),
|
||||||
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
|
subtitle: Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
|
||||||
@@ -335,7 +402,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
},
|
},
|
||||||
separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
|
separatorBuilder: (_, __) => Divider(color: theme.dividerColor),
|
||||||
itemCount: results.length,
|
itemCount: results.length,
|
||||||
),
|
)), // ConstrainedBox
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -357,13 +424,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// IndexedStack keeps each tab alive and preserves state.
|
// IndexedStack keeps each tab alive and preserves state.
|
||||||
|
// RepaintBoundary isolates each tab so inactive tabs don't trigger repaints.
|
||||||
IndexedStack(
|
IndexedStack(
|
||||||
index: _selectedIndex,
|
index: _selectedIndex,
|
||||||
children: [
|
children: [
|
||||||
_buildHomeContent(), // index 0
|
RepaintBoundary(child: _buildHomeContent()), // index 0
|
||||||
const CalendarScreen(), // index 1
|
const RepaintBoundary(child: CalendarScreen()), // index 1
|
||||||
const ContributeScreen(), // index 2 (full page, scrollable)
|
RepaintBoundary(
|
||||||
const ProfileScreen(), // index 3
|
child: ChangeNotifierProvider(
|
||||||
|
create: (_) => GamificationProvider(),
|
||||||
|
child: const ContributeScreen(),
|
||||||
|
),
|
||||||
|
), // index 2 (full page, scrollable)
|
||||||
|
const RepaintBoundary(child: ProfileScreen()), // index 3
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -410,7 +483,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
final active = _selectedIndex == index;
|
final active = _selectedIndex == index;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => setState(() => _selectedIndex = index),
|
onTap: () {
|
||||||
|
if (index == 2 && !AuthGuard.requireLogin(context, reason: 'Sign in to contribute events and earn rewards.')) return;
|
||||||
|
if (index == 3 && !AuthGuard.requireLogin(context, reason: 'Sign in to view your profile.')) return;
|
||||||
|
setState(() => _selectedIndex = index);
|
||||||
|
},
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
@@ -439,17 +516,46 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
|
|
||||||
// Get hero events (first 4 events for the carousel)
|
// Get hero events (first 4 events for the carousel)
|
||||||
List<EventModel> get _heroEvents => _events.take(4).toList();
|
List<EventModel> get _heroEvents => _events.take(6).toList();
|
||||||
|
|
||||||
|
String _formatDate(String dateStr) {
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(dateStr);
|
||||||
|
return DateFormat('d MMM yyyy').format(dt);
|
||||||
|
} catch (_) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getEventTypeName(EventModel event) {
|
||||||
|
if (event.eventTypeId != null && event.eventTypeId! > 0) {
|
||||||
|
final match = _types.where((t) => t.id == event.eventTypeId);
|
||||||
|
if (match.isNotEmpty && match.first.name.isNotEmpty) {
|
||||||
|
return match.first.name.toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'EVENT';
|
||||||
|
}
|
||||||
|
|
||||||
// Date filter state
|
// Date filter state
|
||||||
String _selectedDateFilter = '';
|
String _selectedDateFilter = '';
|
||||||
DateTime? _selectedCustomDate;
|
DateTime? _selectedCustomDate;
|
||||||
|
|
||||||
|
// Cached filtered events to avoid repeated DateTime.parse() calls
|
||||||
|
List<EventModel>? _cachedFilteredEvents;
|
||||||
|
String _cachedFilterKey = '';
|
||||||
|
|
||||||
/// Returns the subset of [_events] that match the active date-filter chip.
|
/// Returns the subset of [_events] that match the active date-filter chip.
|
||||||
/// If no chip is selected the full list is returned.
|
/// Uses caching to avoid re-parsing dates on every access.
|
||||||
List<EventModel> get _filteredEvents {
|
List<EventModel> get _filteredEvents {
|
||||||
if (_selectedDateFilter.isEmpty) return _events;
|
if (_selectedDateFilter.isEmpty) return _events;
|
||||||
|
|
||||||
|
// Build a cache key from filter state
|
||||||
|
final cacheKey = '$_selectedDateFilter|$_selectedCustomDate|${_events.length}';
|
||||||
|
if (_cachedFilteredEvents != null && _cachedFilterKey == cacheKey) {
|
||||||
|
return _cachedFilteredEvents!;
|
||||||
|
}
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
|
||||||
@@ -481,7 +587,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
return _events;
|
return _events;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _events.where((e) {
|
_cachedFilteredEvents = _events.where((e) {
|
||||||
try {
|
try {
|
||||||
final eStart = DateTime.parse(e.startDate);
|
final eStart = DateTime.parse(e.startDate);
|
||||||
final eEnd = DateTime.parse(e.endDate);
|
final eEnd = DateTime.parse(e.endDate);
|
||||||
@@ -491,6 +597,8 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}).toList();
|
}).toList();
|
||||||
|
_cachedFilterKey = cacheKey;
|
||||||
|
return _cachedFilteredEvents!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onDateChipTap(String label) async {
|
Future<void> _onDateChipTap(String label) async {
|
||||||
@@ -501,6 +609,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedCustomDate = picked;
|
_selectedCustomDate = picked;
|
||||||
_selectedDateFilter = 'Date';
|
_selectedDateFilter = 'Date';
|
||||||
|
_cachedFilteredEvents = null; // invalidate cache
|
||||||
});
|
});
|
||||||
_showFilteredEventsSheet(
|
_showFilteredEventsSheet(
|
||||||
'${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}',
|
'${picked.day.toString().padLeft(2, '0')} ${_monthName(picked.month)} ${picked.year}',
|
||||||
@@ -509,12 +618,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedDateFilter = '';
|
_selectedDateFilter = '';
|
||||||
_selectedCustomDate = null;
|
_selectedCustomDate = null;
|
||||||
|
_cachedFilteredEvents = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedDateFilter = label;
|
_selectedDateFilter = label;
|
||||||
_selectedCustomDate = null;
|
_selectedCustomDate = null;
|
||||||
|
_cachedFilteredEvents = null; // invalidate cache
|
||||||
});
|
});
|
||||||
_showFilteredEventsSheet(label);
|
_showFilteredEventsSheet(label);
|
||||||
}
|
}
|
||||||
@@ -663,12 +774,19 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) {
|
if (imageUrl != null && imageUrl.isNotEmpty && imageUrl.startsWith('http')) {
|
||||||
imageWidget = ClipRRect(
|
imageWidget = ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Image.network(
|
child: CachedNetworkImage(
|
||||||
imageUrl,
|
imageUrl: imageUrl,
|
||||||
|
memCacheWidth: 160,
|
||||||
|
memCacheHeight: 160,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
placeholder: (_, __) => Container(
|
||||||
|
width: 80, height: 80,
|
||||||
|
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
width: 80, height: 80,
|
width: 80, height: 80,
|
||||||
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12)),
|
||||||
child: Icon(Icons.image, color: Colors.grey.shade400),
|
child: Icon(Icons.image, color: Colors.grey.shade400),
|
||||||
@@ -1066,23 +1184,51 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
// Featured carousel
|
// Featured carousel
|
||||||
_heroEvents.isEmpty
|
_heroEvents.isEmpty
|
||||||
? SizedBox(
|
? _loading
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 320,
|
||||||
|
child: _HeroShimmer(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox(
|
||||||
height: 280,
|
height: 280,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _loading
|
child: Text('No events available',
|
||||||
? const CircularProgressIndicator(color: Colors.white)
|
style: TextStyle(color: Colors.white70)),
|
||||||
: const Text('No events available', style: TextStyle(color: Colors.white70)),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Column(
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
RepaintBoundary(
|
||||||
height: 300,
|
child: SizedBox(
|
||||||
|
height: 320,
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _heroPageController,
|
controller: _heroPageController,
|
||||||
onPageChanged: (page) => setState(() => _heroCurrentPage = page),
|
onPageChanged: (page) {
|
||||||
|
_heroPageNotifier.value = page;
|
||||||
|
// 8s delay after manual swipe for full read time
|
||||||
|
_startAutoScroll(delay: const Duration(seconds: 8));
|
||||||
|
},
|
||||||
itemCount: _heroEvents.length,
|
itemCount: _heroEvents.length,
|
||||||
itemBuilder: (context, index) => _buildHeroEventImage(_heroEvents[index]),
|
itemBuilder: (context, index) {
|
||||||
|
// Scale animation: active card = 1.0, adjacent = 0.94
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _heroPageController,
|
||||||
|
builder: (context, child) {
|
||||||
|
double scale = index == _heroPageNotifier.value ? 1.0 : 0.94;
|
||||||
|
if (_heroPageController.position.haveDimensions) {
|
||||||
|
scale = (1.0 -
|
||||||
|
(_heroPageController.page! - index).abs() * 0.06)
|
||||||
|
.clamp(0.94, 1.0);
|
||||||
|
}
|
||||||
|
return Transform.scale(scale: scale, child: child);
|
||||||
|
},
|
||||||
|
child: _buildHeroEventImage(_heroEvents[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -1097,14 +1243,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCarouselDots() {
|
Widget _buildCarouselDots() {
|
||||||
|
return ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _heroPageNotifier,
|
||||||
|
builder: (context, currentPage, _) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 12,
|
height: 44,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
_heroEvents.isEmpty ? 5 : _heroEvents.length,
|
_heroEvents.isEmpty ? 5 : _heroEvents.length,
|
||||||
(i) {
|
(i) {
|
||||||
final isActive = i == _heroCurrentPage;
|
final isActive = i == currentPage;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_heroPageController.hasClients) {
|
if (_heroPageController.hasClients) {
|
||||||
@@ -1112,20 +1261,30 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: SizedBox(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
width: isActive ? 24 : 8,
|
width: isActive ? 24 : 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive ? Colors.white : Colors.white.withOpacity(0.4),
|
color: isActive
|
||||||
|
? Colors.white
|
||||||
|
: Colors.white.withValues(alpha: 0.4),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a hero image card with the image only (rounded),
|
/// Build a hero image card with the image only (rounded),
|
||||||
@@ -1138,58 +1297,150 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
img = event.images.first.image;
|
img = event.images.first.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
final radius = 24.0;
|
const double radius = 24.0;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (event.id != null) {
|
if (event.id != null) {
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
|
Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: event.id)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Image only (no text overlay)
|
|
||||||
Expanded(
|
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(radius),
|
borderRadius: BorderRadius.circular(radius),
|
||||||
child: SizedBox(
|
child: Stack(
|
||||||
width: double.infinity,
|
fit: StackFit.expand,
|
||||||
child: img != null && img.isNotEmpty
|
children: [
|
||||||
? Image.network(
|
// ── Layer 0: Event image (full-bleed) ──
|
||||||
img,
|
img != null && img.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
memCacheWidth: 700,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (context, error, stackTrace) =>
|
placeholder: (_, __) => const _HeroShimmer(radius: radius),
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
||||||
)
|
)
|
||||||
: Container(
|
: Container(decoration: AppDecoration.blueGradientRounded(radius)),
|
||||||
decoration: AppDecoration.blueGradientRounded(radius),
|
|
||||||
|
// ── Layer 1: Bottom gradient overlay (text readability) ──
|
||||||
|
Positioned.fill(
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
stops: const [0.35, 1.0],
|
||||||
|
colors: [
|
||||||
|
Colors.transparent,
|
||||||
|
Colors.black.withOpacity(0.78),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Title text outside the image
|
// ── Layer 2: Event type glassmorphism badge (top-left) ──
|
||||||
const SizedBox(height: 12),
|
Positioned(
|
||||||
|
top: 14,
|
||||||
|
left: 14,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.18),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.28)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.star_rounded, color: Colors.amber, size: 13),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_getEventTypeName(event),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Layer 3: Title + metadata (bottom overlay) ──
|
||||||
|
Positioned(
|
||||||
|
bottom: 18,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
event.title ?? event.name ?? '',
|
event.title ?? event.name ?? '',
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.w800,
|
||||||
height: 1.2,
|
height: 1.25,
|
||||||
shadows: [
|
shadows: [
|
||||||
Shadow(color: Colors.black38, blurRadius: 6, offset: Offset(0, 2)),
|
Shadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (event.startDate != null) ...[
|
||||||
|
const Icon(Icons.calendar_today_rounded,
|
||||||
|
color: Colors.white70, size: 12),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
_formatDate(event.startDate!),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
],
|
||||||
|
if (event.place != null && event.place!.isNotEmpty) ...[
|
||||||
|
const Icon(Icons.location_on_rounded,
|
||||||
|
color: Colors.white70, size: 12),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
event.place!,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,10 +1677,17 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
children: [
|
children: [
|
||||||
// Background image
|
// Background image
|
||||||
img != null && img.isNotEmpty
|
img != null && img.isNotEmpty
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
img,
|
imageUrl: img,
|
||||||
|
memCacheWidth: 300,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
color: const Color(0xFF374151),
|
||||||
|
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white38))),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
color: const Color(0xFF374151),
|
color: const Color(0xFF374151),
|
||||||
child: const Icon(Icons.image, color: Colors.white38, size: 40),
|
child: const Icon(Icons.image, color: Colors.white38, size: 40),
|
||||||
),
|
),
|
||||||
@@ -1613,7 +1871,15 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: img != null && img.isNotEmpty
|
child: img != null && img.isNotEmpty
|
||||||
? Image.network(img, width: 96, height: double.infinity, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container(width: 96, color: theme.dividerColor))
|
? CachedNetworkImage(
|
||||||
|
imageUrl: img,
|
||||||
|
memCacheWidth: 192,
|
||||||
|
width: 96,
|
||||||
|
height: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(width: 96, color: theme.dividerColor, child: const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
|
||||||
|
errorWidget: (_, __, ___) => Container(width: 96, color: theme.dividerColor),
|
||||||
|
)
|
||||||
: Container(width: 96, height: double.infinity, color: theme.dividerColor),
|
: Container(width: 96, height: double.infinity, color: theme.dividerColor),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
@@ -1671,12 +1937,23 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
child: img != null && img.isNotEmpty
|
child: img != null && img.isNotEmpty
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
img,
|
imageUrl: img,
|
||||||
|
memCacheWidth: 440,
|
||||||
|
memCacheHeight: 360,
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 180,
|
height: 180,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
placeholder: (_, __) => Container(
|
||||||
|
width: 220,
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 180,
|
height: 180,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1833,12 +2110,23 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
child: img != null && img.isNotEmpty
|
child: img != null && img.isNotEmpty
|
||||||
? Image.network(
|
? CachedNetworkImage(
|
||||||
img,
|
imageUrl: img,
|
||||||
|
memCacheWidth: 800,
|
||||||
|
memCacheHeight: 400,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 200,
|
height: 200,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
placeholder: (_, __) => Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: const Center(child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2))),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 200,
|
height: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1961,7 +2249,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
try {
|
try {
|
||||||
final all = await _eventsService.getEventsByPincode(_pincode);
|
final all = await _eventsService.getEventsByPincode(_pincode);
|
||||||
final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList();
|
final filtered = id == -1 ? all : all.where((e) => e.eventTypeId == id).toList();
|
||||||
if (mounted) setState(() => _events = filtered);
|
if (mounted) setState(() { _events = filtered; _cachedFilteredEvents = null; });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||||
}
|
}
|
||||||
@@ -1975,3 +2263,57 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
|||||||
return 'You';
|
return 'You';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Animated shimmer placeholder shown while a hero card image is loading.
|
||||||
|
/// Renders a blue-toned scan-line effect matching the app's colour palette.
|
||||||
|
class _HeroShimmer extends StatefulWidget {
|
||||||
|
final double radius;
|
||||||
|
const _HeroShimmer({this.radius = 24.0});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HeroShimmer> createState() => _HeroShimmerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HeroShimmerState extends State<_HeroShimmer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1400),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
builder: (_, __) {
|
||||||
|
final x = -1.5 + _ctrl.value * 3.0;
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(widget.radius),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment(x - 1.0, 0),
|
||||||
|
end: Alignment(x, 0),
|
||||||
|
colors: const [
|
||||||
|
Color(0xFF1A2A4A),
|
||||||
|
Color(0xFF2D4580),
|
||||||
|
Color(0xFF1A2A4A),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
|
||||||
import '../features/events/models/event_models.dart';
|
import '../features/events/models/event_models.dart';
|
||||||
import '../features/events/services/events_service.dart';
|
import '../features/events/services/events_service.dart';
|
||||||
|
import '../core/auth/auth_guard.dart';
|
||||||
|
import '../core/constants.dart';
|
||||||
|
|
||||||
class LearnMoreScreen extends StatefulWidget {
|
class LearnMoreScreen extends StatefulWidget {
|
||||||
final int eventId;
|
final int eventId;
|
||||||
@@ -27,7 +30,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
|
|
||||||
// Carousel
|
// Carousel
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _currentPage = 0;
|
late final ValueNotifier<int> _pageNotifier;
|
||||||
Timer? _autoScrollTimer;
|
Timer? _autoScrollTimer;
|
||||||
|
|
||||||
// About section
|
// About section
|
||||||
@@ -44,6 +47,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_pageNotifier = ValueNotifier(0);
|
||||||
_loadEvent();
|
_loadEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_autoScrollTimer?.cancel();
|
_autoScrollTimer?.cancel();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
|
_pageNotifier.dispose();
|
||||||
_mapController?.dispose();
|
_mapController?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -98,7 +103,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
if (count <= 1) return;
|
if (count <= 1) return;
|
||||||
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
_autoScrollTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||||
if (!_pageController.hasClients) return;
|
if (!_pageController.hasClients) return;
|
||||||
final next = (_currentPage + 1) % count;
|
final next = (_pageNotifier.value + 1) % count;
|
||||||
_pageController.animateToPage(next,
|
_pageController.animateToPage(next,
|
||||||
duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
|
duration: const Duration(milliseconds: 500), curve: Curves.easeInOut);
|
||||||
});
|
});
|
||||||
@@ -222,10 +227,280 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final screenWidth = mediaQuery.size.width;
|
||||||
|
final screenHeight = mediaQuery.size.height;
|
||||||
final imageHeight = screenHeight * 0.45;
|
final imageHeight = screenHeight * 0.45;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = mediaQuery.padding.top;
|
||||||
|
|
||||||
|
// ── DESKTOP layout ──────────────────────────────────────────────────
|
||||||
|
if (screenWidth >= AppConstants.desktopBreakpoint) {
|
||||||
|
final images = _imageUrls;
|
||||||
|
final heroImage = images.isNotEmpty ? images[0] : null;
|
||||||
|
final venueLabel = _event!.locationName ?? _event!.venueName ?? _event!.place ?? '';
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── Hero image with gradient overlay ──
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 300,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background image
|
||||||
|
if (heroImage != null)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: heroImage,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Gradient overlay
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withOpacity(0.3),
|
||||||
|
Colors.black.withOpacity(0.65),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Top bar: back + share + wishlist
|
||||||
|
Positioned(
|
||||||
|
top: topPadding + 10,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_squareIconButton(icon: Icons.arrow_back, onTap: () => Navigator.pop(context)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_squareIconButton(icon: Icons.ios_share_outlined, onTap: _shareEvent),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_squareIconButton(
|
||||||
|
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
|
||||||
|
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
|
||||||
|
onTap: () {
|
||||||
|
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
|
||||||
|
setState(() => _wishlisted = !_wishlisted);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Title + date + venue overlaid at bottom-left
|
||||||
|
Positioned(
|
||||||
|
left: 32,
|
||||||
|
bottom: 28,
|
||||||
|
right: 200,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_event!.title ?? _event!.name,
|
||||||
|
style: theme.textTheme.headlineMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 28,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today_outlined, size: 16, color: Colors.white70),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_formattedDateRange(),
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 15),
|
||||||
|
),
|
||||||
|
if (venueLabel.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Icon(Icons.location_on_outlined, size: 16, color: Colors.white70),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
venueLabel,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 15),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// "Book Your Spot" CTA on the right
|
||||||
|
Positioned(
|
||||||
|
right: 32,
|
||||||
|
bottom: 36,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: implement booking action
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF1A56DB),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Book Your Spot',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
|
||||||
|
// ── Two-column: About (left 60%) + Venue/Map (right 40%) ──
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Left column — About the Event
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildAboutSection(theme),
|
||||||
|
if (_event!.importantInfo.isNotEmpty)
|
||||||
|
_buildImportantInfoSection(theme),
|
||||||
|
if (_event!.importantInfo.isEmpty &&
|
||||||
|
(_event!.importantInformation ?? '').isNotEmpty)
|
||||||
|
_buildImportantInfoFallback(theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 32),
|
||||||
|
// Right column — Venue / map
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_event!.latitude != null && _event!.longitude != null) ...[
|
||||||
|
_buildVenueSection(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildGetDirectionsButton(theme),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Gallery: horizontal scrollable image strip ──
|
||||||
|
if (images.length > 1) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 32),
|
||||||
|
child: Text(
|
||||||
|
'Gallery',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
height: 160,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
|
itemCount: images.length > 6 ? 6 : images.length,
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
// Show overflow count badge on last visible item
|
||||||
|
final isLast = i == 5 && images.length > 6;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: images[i],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.broken_image, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLast)
|
||||||
|
Container(
|
||||||
|
color: Colors.black.withOpacity(0.55),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'+${images.length - 6}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@@ -310,10 +585,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
// Pill-shaped page indicators (centered)
|
// Pill-shaped page indicators (centered)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _imageUrls.length > 1
|
child: _imageUrls.length > 1
|
||||||
? Row(
|
? ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _pageNotifier,
|
||||||
|
builder: (context, currentPage, _) => Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: List.generate(_imageUrls.length, (i) {
|
children: List.generate(_imageUrls.length, (i) {
|
||||||
final active = i == _currentPage;
|
final active = i == currentPage;
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
@@ -327,6 +604,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
@@ -338,7 +616,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
_squareIconButton(
|
_squareIconButton(
|
||||||
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
|
icon: _wishlisted ? Icons.favorite : Icons.favorite_border,
|
||||||
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
|
iconColor: _wishlisted ? Colors.redAccent : Colors.white,
|
||||||
onTap: () => setState(() => _wishlisted = !_wishlisted),
|
onTap: () {
|
||||||
|
if (!AuthGuard.requireLogin(context, reason: 'Sign in to save events to your wishlist.')) return;
|
||||||
|
setState(() => _wishlisted = !_wishlisted);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -354,6 +635,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Widget _buildLoadingShimmer(ThemeData theme) {
|
Widget _buildLoadingShimmer(ThemeData theme) {
|
||||||
|
final shimmerHeight = MediaQuery.of(context).size.height;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
@@ -362,7 +644,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Placeholder image
|
// Placeholder image
|
||||||
Container(
|
Container(
|
||||||
height: MediaQuery.of(context).size.height * 0.42,
|
height: shimmerHeight * 0.42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.dividerColor.withOpacity(0.3),
|
color: theme.dividerColor.withOpacity(0.3),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
@@ -430,10 +712,14 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Image.network(
|
ValueListenableBuilder<int>(
|
||||||
images[_currentPage],
|
valueListenable: _pageNotifier,
|
||||||
|
builder: (context, currentPage, _) => CachedNetworkImage(
|
||||||
|
imageUrl: images[currentPage],
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
placeholder: (_, __) => Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -442,6 +728,16 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
BackdropFilter(
|
BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
|
filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25),
|
||||||
@@ -474,13 +770,17 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: PageView.builder(
|
child: PageView.builder(
|
||||||
controller: _pageController,
|
controller: _pageController,
|
||||||
onPageChanged: (i) => setState(() => _currentPage = i),
|
onPageChanged: (i) => _pageNotifier.value = i,
|
||||||
itemCount: images.length,
|
itemCount: images.length,
|
||||||
itemBuilder: (_, i) => Image.network(
|
itemBuilder: (_, i) => CachedNetworkImage(
|
||||||
images[i],
|
imageUrl: images[i],
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
placeholder: (_, __) => Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
color: theme.dividerColor,
|
color: theme.dividerColor,
|
||||||
child: Icon(Icons.broken_image, size: 48, color: theme.hintColor),
|
child: Icon(Icons.broken_image, size: 48, color: theme.hintColor),
|
||||||
),
|
),
|
||||||
@@ -672,10 +972,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.network(
|
child: CachedNetworkImage(
|
||||||
'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
|
imageUrl: 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$lng&zoom=15&size=600x300&markers=color:red%7C$lat,$lng&key=',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorWidget: (_, __, ___) => Container(
|
||||||
color: const Color(0xFFE8EAF6),
|
color: const Color(0xFFE8EAF6),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 '../features/auth/services/auth_service.dart';
|
import '../features/auth/services/auth_service.dart';
|
||||||
|
import '../core/auth/auth_guard.dart';
|
||||||
import 'home_screen.dart';
|
import 'home_screen.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -47,9 +48,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initVideo() async {
|
Future<void> _initVideo() async {
|
||||||
_videoController = VideoPlayerController.networkUrl(
|
_videoController = VideoPlayerController.asset('assets/login-bg.mp4');
|
||||||
Uri.parse('assets/login-bg.mp4'),
|
|
||||||
);
|
|
||||||
await _videoController.initialize();
|
await _videoController.initialize();
|
||||||
_videoController.setLooping(true);
|
_videoController.setLooping(true);
|
||||||
_videoController.setVolume(0);
|
_videoController.setVolume(0);
|
||||||
@@ -511,6 +510,34 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Continue as Guest
|
||||||
|
Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
AuthGuard.setGuest(true);
|
||||||
|
Navigator.of(context).pushAndRemoveUntil(
|
||||||
|
MaterialPageRoute(builder: (_) => const HomeScreen()),
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Continue as Guest',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -11,6 +12,7 @@ import 'learn_more_screen.dart';
|
|||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
import '../core/constants.dart';
|
import '../core/constants.dart';
|
||||||
|
import '../widgets/landscape_section_header.dart';
|
||||||
|
|
||||||
class ProfileScreen extends StatefulWidget {
|
class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({Key? key}) : super(key: key);
|
const ProfileScreen({Key? key}) : super(key: key);
|
||||||
@@ -89,24 +91,17 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
parent: _animController,
|
parent: _animController,
|
||||||
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
|
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
|
||||||
);
|
);
|
||||||
|
// Update fields without setState — AnimatedBuilder handles the rebuilds
|
||||||
expAnim.addListener(() {
|
expAnim.addListener(() {
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_expProgress = expTween.evaluate(expAnim);
|
_expProgress = expTween.evaluate(expAnim);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animate stat counters: 0 → target over full 2s
|
|
||||||
_animController.addListener(() {
|
_animController.addListener(() {
|
||||||
if (!mounted) return;
|
|
||||||
final t = _animController.value;
|
final t = _animController.value;
|
||||||
setState(() {
|
|
||||||
_animatedLikes = (t * _targetLikes).round();
|
_animatedLikes = (t * _targetLikes).round();
|
||||||
_animatedPosts = (t * _targetPosts).round();
|
_animatedPosts = (t * _targetPosts).round();
|
||||||
_animatedViews = (t * _targetViews).round();
|
_animatedViews = (t * _targetViews).round();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
_animController.forward();
|
_animController.forward();
|
||||||
});
|
});
|
||||||
@@ -273,6 +268,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
final result = await showDialog<String?>(
|
final result = await showDialog<String?>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
|
// Note: ctl is disposed after dialog closes below
|
||||||
final theme = Theme.of(ctx);
|
final theme = Theme.of(ctx);
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Enter image path or URL'),
|
title: const Text('Enter image path or URL'),
|
||||||
@@ -305,6 +301,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ctl.dispose();
|
||||||
if (result == null || result.isEmpty) return;
|
if (result == null || result.isEmpty) return;
|
||||||
await _saveProfile(_username, _email, result);
|
await _saveProfile(_username, _email, result);
|
||||||
}
|
}
|
||||||
@@ -318,6 +315,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
|
// nameCtl and emailCtl are disposed via .then() below
|
||||||
final theme = Theme.of(ctx);
|
final theme = Theme.of(ctx);
|
||||||
return DraggableScrollableSheet(
|
return DraggableScrollableSheet(
|
||||||
expand: false,
|
expand: false,
|
||||||
@@ -419,7 +417,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
).then((_) {
|
||||||
|
nameCtl.dispose();
|
||||||
|
emailCtl.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────── Avatar builder (reused, with size param) ─────────
|
// ───────── Avatar builder (reused, with size param) ─────────
|
||||||
@@ -428,11 +429,16 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
final path = _profileImage.trim();
|
final path = _profileImage.trim();
|
||||||
if (path.startsWith('http')) {
|
if (path.startsWith('http')) {
|
||||||
return ClipOval(
|
return ClipOval(
|
||||||
child: Image.network(path,
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: path,
|
||||||
|
memCacheWidth: (size * 2).toInt(),
|
||||||
|
memCacheHeight: (size * 2).toInt(),
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) =>
|
placeholder: (_, __) =>
|
||||||
|
Container(width: size, height: size, color: const Color(0xFFE5E7EB)),
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
||||||
}
|
}
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
@@ -497,11 +503,18 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
if (imageUrl.startsWith('http')) {
|
if (imageUrl.startsWith('http')) {
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Image.network(imageUrl,
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
memCacheWidth: 120,
|
||||||
|
memCacheHeight: 120,
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
placeholder: (_, __) => Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: const Color(0xFFE5E7EB)),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 60,
|
height: 60,
|
||||||
color: theme.dividerColor,
|
color: theme.dividerColor,
|
||||||
@@ -736,7 +749,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LayoutBuilder(
|
child: AnimatedBuilder(
|
||||||
|
animation: _animController,
|
||||||
|
builder: (context, _) => LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final fullWidth = constraints.maxWidth;
|
final fullWidth = constraints.maxWidth;
|
||||||
final filledWidth = fullWidth * _expProgress;
|
final filledWidth = fullWidth * _expProgress;
|
||||||
@@ -744,7 +759,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
color: Colors.grey.shade200, // gray track
|
color: Colors.grey.shade200,
|
||||||
),
|
),
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -761,6 +776,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -805,7 +821,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
bottom: BorderSide(color: Colors.grey.shade200, width: 1),
|
bottom: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: IntrinsicHeight(
|
child: AnimatedBuilder(
|
||||||
|
animation: _animController,
|
||||||
|
builder: (context, _) => IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
statColumn(_formatNumber(_animatedLikes), 'Likes'),
|
statColumn(_formatNumber(_animatedLikes), 'Likes'),
|
||||||
@@ -816,6 +834,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -995,6 +1014,534 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// LANDSCAPE LAYOUT
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
Widget _buildLandscapeLeftPanel(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Top bar row — title + settings
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Profile',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(color: Colors.white24, borderRadius: BorderRadius.circular(10)),
|
||||||
|
child: const Icon(Icons.settings, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Avatar + name section
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 8, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: _buildProfileAvatar(size: 64),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_username.isNotEmpty ? _username : 'Guest User',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w700),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_email,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// EXP Bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: _buildExpBar(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Stats row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: _buildLandscapeStats(context, textColor: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// Edit profile button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _openEditDialog,
|
||||||
|
icon: const Icon(Icons.edit, size: 16, color: Colors.white),
|
||||||
|
label: const Text('Edit Profile', style: TextStyle(color: Colors.white)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.white38),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLandscapeStats(BuildContext context, {Color? textColor}) {
|
||||||
|
final color = textColor ?? Theme.of(context).textTheme.bodyLarge?.color;
|
||||||
|
final hintColor = textColor?.withOpacity(0.6) ?? Theme.of(context).hintColor;
|
||||||
|
|
||||||
|
String fmt(int v) => v >= 1000 ? '${(v / 1000).toStringAsFixed(1)}K' : '$v';
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animController,
|
||||||
|
builder: (_, __) => Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_landscapeStatItem(fmt(_animatedLikes), 'Likes', color, hintColor),
|
||||||
|
_landscapeStatDivider(),
|
||||||
|
_landscapeStatItem(fmt(_animatedPosts), 'Posts', color, hintColor),
|
||||||
|
_landscapeStatDivider(),
|
||||||
|
_landscapeStatItem(fmt(_animatedViews), 'Views', color, hintColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _landscapeStatItem(String value, String label, Color? valueColor, Color? labelColor) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: valueColor)),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, color: labelColor, fontWeight: FontWeight.w400)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _landscapeStatDivider() => Container(width: 1, height: 36, color: Colors.white24);
|
||||||
|
|
||||||
|
Widget _buildLandscapeRightPanel(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
Widget _eventList(List<EventModel> events, {bool faded = false}) {
|
||||||
|
if (_loadingEvents) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (events.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Text('No events', style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.builder(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 8, 18, 32),
|
||||||
|
itemCount: events.length,
|
||||||
|
itemBuilder: (ctx, i) => _eventListTileFromModel(events[i], faded: faded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const LandscapeSectionHeader(title: 'My Events'),
|
||||||
|
// Tab bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.dividerColor.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: TabBar(
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: theme.hintColor,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||||||
|
tabs: const [
|
||||||
|
Tab(text: 'Ongoing'),
|
||||||
|
Tab(text: 'Upcoming'),
|
||||||
|
Tab(text: 'Past'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
_eventList(_ongoingEvents),
|
||||||
|
_eventList(_upcomingEvents),
|
||||||
|
_eventList(_pastEvents, faded: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
// DESKTOP LAYOUT (Figma: full-width banner + 3-col grids)
|
||||||
|
// ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
Widget _buildDesktopLayout(BuildContext context, ThemeData theme) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Full-width profile header + card (reuse existing widgets)
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
_buildGradientHeader(context, 200),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 130),
|
||||||
|
child: _buildProfileCard(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Ongoing Events (only if non-empty)
|
||||||
|
if (_ongoingEvents.isNotEmpty)
|
||||||
|
_buildDesktopEventSection(
|
||||||
|
context,
|
||||||
|
title: 'Ongoing Events',
|
||||||
|
events: _ongoingEvents,
|
||||||
|
faded: false,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Upcoming Events
|
||||||
|
_buildDesktopEventSection(
|
||||||
|
context,
|
||||||
|
title: 'Upcoming Events',
|
||||||
|
events: _upcomingEvents,
|
||||||
|
faded: false,
|
||||||
|
emptyMessage: 'No upcoming events',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Past Events
|
||||||
|
_buildDesktopEventSection(
|
||||||
|
context,
|
||||||
|
title: 'Past Events',
|
||||||
|
events: _pastEvents,
|
||||||
|
faded: true,
|
||||||
|
emptyMessage: 'No past events',
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Section heading row ("Title" + "View All >") followed by a 3-column grid.
|
||||||
|
Widget _buildDesktopEventSection(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required List<EventModel> events,
|
||||||
|
bool faded = false,
|
||||||
|
String? emptyMessage,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Heading row
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (events.isNotEmpty)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// View all — no-op for now; could navigate to a full list
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'View All >',
|
||||||
|
style: TextStyle(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (_loadingEvents)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else if (events.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
emptyMessage ?? 'No events',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
GridView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: 16,
|
||||||
|
mainAxisSpacing: 16,
|
||||||
|
childAspectRatio: 0.82,
|
||||||
|
),
|
||||||
|
itemCount: events.length,
|
||||||
|
itemBuilder: (ctx, i) =>
|
||||||
|
_buildDesktopEventGridCard(events[i], faded: faded),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single event card for the desktop grid: image on top, title, date (blue dot), venue (green dot).
|
||||||
|
Widget _buildDesktopEventGridCard(EventModel ev, {bool faded = false}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final title = ev.title ?? ev.name ?? '';
|
||||||
|
final dateLabel =
|
||||||
|
(ev.startDate != null && ev.endDate != null && ev.startDate == ev.endDate)
|
||||||
|
? ev.startDate!
|
||||||
|
: ((ev.startDate != null && ev.endDate != null)
|
||||||
|
? '${ev.startDate} - ${ev.endDate}'
|
||||||
|
: (ev.startDate ?? ''));
|
||||||
|
final location = ev.place ?? '';
|
||||||
|
final imageUrl = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty)
|
||||||
|
? ev.thumbImg!
|
||||||
|
: (ev.images.isNotEmpty ? ev.images.first.image : null);
|
||||||
|
|
||||||
|
final titleColor = faded ? theme.hintColor : (theme.textTheme.bodyLarge?.color);
|
||||||
|
final subtitleColor = faded
|
||||||
|
? theme.hintColor.withValues(alpha: 0.7)
|
||||||
|
: theme.hintColor;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (ev.id != null) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.06),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Image
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(14)),
|
||||||
|
child: _buildCardImage(imageUrl, theme),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Text content
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Title
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: titleColor,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Date row with blue dot
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
dateLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// Venue row with green dot
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF22C55E),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
location,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build the image widget for a desktop grid card.
|
||||||
|
Widget _buildCardImage(String? imageUrl, ThemeData theme) {
|
||||||
|
if (imageUrl != null && imageUrl.trim().isNotEmpty) {
|
||||||
|
if (imageUrl.startsWith('http')) {
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
memCacheWidth: 400,
|
||||||
|
memCacheHeight: 400,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, __) => Container(color: theme.dividerColor),
|
||||||
|
errorWidget: (_, __, ___) => Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!kIsWeb) {
|
||||||
|
final path = imageUrl;
|
||||||
|
if (path.startsWith('/') || path.contains(Platform.pathSeparator)) {
|
||||||
|
final file = File(path);
|
||||||
|
if (file.existsSync()) {
|
||||||
|
return Image.file(
|
||||||
|
file,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Image.asset(
|
||||||
|
imageUrl,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
child: Icon(Icons.event, size: 32, color: theme.hintColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
// BUILD
|
// BUILD
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
@@ -1003,15 +1550,33 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
const double headerHeight = 200.0;
|
const double headerHeight = 200.0;
|
||||||
const double cardTopOffset = 130.0; // card starts overlapping into header
|
const double cardTopOffset = 130.0;
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
|
Widget sectionTitle(String text) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: theme.textTheme.titleMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w600, fontSize: 18),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── DESKTOP / LANDSCAPE layout ─────────────────────────────────────────
|
||||||
|
if (width >= AppConstants.desktopBreakpoint) {
|
||||||
|
return _buildDesktopLayout(context, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: SingleChildScrollView(
|
// CustomScrollView: only visible event cards are built — no full-tree Column renders
|
||||||
child: Column(
|
body: CustomScrollView(
|
||||||
children: [
|
physics: const BouncingScrollPhysics(),
|
||||||
// Header + Profile Card overlap using Stack
|
slivers: [
|
||||||
Stack(
|
// Header gradient + Profile card overlap (same visual as before)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_buildGradientHeader(context, headerHeight),
|
_buildGradientHeader(context, headerHeight),
|
||||||
Padding(
|
Padding(
|
||||||
@@ -1020,13 +1585,74 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Event sections
|
|
||||||
_buildEventSections(context),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ── Ongoing Events ──
|
||||||
|
if (_ongoingEvents.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(child: sectionTitle('Ongoing Events')),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(ctx, i) => _eventListTileFromModel(_ongoingEvents[i]),
|
||||||
|
childCount: _ongoingEvents.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Upcoming Events ──
|
||||||
|
SliverToBoxAdapter(child: sectionTitle('Upcoming Events')),
|
||||||
|
if (_loadingEvents)
|
||||||
|
const SliverToBoxAdapter(child: SizedBox.shrink())
|
||||||
|
else if (_upcomingEvents.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
|
child: Text('No upcoming events',
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: theme.hintColor)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(ctx, i) => _eventListTileFromModel(_upcomingEvents[i]),
|
||||||
|
childCount: _upcomingEvents.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
|
||||||
|
// ── Past Events ──
|
||||||
|
SliverToBoxAdapter(child: sectionTitle('Past Events')),
|
||||||
|
if (_loadingEvents)
|
||||||
|
const SliverToBoxAdapter(child: SizedBox.shrink())
|
||||||
|
else if (_pastEvents.isEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
|
child: Text('No past events',
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: theme.hintColor)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(ctx, i) => _eventListTileFromModel(_pastEvents[i], faded: true),
|
||||||
|
childCount: _pastEvents.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
if (mounted) Navigator.of(context).pop('${pos.latitude.toStringAsFixed(5)},${pos.longitude.toStringAsFixed(5)}');
|
if (mounted) Navigator.of(context).pop('Current Location');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Could not determine location: $e')));
|
||||||
@@ -169,10 +169,12 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
BackdropFilter(
|
RepaintBoundary(
|
||||||
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
|
||||||
child: Container(color: Colors.black.withOpacity(0.16)),
|
child: Container(color: Colors.black.withOpacity(0.16)),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
@@ -305,9 +307,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))),
|
child: Center(child: Text('No results found', style: TextStyle(color: Colors.grey[500]))),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ListView.separated(
|
ConstrainedBox(
|
||||||
shrinkWrap: true,
|
constraints: const BoxConstraints(maxHeight: 320),
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
child: ListView.separated(
|
||||||
|
shrinkWrap: false,
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
itemCount: _searchResults.length,
|
itemCount: _searchResults.length,
|
||||||
separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1),
|
separatorBuilder: (_, __) => Divider(color: Colors.grey[200], height: 1),
|
||||||
itemBuilder: (ctx, idx) {
|
itemBuilder: (ctx, idx) {
|
||||||
@@ -326,6 +330,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))),
|
const Text('Popular Cities', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Color(0xFF1A1A2E))),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'login_screen.dart';
|
import 'login_screen.dart';
|
||||||
import 'desktop_login_screen.dart';
|
import 'desktop_login_screen.dart';
|
||||||
import '../core/theme_manager.dart';
|
import '../core/theme_manager.dart';
|
||||||
import 'privacy_policy_screen.dart'; // new import
|
import 'privacy_policy_screen.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
@@ -15,7 +15,8 @@ class SettingsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
bool _notifications = true;
|
bool _notifications = true;
|
||||||
String _appVersion = '1.2(p)';
|
String _appVersion = '1.6(p)';
|
||||||
|
int _selectedSection = 0; // 0=Preferences, 1=Account, 2=About
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -100,16 +101,209 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Settings content sections ────────────────────────────────────────────
|
||||||
|
Widget _buildPreferencesSection() {
|
||||||
|
const primary = Color(0xFF0B63D6);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
|
||||||
|
child: SwitchListTile(
|
||||||
|
tileColor: Theme.of(context).cardColor,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
value: _notifications,
|
||||||
|
onChanged: (v) => _saveNotifications(v),
|
||||||
|
title: const Text('Reminders'),
|
||||||
|
secondary: const Icon(Icons.notifications, color: primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
|
||||||
|
child: ValueListenableBuilder<ThemeMode>(
|
||||||
|
valueListenable: ThemeManager.themeMode,
|
||||||
|
builder: (context, mode, _) {
|
||||||
|
return SwitchListTile(
|
||||||
|
tileColor: Theme.of(context).cardColor,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
value: mode == ThemeMode.dark,
|
||||||
|
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
|
||||||
|
title: const Text('Dark Mode'),
|
||||||
|
secondary: const Icon(Icons.dark_mode, color: primary),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAccountSection() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTile(
|
||||||
|
icon: Icons.person,
|
||||||
|
title: 'Edit Profile',
|
||||||
|
subtitle: 'Change username, email or photo',
|
||||||
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab'))),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _confirmLogout,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red.shade600,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
),
|
||||||
|
child: const Text('Logout', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAboutSection() {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 8, 18, 24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTile(
|
||||||
|
icon: Icons.privacy_tip_outlined,
|
||||||
|
title: 'Privacy Policy',
|
||||||
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActiveSection() {
|
||||||
|
switch (_selectedSection) {
|
||||||
|
case 1: return _buildAccountSection();
|
||||||
|
case 2: return _buildAboutSection();
|
||||||
|
default: return _buildPreferencesSection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
const primary = Color(0xFF0B63D6);
|
const primary = Color(0xFF0B63D6);
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
final isLandscape = width >= 820;
|
||||||
|
|
||||||
|
// ── LANDSCAPE layout ──────────────────────────────────────────────────
|
||||||
|
if (isLandscape) {
|
||||||
|
const navIcons = [Icons.tune, Icons.person_outline, Icons.info_outline];
|
||||||
|
const navLabels = ['Preferences', 'Account', 'About'];
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Left: settings nav on gradient
|
||||||
|
Flexible(
|
||||||
|
flex: 1,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
decoration: AppDecoration.blueGradient,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 24, 20, 20),
|
||||||
|
child: Text('Settings', style: TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
...List.generate(navLabels.length, (i) {
|
||||||
|
final isActive = _selectedSection == i;
|
||||||
|
return MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedSection = i),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? Colors.white : Colors.white.withOpacity(0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(navIcons[i], size: 20, color: isActive ? primary : Colors.white70),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(navLabels[i], style: TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: isActive ? primary : Colors.white)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _confirmLogout,
|
||||||
|
icon: const Icon(Icons.logout, color: Colors.white70, size: 18),
|
||||||
|
label: const Text('Logout', style: TextStyle(color: Colors.white70)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Right: settings content
|
||||||
|
Flexible(
|
||||||
|
flex: 2,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
navLabels[_selectedSection],
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildActiveSection()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
|
||||||
@@ -131,41 +325,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// Content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
padding: const EdgeInsets.fromLTRB(18, 0, 18, 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Account
|
|
||||||
const Text('Account', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('Account', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildTile(
|
_buildTile(
|
||||||
icon: Icons.person,
|
icon: Icons.person,
|
||||||
title: 'Edit Profile',
|
title: 'Edit Profile',
|
||||||
subtitle: 'Change username, email or photo',
|
subtitle: 'Change username, email or photo',
|
||||||
onTap: () {
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)'))),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Open Profile tab (demo)')));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Preferences
|
|
||||||
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('Preferences', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Reminders switch wrapped in card-like container
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
|
||||||
color: Theme.of(context).cardColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
|
|
||||||
),
|
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
tileColor: Theme.of(context).cardColor,
|
tileColor: Theme.of(context).cardColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
@@ -175,54 +355,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
secondary: const Icon(Icons.notifications, color: primary),
|
secondary: const Icon(Icons.notifications, color: primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Dark Mode switch wrapped in card-like container and hooked to ThemeManager
|
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: Theme.of(context).cardColor, borderRadius: BorderRadius.circular(12), boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))]),
|
||||||
color: Theme.of(context).cardColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 6, offset: Offset(0, 4))],
|
|
||||||
),
|
|
||||||
child: ValueListenableBuilder<ThemeMode>(
|
child: ValueListenableBuilder<ThemeMode>(
|
||||||
valueListenable: ThemeManager.themeMode,
|
valueListenable: ThemeManager.themeMode,
|
||||||
builder: (context, mode, _) {
|
builder: (context, mode, _) => SwitchListTile(
|
||||||
final isDark = mode == ThemeMode.dark;
|
|
||||||
return SwitchListTile(
|
|
||||||
tileColor: Theme.of(context).cardColor,
|
tileColor: Theme.of(context).cardColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
value: isDark,
|
value: mode == ThemeMode.dark,
|
||||||
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
|
onChanged: (v) => ThemeManager.setThemeMode(v ? ThemeMode.dark : ThemeMode.light),
|
||||||
title: const Text('Dark Mode'),
|
title: const Text('Dark Mode'),
|
||||||
secondary: const Icon(Icons.dark_mode, color: primary),
|
secondary: const Icon(Icons.dark_mode, color: primary),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// About
|
|
||||||
const Text('About', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
const Text('About', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
|
_buildTile(icon: Icons.info_outline, title: 'App Version', subtitle: _appVersion, onTap: () {}),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Privacy Policy tile now navigates to PrivacyPolicyScreen
|
|
||||||
_buildTile(
|
_buildTile(
|
||||||
icon: Icons.privacy_tip_outlined,
|
icon: Icons.privacy_tip_outlined,
|
||||||
title: 'Privacy Policy',
|
title: 'Privacy Policy',
|
||||||
subtitle: 'Demo app',
|
subtitle: 'Demo app',
|
||||||
onTap: () {
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen())),
|
||||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const PrivacyPolicyScreen()));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Logout area
|
|
||||||
Center(
|
Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -240,7 +400,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
141
lib/widgets/desktop_sidebar.dart
Normal file
141
lib/widgets/desktop_sidebar.dart
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/app_decoration.dart';
|
||||||
|
import '../core/constants.dart';
|
||||||
|
|
||||||
|
class DesktopSidebar extends StatelessWidget {
|
||||||
|
final int selectedIndex;
|
||||||
|
final ValueChanged<int> onIndexChanged;
|
||||||
|
|
||||||
|
const DesktopSidebar({
|
||||||
|
Key? key,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onIndexChanged,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
static const _navItems = <_NavDef>[
|
||||||
|
_NavDef(Icons.home_outlined, Icons.home, 'Home', 0),
|
||||||
|
_NavDef(Icons.calendar_today_outlined, Icons.calendar_today, 'Calendar', 1),
|
||||||
|
_NavDef(Icons.person_outline, Icons.person, 'Profile', 2),
|
||||||
|
];
|
||||||
|
|
||||||
|
static const _bottomItems = <_NavDef>[
|
||||||
|
_NavDef(Icons.settings_outlined, Icons.settings, 'Settings', 5),
|
||||||
|
_NavDef(Icons.help_outline, Icons.help, 'Help', -1),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
width: AppConstants.sidebarExpandedWidth,
|
||||||
|
decoration: AppDecoration.blueGradient,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Logo
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 24, top: 20, right: 24),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'EVENTIFY',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Main nav items
|
||||||
|
Column(
|
||||||
|
children: _navItems
|
||||||
|
.map((item) => _buildNavItem(item))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// Bottom nav items
|
||||||
|
Column(
|
||||||
|
children: _bottomItems
|
||||||
|
.map((item) => _buildNavItem(item))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavItem(_NavDef item) {
|
||||||
|
final selected = selectedIndex == item.index;
|
||||||
|
final icon = selected ? item.activeIcon : item.icon;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: MouseRegion(
|
||||||
|
cursor: SystemMouseCursors.click,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => onIndexChanged(item.index),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
height: 48,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? Colors.white : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 22,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF0F45CF)
|
||||||
|
: Colors.white.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
item.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF0F45CF)
|
||||||
|
: Colors.white.withValues(alpha: 0.85),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavDef {
|
||||||
|
final IconData icon;
|
||||||
|
final IconData activeIcon;
|
||||||
|
final String label;
|
||||||
|
final int index;
|
||||||
|
const _NavDef(this.icon, this.activeIcon, this.label, this.index);
|
||||||
|
}
|
||||||
142
lib/widgets/desktop_topbar.dart
Normal file
142
lib/widgets/desktop_topbar.dart
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class DesktopTopBar extends StatelessWidget {
|
||||||
|
final String username;
|
||||||
|
final String? profileImage;
|
||||||
|
final VoidCallback? onSearchTap;
|
||||||
|
final VoidCallback? onNotificationTap;
|
||||||
|
final VoidCallback? onAvatarTap;
|
||||||
|
|
||||||
|
const DesktopTopBar({
|
||||||
|
Key? key,
|
||||||
|
required this.username,
|
||||||
|
this.profileImage,
|
||||||
|
this.onSearchTap,
|
||||||
|
this.onNotificationTap,
|
||||||
|
this.onAvatarTap,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: 64,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.scaffoldBackgroundColor,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Left: search bar
|
||||||
|
Expanded(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: TextField(
|
||||||
|
onTap: onSearchTap,
|
||||||
|
readOnly: onSearchTap != null,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: theme.colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.4),
|
||||||
|
prefixIcon: Icon(Icons.search, color: theme.hintColor),
|
||||||
|
hintText: 'Search',
|
||||||
|
hintStyle: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: theme.hintColor),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 0),
|
||||||
|
),
|
||||||
|
style: theme.textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Right: notification bell + avatar
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: onNotificationTap,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.notifications_none,
|
||||||
|
color: theme.iconTheme.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 6,
|
||||||
|
top: 6,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 8,
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
child: Text(
|
||||||
|
'2',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onAvatarTap,
|
||||||
|
child: _buildAvatar(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAvatar() {
|
||||||
|
if (profileImage != null && profileImage!.trim().isNotEmpty) {
|
||||||
|
final url = profileImage!.trim();
|
||||||
|
if (url.startsWith('http')) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.grey.shade200,
|
||||||
|
backgroundImage: NetworkImage(url),
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = username.trim();
|
||||||
|
String initials = 'U';
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
if (name.contains('@')) {
|
||||||
|
initials = name[0].toUpperCase();
|
||||||
|
} else {
|
||||||
|
final parts = name.split(' ').where((p) => p.isNotEmpty).toList();
|
||||||
|
initials = parts.isEmpty
|
||||||
|
? 'U'
|
||||||
|
: parts.take(2).map((p) => p[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.blue.shade600,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
lib/widgets/landscape_section_header.dart
Normal file
58
lib/widgets/landscape_section_header.dart
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// lib/widgets/landscape_section_header.dart
|
||||||
|
//
|
||||||
|
// Consistent section header for the right panel of landscape layouts.
|
||||||
|
// Shows a title, optional subtitle, and optional trailing action widget.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class LandscapeSectionHeader extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final Widget? trailing;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
|
||||||
|
const LandscapeSectionHeader({
|
||||||
|
Key? key,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.trailing,
|
||||||
|
this.padding = const EdgeInsets.fromLTRB(24, 24, 24, 12),
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lib/widgets/landscape_shell.dart
Normal file
67
lib/widgets/landscape_shell.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// lib/widgets/landscape_shell.dart
|
||||||
|
//
|
||||||
|
// Reusable two-panel landscape scaffold for all desktop/wide screens.
|
||||||
|
// Left panel uses the brand dark-blue gradient; right panel is the content area.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// LandscapeShell(
|
||||||
|
// leftPanel: MyLeftContent(),
|
||||||
|
// rightPanel: MyRightContent(),
|
||||||
|
// )
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/app_decoration.dart';
|
||||||
|
|
||||||
|
class LandscapeShell extends StatelessWidget {
|
||||||
|
final Widget leftPanel;
|
||||||
|
final Widget rightPanel;
|
||||||
|
|
||||||
|
/// Flex weight for left panel (default 2 → ~40% of width)
|
||||||
|
final int leftFlex;
|
||||||
|
|
||||||
|
/// Flex weight for right panel (default 3 → ~60% of width)
|
||||||
|
final int rightFlex;
|
||||||
|
|
||||||
|
/// Optional background color for right panel (defaults to scaffold background)
|
||||||
|
final Color? rightBackground;
|
||||||
|
|
||||||
|
const LandscapeShell({
|
||||||
|
Key? key,
|
||||||
|
required this.leftPanel,
|
||||||
|
required this.rightPanel,
|
||||||
|
this.leftFlex = 2,
|
||||||
|
this.rightFlex = 3,
|
||||||
|
this.rightBackground,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bg = rightBackground ?? Theme.of(context).scaffoldBackgroundColor;
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// ── Left panel — dark blue gradient ──────────────────────────────
|
||||||
|
Flexible(
|
||||||
|
flex: leftFlex,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
decoration: AppDecoration.blueGradient,
|
||||||
|
child: leftPanel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Right panel — content area ────────────────────────────────────
|
||||||
|
Flexible(
|
||||||
|
flex: rightFlex,
|
||||||
|
child: RepaintBoundary(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: bg,
|
||||||
|
child: rightPanel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
lib/widgets/responsive_shell.dart
Normal file
84
lib/widgets/responsive_shell.dart
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../core/constants.dart';
|
||||||
|
import 'desktop_sidebar.dart';
|
||||||
|
import 'desktop_topbar.dart';
|
||||||
|
|
||||||
|
class ResponsiveShell extends StatefulWidget {
|
||||||
|
final int currentIndex;
|
||||||
|
final ValueChanged<int> onIndexChanged;
|
||||||
|
final Widget child;
|
||||||
|
final bool showTopBar;
|
||||||
|
|
||||||
|
const ResponsiveShell({
|
||||||
|
Key? key,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.onIndexChanged,
|
||||||
|
required this.child,
|
||||||
|
this.showTopBar = true,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ResponsiveShell> createState() => _ResponsiveShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResponsiveShellState extends State<ResponsiveShell> {
|
||||||
|
String _username = 'Guest';
|
||||||
|
String? _profileImage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_username = prefs.getString('display_name') ??
|
||||||
|
prefs.getString('username') ??
|
||||||
|
'Guest';
|
||||||
|
_profileImage = prefs.getString('profileImage');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
|
||||||
|
// Mobile — no shell
|
||||||
|
if (width < AppConstants.desktopBreakpoint) {
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
DesktopSidebar(
|
||||||
|
selectedIndex: widget.currentIndex,
|
||||||
|
onIndexChanged: widget.onIndexChanged,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (widget.showTopBar)
|
||||||
|
DesktopTopBar(
|
||||||
|
username: _username,
|
||||||
|
profileImage: _profileImage,
|
||||||
|
onAvatarTap: () => widget.onIndexChanged(2),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RepaintBoundary(child: widget.child),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import geolocator_apple
|
|||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import video_player_avfoundation
|
import video_player_avfoundation
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
112
pubspec.lock
112
pubspec.lock
@@ -41,6 +41,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
cached_network_image:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cached_network_image
|
||||||
|
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
|
cached_network_image_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_platform_interface
|
||||||
|
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
cached_network_image_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cached_network_image_web
|
||||||
|
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.1"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -182,6 +206,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_cache_manager:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_cache_manager
|
||||||
|
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.1"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -520,6 +552,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.6"
|
version: "1.0.6"
|
||||||
|
nested:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nested
|
||||||
|
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
octo_image:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: octo_image
|
||||||
|
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -616,6 +664,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.3"
|
version: "6.0.3"
|
||||||
|
provider:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: provider
|
||||||
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5+1"
|
||||||
|
rxdart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: rxdart
|
||||||
|
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.28.0"
|
||||||
sanitize_html:
|
sanitize_html:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -717,6 +781,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
|
sqflite:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite
|
||||||
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_android
|
||||||
|
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2+3"
|
||||||
|
sqflite_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_common
|
||||||
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
sqflite_darwin:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_darwin
|
||||||
|
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
sqflite_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sqflite_platform_interface
|
||||||
|
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.0"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -749,6 +853,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
|
synchronized:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: synchronized
|
||||||
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.4.0"
|
||||||
table_calendar:
|
table_calendar:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: figma
|
name: figma
|
||||||
description: A Flutter event app
|
description: A Flutter event app
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.6.1+17
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
@@ -19,7 +19,9 @@ dependencies:
|
|||||||
google_maps_flutter: ^2.5.0
|
google_maps_flutter: ^2.5.0
|
||||||
url_launcher: ^6.2.1
|
url_launcher: ^6.2.1
|
||||||
share_plus: ^7.2.1
|
share_plus: ^7.2.1
|
||||||
|
provider: ^6.1.2
|
||||||
video_player: ^2.8.1
|
video_player: ^2.8.1
|
||||||
|
cached_network_image: ^3.3.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -32,6 +34,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/icon/hand_stop.svg
|
- assets/icon/hand_stop.svg
|
||||||
|
- assets/login-bg.mp4
|
||||||
fonts:
|
fonts:
|
||||||
- family: Gilroy
|
- family: Gilroy
|
||||||
fonts:
|
fonts:
|
||||||
|
|||||||
3
run_web.sh
Executable file
3
run_web.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /Users/bshtechnologies/Documents/Eventify-frontend
|
||||||
|
exec flutter run -d web-server --web-hostname 0.0.0.0 --web-port "${PORT:-8080}"
|
||||||
Reference in New Issue
Block a user