feat: Phase 3 — 26 medium-priority gaps implemented

P3-A/K  Profile: Eventify ID glassmorphic badge (tap-to-copy), DiceBear
        Notionists avatar via TierAvatarRing, district picker (14 pills)
        with 183-day cooldown lock, multipart photo upload to server
P3-B    Home: Top Events converted to PageView scroll-snap
        (viewportFraction 0.9 + PageScrollPhysics)
P3-C    Event detail: contributor widget (tier ring + name + navigation),
        related events horizontal row; added getEventsByCategory() to
        EventsService; added contributorId/Name/Tier fields to EventModel
P3-D    Kerala pincodes: 463-entry JSON (all 14 districts), registered as
        asset, async-loaded in SearchScreen replacing hardcoded 32 cities
P3-E    Checkout: promo code field + Apply/Remove button in Step 2,
        discountAmount subtracted from total, applyPromo()/resetPromo()
        methods in CheckoutProvider
P3-F/G  Gamification: reward cycle countdown + EP→RP progress bar (blue→
        amber) in contribute + profile screens; TierAvatarRing in podium
        and all leaderboard rows; GlassCard current-user stats card at
        top of leaderboard tab
P3-H    New ContributorProfileScreen: tier ring, stats, submission grid
        with status chips; getDashboardForUser() in GamificationService;
        wired from leaderboard row taps
P3-I    Achievements: 11 default badges (up from 6), 6 new icon map
        entries; progress % labels already confirmed present
P3-J    Reviews: CustomPainter circular arc rating ring (amber, 84px)
        replaces large rating number in ReviewSummary
P3-L    Share rank card: RepaintBoundary → PNG capture → Share.shareXFiles;
        share button wired in profile header and leaderboard tab
P3-M    SafeArea audit: home bottom nav, contribute/achievements scroll
        padding, profile CustomScrollView top inset

New files: tier_avatar_ring.dart, glass_card.dart,
  eventify_bottom_sheet.dart, contributor_profile_screen.dart,
  share_rank_card.dart, assets/data/kerala_pincodes.json
