release: bump version to 1.4(p) (versionCode 14)
- Update versionCode 12 → 14, versionName 1.3(p) → 1.4(p) - Update pubspec.yaml version to 1.4.0+14 - Add CHANGELOG.md with full version history - Update README.md: version badge + changelog section - Desktop Contribute Dashboard rebuilt to match web version - Contributor Dashboard title, 3-tab nav (Contribute/Leaderboard/Achievements) - Two-column submit form, tier milestone progress bar - Desktop leaderboard with podium, filters, rank table (green points) - Desktop achievements 3-column badge grid - Inline Reward Shop with RP balance - Gamification feature module (EP, RP, leaderboard, achievements, shop) - Profile screen redesigned to match web app layout with animations - Home screen bottom sheet date filter chips - Updated API endpoints, login/event detail screens, theme colors - Added Gilroy font suite, responsive layout improvements
This commit is contained in:
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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user