This commit is contained in:
Rishad7594
2026-04-08 08:00:33 +05:30
parent 4c57391bbd
commit d921ac2b78
4 changed files with 650 additions and 198 deletions

View File

@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
import '../../../core/utils/error_utils.dart';
import '../models/gamification_models.dart';
import '../services/gamification_service.dart';
import '../../events/services/events_service.dart';
import '../../events/models/event_models.dart';
class GamificationProvider extends ChangeNotifier {
final GamificationService _service = GamificationService();
@@ -16,6 +18,7 @@ class GamificationProvider extends ChangeNotifier {
List<SubmissionModel> submissions = [];
CurrentUserStats? currentUserStats;
int totalParticipants = 0;
List<String> eventCategories = [];
// Leaderboard filters — matches web version
String leaderboardDistrict = 'Overall Kerala';
@@ -44,26 +47,53 @@ class GamificationProvider extends ChangeNotifier {
try {
final results = await Future.wait([
_service.getDashboard(),
_service.getShopItems(),
_service.getAchievements(),
_service.getDashboard().catchError((e) {
debugPrint('Dashboard error: $e');
return const DashboardResponse(profile: UserGamificationProfile(userId: '', lifetimeEp: 0, currentEp: 0, currentRp: 0, tier: ContributorTier.BRONZE));
}),
_service.getLeaderboard(district: leaderboardDistrict, timePeriod: leaderboardTimePeriod).catchError((e) {
debugPrint('Leaderboard error: $e');
return const LeaderboardResponse(entries: []);
}),
_service.getShopItems().catchError((e) {
debugPrint('Shop error: $e');
return <ShopItem>[];
}),
_service.getAchievements().catchError((e) {
debugPrint('Achievements error: $e');
return <AchievementBadge>[];
}),
EventsService().getEventTypes().catchError((e) {
debugPrint('EventTypes error: $e');
return <EventTypeModel>[];
}),
]);
final dashboard = results[0] as DashboardResponse;
profile = dashboard.profile;
submissions = dashboard.submissions;
shopItems = results[1] as List<ShopItem>;
final lbResponse = results[1] as LeaderboardResponse;
leaderboard = _filterAndReRank(lbResponse.entries, leaderboardDistrict, leaderboardTimePeriod);
currentUserStats = lbResponse.currentUser;
totalParticipants = lbResponse.totalParticipants;
shopItems = results[2] as List<ShopItem>;
// Prefer achievements from dashboard API; fall back to fetched or existing defaults
final dashAchievements = dashboard.achievements;
final fetchedAchievements = results[2] as List<AchievementBadge>;
final fetchedAchievements = results[3] as List<AchievementBadge>;
if (dashAchievements.isNotEmpty) {
achievements = dashAchievements;
} else if (fetchedAchievements.isNotEmpty) {
achievements = fetchedAchievements;
}
final eventTypes = results[4] as List<EventTypeModel>;
if (eventTypes.isNotEmpty) {
eventCategories = eventTypes.map((e) => e.name).toList();
}
// Otherwise, keep current defaults
_lastLoadTime = DateTime.now();
@@ -107,7 +137,7 @@ class GamificationProvider extends ChangeNotifier {
notifyListeners();
try {
final response = await _service.getLeaderboard(district: district, timePeriod: leaderboardTimePeriod);
leaderboard = response.entries;
leaderboard = _filterAndReRank(response.entries, district, leaderboardTimePeriod);
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
@@ -128,7 +158,7 @@ class GamificationProvider extends ChangeNotifier {
notifyListeners();
try {
final response = await _service.getLeaderboard(district: leaderboardDistrict, timePeriod: period);
leaderboard = response.entries;
leaderboard = _filterAndReRank(response.entries, leaderboardDistrict, period);
currentUserStats = response.currentUser;
totalParticipants = response.totalParticipants;
} catch (e) {
@@ -182,4 +212,41 @@ class GamificationProvider extends ChangeNotifier {
Future<void> submitContribution(Map<String, dynamic> data) async {
await _service.submitContribution(data);
}
// ---------------------------------------------------------------------------
// Helper: Filter by district and re-rank results locally.
// This is a fallback in case the backend returns a global list for a district-specific query.
// ---------------------------------------------------------------------------
List<LeaderboardEntry> _filterAndReRank(List<LeaderboardEntry> entries, String district, String period) {
if (entries.isEmpty) return [];
List<LeaderboardEntry> result = entries;
if (district != 'Overall Kerala') {
// Case-insensitive filtering to be robust
result = entries.where((e) => e.district?.toLowerCase() == district.toLowerCase()).toList();
}
// Sort based on period
if (period == 'this_month') {
result.sort((a, b) => b.monthlyPoints.compareTo(a.monthlyPoints));
} else {
result.sort((a, b) => b.lifetimeEp.compareTo(a.lifetimeEp));
}
// Assign new ranks based on local sort order
return List.generate(result.length, (i) {
final e = result[i];
return LeaderboardEntry(
rank: i + 1,
username: e.username,
avatarUrl: e.avatarUrl,
lifetimeEp: e.lifetimeEp,
monthlyPoints: e.monthlyPoints,
tier: e.tier,
eventsCount: e.eventsCount,
isCurrentUser: e.isCurrentUser,
district: e.district,
);
});
}
}

View File

@@ -44,7 +44,7 @@ const _districts = [
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod', 'Other',
];
const _categories = [
const _categories_fallback = [
'Music', 'Food', 'Arts', 'Sports', 'Tech', 'Community',
'Dance', 'Film', 'Business', 'Health', 'Education', 'Other',
];
@@ -88,7 +88,7 @@ class _ContributeScreenState extends State<ContributeScreen>
DateTime? _selectedDate;
TimeOfDay? _selectedTime;
String _selectedCategory = _categories.first;
String _selectedCategory = 'Music';
String _selectedDistrict = _districts.first;
List<XFile> _images = [];
bool _submitting = false;
@@ -135,6 +135,13 @@ class _ContributeScreenState extends State<ContributeScreen>
body: Center(child: BouncingLoader(color: Colors.white)),
);
}
// Sync _selectedCategory with provider data if it's missing from current list
if (provider.eventCategories.isNotEmpty && !provider.eventCategories.contains(_selectedCategory)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _selectedCategory = provider.eventCategories.first);
});
}
return Scaffold(
backgroundColor: Colors.white, // Changed from _blue
body: SafeArea(
@@ -287,9 +294,36 @@ class _ContributeScreenState extends State<ContributeScreen>
child: Center(
child: Column(
children: [
Icon(Icons.emoji_events_outlined, size: 48, color: Colors.grey.shade300),
const SizedBox(height: 12),
Text('No rankings available for this area.', style: TextStyle(color: Colors.grey.shade400)),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.amber.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.emoji_events_rounded, size: 72, color: Colors.amber),
),
const SizedBox(height: 24),
const Text(
'No Contributor Yet',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: _darkText,
),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
'No contributors in $currentDistrict yet. Be the first to join the ranks!',
textAlign: TextAlign.center,
style: const TextStyle(
color: _subText,
fontSize: 15,
height: 1.5,
),
),
),
],
),
),
@@ -1181,7 +1215,13 @@ class _ContributeScreenState extends State<ContributeScreen>
children: [
_inputLabel('Category', required: true),
const SizedBox(height: 6),
_dropdown(_selectedCategory, _categories, (v) => setState(() => _selectedCategory = v!)),
if (provider.eventCategories.isEmpty)
const SizedBox(
height: 48,
child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))),
)
else
_dropdown(_selectedCategory, provider.eventCategories, (v) => setState(() => _selectedCategory = v!)),
],
),
),
@@ -1799,7 +1839,7 @@ class _ContributeScreenState extends State<ContributeScreen>
_mapsLinkCtl.clear();
_selectedDate = null;
_selectedTime = null;
_selectedCategory = _categories.first;
_selectedCategory = _categories_fallback.first;
_selectedDistrict = _districts.first;
_images.clear();
_coordMessage = null;

