...
This commit is contained in:
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
backgroundColor: const Color(0xFFF9FAFB), // Softer light bg
|
||||
body: Consumer<GamificationProvider>(
|
||||
builder: (context, gam, _) {
|
||||
return 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),
|
||||
child: _buildModernHeader(context, theme),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildModernStatCards(gam, theme),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildTierProgressCard(gam, theme),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildContributedEventsSection(gam, theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user