New dep:   path_provider ^2.1.0
This commit is contained in:
2026-04-04 17:17:36 +05:30
parent e365361451
commit e9752c3d61
19 changed files with 2346 additions and 183 deletions

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import '../core/utils/error_utils.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -13,11 +16,14 @@ import '../features/events/models/event_models.dart';
import '../features/gamification/providers/gamification_provider.dart';
import '../features/gamification/models/gamification_models.dart';
import '../widgets/skeleton_loader.dart';
import '../widgets/tier_avatar_ring.dart';
import 'learn_more_screen.dart';
import 'settings_screen.dart';
import '../core/api/api_endpoints.dart';
import '../core/app_decoration.dart';
import '../core/constants.dart';
import '../widgets/landscape_section_header.dart';
import '../features/share/share_rank_card.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({Key? key}) : super(key: key);
@@ -31,10 +37,35 @@ class _ProfileScreenState extends State<ProfileScreen>
String _username = '';
String _email = 'not provided';
String _profileImage = '';
String? _eventifyId;
String? _userTier;
String? _district;
DateTime? _districtChangedAt;
final ImagePicker _picker = ImagePicker();
// 14 Kerala districts
static const List<String> _districts = [
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
];
final EventsService _eventsService = EventsService();
// AUTH-005: District change cooldown (183-day lock)
bool get _districtLocked {
if (_districtChangedAt == null) return false;
return DateTime.now().difference(_districtChangedAt!) < const Duration(days: 183);
}
String get _districtNextChange {
if (_districtChangedAt == null) return '';
final next = _districtChangedAt!.add(const Duration(days: 183));
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return '${next.day} ${months[next.month - 1]} ${next.year}';
}
List<EventModel> _ongoingEvents = [];
List<EventModel> _upcomingEvents = [];
List<EventModel> _pastEvents = [];
@@ -149,6 +180,21 @@ class _ProfileScreenState extends State<ProfileScreen>
_profileImage =
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
// AUTH-003/PROF-001: Eventify ID
_eventifyId = prefs.getString('eventify_id');
// PROF-004 partial: tier for avatar ring
_userTier = prefs.getString('user_tier') ?? prefs.getString('level');
// PROF-002: District
_district = prefs.getString('district');
// AUTH-005: District change cooldown
final districtChangedStr = prefs.getString('district_changed_at');
if (districtChangedStr != null) {
_districtChangedAt = DateTime.tryParse(districtChangedStr);
}
await _loadEventsForProfile(prefs);
if (mounted) setState(() {});
}
@@ -266,6 +312,8 @@ class _ProfileScreenState extends State<ProfileScreen>
}
final String path = xfile.path;
await _saveProfile(_username, _email, path);
// PROF-004: Upload to server on mobile
await _uploadProfilePhoto(path);
} catch (e) {
debugPrint('Image pick error: $e');
ScaffoldMessenger.of(context)
@@ -273,6 +321,77 @@ class _ProfileScreenState extends State<ProfileScreen>
}
}
// PROF-004: Upload profile photo to server
Future<void> _uploadProfilePhoto(String filePath) async {
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
if (token.isEmpty) return;
final request = http.MultipartRequest(
'PATCH',
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
);
request.headers['Authorization'] = 'Bearer $token';
request.files.add(await http.MultipartFile.fromPath('profile_picture', filePath));
final response = await request.send();
if (response.statusCode == 200) {
final body = await response.stream.bytesToString();
final data = jsonDecode(body) as Map<String, dynamic>;
if (data['profile_picture'] != null) {
final newUrl = data['profile_picture'].toString();
final prefs2 = await SharedPreferences.getInstance();
final currentEmail = prefs2.getString('current_email') ?? prefs2.getString('email') ?? '';
final profileImageKey = currentEmail.isNotEmpty ? 'profileImage_$currentEmail' : 'profileImage';
await prefs2.setString(profileImageKey, newUrl);
if (mounted) setState(() => _profileImage = newUrl);
}
}
} catch (e) {
debugPrint('Photo upload error: $e');
}
}
// PROF-002: Update district via API with cooldown check
Future<void> _updateDistrict(String district) async {
if (_districtLocked) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('District locked until $_districtNextChange')),
);
}
return;
}
try {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString('access_token') ?? '';
final response = await http.patch(
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({'district': district}),
);
if (response.statusCode == 200) {
final now = DateTime.now();
await prefs.setString('district', district);
await prefs.setString('district_changed_at', now.toIso8601String());
if (mounted) {
setState(() {
_district = district;
_districtChangedAt = now;
});
}
}
} catch (e) {
debugPrint('District update error: $e');
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
}
}
}
Future<void> _enterAssetPathDialog() async {
final ctl = TextEditingController(text: _profileImage);
final result = await showDialog<String?>(
@@ -420,6 +539,87 @@ class _ProfileScreenState extends State<ProfileScreen>
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(height: 20),
// PROF-002: District picker
Align(
alignment: Alignment.centerLeft,
child: Text(
'District',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 8),
// AUTH-005: Cooldown lock indicator
if (_districtLocked)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
const Icon(Icons.lock_outline,
size: 14, color: Colors.amber),
const SizedBox(width: 4),
Text(
'District locked until $_districtNextChange',
style: const TextStyle(
fontSize: 12, color: Colors.amber),
),
],
),
),
// District pill grid
StatefulBuilder(
builder: (ctx2, setInner) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: _districts.map((d) {
final isSelected = _district == d;
return GestureDetector(
onTap: _districtLocked
? null
: () async {
setInner(() {});
await _updateDistrict(d);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: theme.cardColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: theme.dividerColor,
),
),
child: Text(
d,
style: TextStyle(
fontSize: 12,
color: isSelected
? Colors.white
: theme.textTheme.bodyMedium?.color,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
),
);
}).toList(),
);
},
),
const SizedBox(height: 8),
],
),
),
@@ -433,54 +633,22 @@ class _ProfileScreenState extends State<ProfileScreen>
});
}
// ───────── Avatar builder (reused, with size param) ─────────
// ───────── Avatar builder (AUTH-006 / PROF-004: DiceBear via TierAvatarRing) ─────────
Widget _buildProfileAvatar({double size = 96}) {
final path = _profileImage.trim();
// Resolve a network-compatible URL: http URLs pass through directly,
// file paths and assets fall back to null so DiceBear is used.
String? imageUrl;
if (path.startsWith('http')) {
return ClipOval(
child: CachedNetworkImage(
imageUrl: path,
memCacheWidth: (size * 2).toInt(),
memCacheHeight: (size * 2).toInt(),
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (_, __) =>
Container(width: size, height: size, color: const Color(0xFFE5E7EB)),
errorWidget: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
imageUrl = path;
}
if (kIsWeb) {
return ClipOval(
child: Image.asset(
path.isNotEmpty ? path : 'assets/images/profile.jpg',
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
}
if (path.isNotEmpty &&
(path.startsWith('/') || path.contains(Platform.pathSeparator))) {
final file = File(path);
if (file.existsSync()) {
return ClipOval(
child: Image.file(file,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
}
}
return ClipOval(
child: Image.asset('assets/images/profile.jpg',
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: size / 2, color: Colors.grey)));
return TierAvatarRing(
username: _username.isNotEmpty ? _username : _email,
tier: _userTier ?? '',
size: size,
imageUrl: imageUrl,
);
}
// ───────── Event list tile (updated styling) ─────────
@@ -636,7 +804,34 @@ class _ProfileScreenState extends State<ProfileScreen>
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 40), // balance
// Share rank card button
Consumer<GamificationProvider>(
builder: (context, gam, _) => GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
child: ShareRankCard(
username: _username,
tier: gam.currentUserStats?.level ?? _userTier ?? '',
rank: gam.currentUserStats?.rank ?? 0,
ep: gam.profile?.currentEp ?? 0,
rewardPoints: gam.profile?.currentRp ?? 0,
),
),
);
},
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(10)),
child: const Icon(Icons.share_outlined, color: Colors.white),
),
),
),
const Spacer(),
Text(
'Profile',
@@ -927,6 +1122,51 @@ class _ProfileScreenState extends State<ProfileScreen>
),
),
),
const SizedBox(height: 6),
// AUTH-003 / PROF-001: Eventify ID badge
if (_eventifyId != null && _eventifyId!.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: _eventifyId!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Eventify ID copied'),
duration: Duration(seconds: 2),
),
);
},
child: Container(
margin: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF1E3A8A).withOpacity(0.3),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFF3B82F6).withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.badge_outlined,
size: 12, color: Color(0xFF93C5FD)),
const SizedBox(width: 4),
Text(
_eventifyId!,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF93C5FD),
fontFamily: 'monospace',
letterSpacing: 0.5,
),
),
],
),
),
),
),
const SizedBox(height: 8),
// Email
@@ -1581,7 +1821,10 @@ class _ProfileScreenState extends State<ProfileScreen>
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
// CustomScrollView: only visible event cards are built — no full-tree Column renders
body: CustomScrollView(
// 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)
@@ -1667,6 +1910,7 @@ class _ProfileScreenState extends State<ProfileScreen>
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
),
);
}
@@ -1688,7 +1932,56 @@ class _ProfileScreenState extends State<ProfileScreen>
children: [
_gamStatCard('Lifetime EP', '${p.lifetimeEp}', Icons.star, const Color(0xFFF59E0B), theme),
const SizedBox(width: 10),
_gamStatCard('Liquid EP', '${p.currentEp}', Icons.bolt, const Color(0xFF3B82F6), theme),
// GAM-003 + GAM-004: Liquid EP with cycle countdown and progress bar
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: const Color(0xFF3B82F6).withOpacity(0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.2)),
),
child: Column(
children: [
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 22),
const SizedBox(height: 4),
Text(
'${p.currentEp}',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: theme.textTheme.bodyLarge?.color),
),
const SizedBox(height: 2),
Text('Liquid EP', style: TextStyle(color: theme.hintColor, fontSize: 10), textAlign: TextAlign.center),
if (gp.currentUserStats?.rewardCycleDays != null) ...[
const SizedBox(height: 4),
Text(
'Converts in ${gp.currentUserStats!.rewardCycleDays}d',
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Builder(
builder: (_) {
final days = gp.currentUserStats?.rewardCycleDays ?? 30;
final elapsed = (30 - days).clamp(0, 30);
final ratio = elapsed / 30;
return LinearProgressIndicator(
value: ratio,
minHeight: 4,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation<Color>(
ratio > 0.7 ? const Color(0xFFFBBF24) : const Color(0xFF3B82F6),
),
);
},
),
),
],
],
),
),
),
const SizedBox(width: 10),
_gamStatCard('Reward Pts', '${p.currentRp}', Icons.redeem, const Color(0xFF10B981), theme),
],