View File

@@ -225,7 +225,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> with SingleTickerProv
Future<void> _shareEvent() async {
final title = _event?.title ?? _event?.name ?? 'Check out this event';
final url =
'https://uat.eventifyplus.com/events/${widget.eventId}';
'https://app.eventifyplus.com/event/${widget.eventId}';
await Share.share('$title\n$url', subject: title);
}

View File

@@ -68,8 +68,6 @@ class _ProfileScreenState extends State<ProfileScreen>
}
List<EventModel> _ongoingEvents = [];
List<EventModel> _upcomingEvents = [];
List<EventModel> _pastEvents = [];
bool _loadingEvents = true;
@@ -202,12 +200,7 @@ class _ProfileScreenState extends State<ProfileScreen>
}
Future<void> _loadEventsForProfile([SharedPreferences? prefs]) async {
setState(() {
_loadingEvents = true;
_ongoingEvents = [];
_upcomingEvents = [];
_pastEvents = [];
});
prefs ??= await SharedPreferences.getInstance();
final pincode = prefs.getString('pincode') ?? 'all';
@@ -218,8 +211,6 @@ class _ProfileScreenState extends State<ProfileScreen>
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final ongoing = <EventModel>[];
final upcoming = <EventModel>[];
final past = <EventModel>[];
DateTime? tryParseDate(String? s) {
if (s == null) return null;
@@ -235,35 +226,21 @@ class _ProfileScreenState extends State<ProfileScreen>
final parsedEnd = tryParseDate(e.endDate);
if (parsedStart == null) {
upcoming.add(e);
// treat as ongoing or handle differently
} else if (parsedStart.isAtSameMomentAs(today) ||
(parsedStart.isBefore(today) &&
parsedEnd != null &&
!parsedEnd.isBefore(today))) {
ongoing.add(e);
} else if (parsedStart.isBefore(today)) {
past.add(e);
} else {
upcoming.add(e);
// ignore past
}
}
upcoming.sort((a, b) {
final da = tryParseDate(a.startDate) ?? DateTime(9999);
final db = tryParseDate(b.startDate) ?? DateTime(9999);
return da.compareTo(db);
});
past.sort((a, b) {
final da = tryParseDate(a.startDate) ?? DateTime(0);
final db = tryParseDate(b.startDate) ?? DateTime(0);
return db.compareTo(da);
});
if (mounted) {
setState(() {
_ongoingEvents = ongoing;
_upcomingEvents = upcoming;
_pastEvents = past;
});
}
} catch (e) {
@@ -787,8 +764,516 @@ class _ProfileScreenState extends State<ProfileScreen>
// NEW UI WIDGETS — matching web profile layout
// ═══════════════════════════════════════════════
// ═══════════════════════════════════════════════
// MODERN UI WIDGETS
// ═══════════════════════════════════════════════
Widget _buildModernHeader(BuildContext context, ThemeData theme) {
return Consumer<GamificationProvider>(
builder: (context, gam, _) {
final stats = gam.currentUserStats;
final p = gam.profile;
final tier = stats?.level ?? _userTier ?? 'Bronze';
return Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 30),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF2563EB), Color(0xFF3B82F6)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(40),
bottomRight: Radius.circular(40),
),
),
child: Column(
children: [
// Top Bar
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
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),
),
),
],
),
),
),
// Profile Avatar
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withOpacity(0.5), width: 2),
),
child: _buildProfileAvatar(size: 100),
),
const SizedBox(height: 16),
// Username
Text(
_username,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 12),
// Eventify ID Badge
if (_eventifyId != null)
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: _eventifyId!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ID copied to clipboard')),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.copy, size: 14, color: Colors.white70),
const SizedBox(width: 8),
Text(
_eventifyId!,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
fontFamily: 'monospace',
),
),
],
),
),
),
const SizedBox(height: 16),
// Location (District)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
const SizedBox(width: 4),
Text(
_district ?? 'No district selected',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
),
],
),
if (_districtNextChange.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Next change: $_districtNextChange',
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 12),
),
),
const SizedBox(height: 20),
// Tier Badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFD1A9), // Matching "Bronze" color from screenshot
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shield_outlined, size: 18, color: Color(0xFF92400E)),
const SizedBox(width: 8),
Text(
tier.toUpperCase(),
style: const TextStyle(
color: Color(0xFF92400E),
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
],
),
),
const SizedBox(height: 24),
// Buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
_buildHeaderButton(
label: 'Edit Profile',
icon: Icons.person_outline,
onTap: _openEditDialog,
),
const SizedBox(height: 12),
_buildHeaderButton(
label: 'Share Rank',
icon: Icons.share_outlined,
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: ShareRankCard(
username: _username,
tier: stats?.level ?? _userTier ?? '',
rank: stats?.rank ?? 0,
ep: p?.currentEp ?? 0,
rewardPoints: p?.currentRp ?? 0,
),
),
);
},
),
],
),
),
],
),
);
},
);
}
Widget _buildHeaderButton({required String label, required IconData icon, required VoidCallback onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withOpacity(0.3)),
color: Colors.white.withValues(alpha: 0.05),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600),
),
],
),
),
);
}
Widget _buildModernStatCards(GamificationProvider gam, ThemeData theme) {
final p = gam.profile;
if (p == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
child: Column(
children: [
_buildStatRowCard('Lifetime EP', '${p.lifetimeEp}', Icons.bolt, const Color(0xFFEFF6FF), const Color(0xFF3B82F6)),
const SizedBox(height: 12),
_buildStatRowCard('Liquid EP', '${p.currentEp}', Icons.hexagon_outlined, const Color(0xFFF0FDF4), const Color(0xFF22C55E)),
const SizedBox(height: 12),
_buildStatRowCard('Reward Points', '${p.currentRp}', Icons.card_giftcard, const Color(0xFFFFFBEB), const Color(0xFFF59E0B)),
],
),
);
}
Widget _buildStatRowCard(String label, String value, IconData icon, Color bgColor, Color iconColor) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade100),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: Color(0xFF1F2937)),
),
Text(
label,
style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w500),
),
],
),
],
),
);
}
Widget _buildTierProgressCard(GamificationProvider gam, ThemeData theme) {
final ep = gam.profile?.lifetimeEp ?? 0;
final currentTier = tierFromEp(ep);
final nextThreshold = nextTierThreshold(currentTier);
final startEp = tierStartEp(currentTier);
double progress = 0.0;
String nextInfo = '';
if (nextThreshold != null) {
final needed = nextThreshold - ep;
final range = nextThreshold - startEp;
progress = ((ep - startEp) / range).clamp(0.0, 1.0);
nextInfo = '$needed EP to ${tierLabel(ContributorTier.values[currentTier.index + 1]).toUpperCase()}';
} else {
progress = 1.0;
nextInfo = 'MAX TIER REACHED';
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 18),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.grey.shade100),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
tierLabel(currentTier).toUpperCase(),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Color(0xFF1F2937)),
),
Text(
nextInfo,
style: TextStyle(fontSize: 13, color: Colors.grey.shade500, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: LinearProgressIndicator(
value: progress,
minHeight: 10,
backgroundColor: const Color(0xFFF3F4F6),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF2563EB)),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: Text(
'$ep EP total',
style: TextStyle(fontSize: 13, color: Colors.grey.shade400, fontWeight: FontWeight.w500),
),
),
],
),
);
}
Widget _buildContributedEventsSection(GamificationProvider gam, ThemeData theme) {
final submissions = gam.submissions;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 32, 20, 16),
child: Row(
children: [
const Text(
'Contributed Events',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, color: Color(0xFF1F2937)),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${submissions.length}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Color(0xFF6B7280)),
),
),
],
),
),
if (submissions.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Text('No contributions yet. Start contributing to earn EP!'),
)
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 18),
itemCount: submissions.length,
itemBuilder: (ctx, i) => _buildSubmissionCard(submissions[i], theme),
),
const SizedBox(height: 100),
],
);
}
Widget _buildSubmissionCard(SubmissionModel sub, ThemeData theme) {
Color statusColor;
Color statusBgColor;
String statusLabel = sub.status.toUpperCase();
switch (sub.status.toUpperCase()) {
case 'APPROVED':
statusColor = const Color(0xFF059669);
statusBgColor = const Color(0xFFD1FAE5);
break;
case 'REJECTED':
statusColor = const Color(0xFFDC2626);
statusBgColor = const Color(0xFFFEE2E2);
break;
default: // PENDING
statusColor = const Color(0xFFD97706);
statusBgColor = const Color(0xFFFEF3C7);
statusLabel = 'PENDING';
}
final dateStr = '${sub.createdAt.day} ${_getMonth(sub.createdAt.month)} ${sub.createdAt.year}';
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.grey.shade100),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.01), blurRadius: 10, offset: const Offset(0, 4)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
sub.eventName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Color(0xFF1F2937)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: statusBgColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
children: [
Icon(Icons.access_time_filled, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
statusLabel,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: statusColor),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.calendar_today_outlined, size: 14, color: Colors.grey.shade400),
const SizedBox(width: 6),
Text(dateStr, style: TextStyle(color: Colors.grey.shade500, fontSize: 13)),
const SizedBox(width: 16),
Icon(Icons.location_on_outlined, size: 14, color: Colors.grey.shade400),
const SizedBox(width: 6),
Expanded(
child: Text(
sub.district ?? 'Unknown',
style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (sub.status.toUpperCase() == 'APPROVED' && sub.epAwarded > 0)
Text(
'+${sub.epAwarded} EP',
style: const TextStyle(color: Color(0xFF059669), fontWeight: FontWeight.w800, fontSize: 14),
),
],
),
if (sub.category.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF3F4F6),
borderRadius: BorderRadius.circular(8),
),
child: Text(
sub.category,
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280), fontWeight: FontWeight.w500),
),
),
],
],
),
);
}
String _getMonth(int m) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[m - 1];
}
// ───────── Gradient Header ─────────
Widget _buildGradientHeader(BuildContext context, double height) {
return Container(
width: double.infinity,
@@ -1224,43 +1709,6 @@ class _ProfileScreenState extends State<ProfileScreen>
_ongoingEvents.map((e) => _eventListTileFromModel(e)).toList()),
const SizedBox(height: 24),
],
// Upcoming Events
sectionHeading('Upcoming Events'),
const SizedBox(height: 12),
if (_loadingEvents)
const SizedBox.shrink()
else if (_upcomingEvents.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text('No upcoming events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
)
else
Column(
children: _upcomingEvents
.map((e) => _eventListTileFromModel(e))
.toList()),
const SizedBox(height: 24),
// Past Events
sectionHeading('Past Events'),
const SizedBox(height: 12),
if (_loadingEvents)
const SizedBox.shrink()
else if (_pastEvents.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text('No past events',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.hintColor)),
)
else
Column(
children: _pastEvents
.map((e) => _eventListTileFromModel(e, faded: true))
.toList()),
],
),
);
@@ -1438,7 +1886,7 @@ class _ProfileScreenState extends State<ProfileScreen>
return SafeArea(
child: DefaultTabController(
length: 3,
length: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -1461,8 +1909,6 @@ class _ProfileScreenState extends State<ProfileScreen>
labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
tabs: const [
Tab(text: 'Ongoing'),
Tab(text: 'Upcoming'),
Tab(text: 'Past'),
],
),
),
@@ -1471,8 +1917,6 @@ class _ProfileScreenState extends State<ProfileScreen>
child: TabBarView(
children: [
_eventList(_ongoingEvents),
_eventList(_upcomingEvents),
_eventList(_pastEvents, faded: true),
],
),
),
@@ -1515,24 +1959,6 @@ class _ProfileScreenState extends State<ProfileScreen>
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),
],
),
@@ -1801,19 +2227,8 @@ class _ProfileScreenState extends State<ProfileScreen>
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const double headerHeight = 200.0;
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);
@@ -1821,97 +2236,27 @@ class _ProfileScreenState extends State<ProfileScreen>
// ── MOBILE layout ─────────────────────────────────────────────────────
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
// CustomScrollView: only visible event cards are built — no full-tree Column renders
// SafeArea(bottom: false) — bottom is handled by home screen's floating nav buffer
body: SafeArea(
bottom: false,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
// Header gradient + Profile card overlap (same visual as before)
SliverToBoxAdapter(
child: Stack(
children: [
_buildGradientHeader(context, headerHeight),
Padding(
padding: EdgeInsets.only(top: cardTopOffset),
child: _buildProfileCard(context),
),
],
),
),
// PROF-003: Gamification stat cards
SliverToBoxAdapter(child: _buildGamificationCards(theme)),
// ── 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,
),
backgroundColor: const Color(0xFFF9FAFB), // Softer light bg
body: Consumer<GamificationProvider>(
builder: (context, gam, _) {
return CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _buildModernHeader(context, theme),
),
),
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)),
SliverToBoxAdapter(
child: _buildModernStatCards(gam, theme),
),
)
else
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 18),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(ctx, i) => _eventListTileFromModel(_upcomingEvents[i]),
childCount: _upcomingEvents.length,
),
SliverToBoxAdapter(
child: _buildTierProgressCard(gam, theme),
),
),
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)),
SliverToBoxAdapter(
child: _buildContributedEventsSection(gam, theme),
),
)
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)),
],
),
],
);
},
),
);
}