Three root causes of the perceived scroll/animation lag: 1. profile_screen.dart — AnimationController listener called setState() on every animation frame (60fps × 2s = 120 full-tree rebuilds). The entire ProfileScreen with its nested lists and images was rebuilding 60 times per second just to update two small widgets (EXP bar + stat counters). Fix: remove setState() from listeners entirely; wrap only the EXP bar (LayoutBuilder) and stat row (IntrinsicHeight) in AnimatedBuilder so only those two leaf widgets re-render per frame. 2. learn_more_screen.dart — PageView.onPageChanged called setState() on every swipe, rebuilding the full event detail screen (blurred bg image, map, about section, etc.) just to update the 6px dot indicators. Fix: int _currentPage → ValueNotifier<int> _pageNotifier; wrap only the dot row and the blurred background image in ValueListenableBuilder. 3. search_screen.dart — BackdropFilter(ImageFilter.blur) without a RepaintBoundary forces Flutter to read every pixel behind the blur widget and composite it every frame. When the user scrolls the underlying list, the blur repaints continuously causing frame drops. Fix: wrap BackdropFilter in RepaintBoundary to isolate its repaint layer.
1048 lines
34 KiB
Dart
1048 lines
34 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../features/events/services/events_service.dart';
|
|
import '../features/events/models/event_models.dart';
|
|
import 'learn_more_screen.dart';
|
|
import 'settings_screen.dart';
|
|
import '../core/app_decoration.dart';
|
|
import '../core/constants.dart';
|
|
|
|
class ProfileScreen extends StatefulWidget {
|
|
const ProfileScreen({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
|
}
|
|
|
|
class _ProfileScreenState extends State<ProfileScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
String _username = '';
|
|
String _email = 'not provided';
|
|
String _profileImage = '';
|
|
final ImagePicker _picker = ImagePicker();
|
|
|
|
final EventsService _eventsService = EventsService();
|
|
|
|
List<EventModel> _ongoingEvents = [];
|
|
List<EventModel> _upcomingEvents = [];
|
|
List<EventModel> _pastEvents = [];
|
|
|
|
bool _loadingEvents = true;
|
|
|
|
// Gradient used for EXP bar and rainbow bar (6-color rainbow)
|
|
static const LinearGradient _expGradient = LinearGradient(
|
|
colors: [
|
|
Color(0xFFA855F7), // purple-500
|
|
Color(0xFFEC4899), // pink-500
|
|
Color(0xFFF97316), // orange-500
|
|
Color(0xFFEAB308), // yellow-500
|
|
Color(0xFF22C55E), // green-500
|
|
Color(0xFF3B82F6), // blue-500
|
|
],
|
|
);
|
|
|
|
// Animation state
|
|
late AnimationController _animController;
|
|
double _expProgress = 0.0;
|
|
int _animatedLikes = 0;
|
|
int _animatedPosts = 0;
|
|
int _animatedViews = 0;
|
|
|
|
// Target stat values (matching web: 1.2K, 45, 3.4K)
|
|
static const int _targetLikes = 1200;
|
|
static const int _targetPosts = 45;
|
|
static const int _targetViews = 3400;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Animation controller for EXP bar + stat counters
|
|
_animController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 2000),
|
|
);
|
|
|
|
_loadProfile();
|
|
_startAnimations();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startAnimations() {
|
|
// Delay to match React's setTimeout(500ms)
|
|
Future.delayed(const Duration(milliseconds: 500), () {
|
|
if (!mounted) return;
|
|
|
|
// Animate EXP bar: 0 → 0.65 with ease-out over 1.3s
|
|
final expTween = Tween<double>(begin: 0.0, end: 0.65);
|
|
final expAnim = CurvedAnimation(
|
|
parent: _animController,
|
|
curve: const Interval(0.0, 0.65, curve: Curves.easeOut),
|
|
);
|
|
// Update fields without setState — AnimatedBuilder handles the rebuilds
|
|
expAnim.addListener(() {
|
|
_expProgress = expTween.evaluate(expAnim);
|
|
});
|
|
|
|
_animController.addListener(() {
|
|
final t = _animController.value;
|
|
_animatedLikes = (t * _targetLikes).round();
|
|
_animatedPosts = (t * _targetPosts).round();
|
|
_animatedViews = (t * _targetViews).round();
|
|
});
|
|
|
|
_animController.forward();
|
|
});
|
|
}
|
|
|
|
/// Format large numbers: ≥1M → "X.XM", ≥1K → "X.XK", else raw
|
|
String _formatNumber(int n) {
|
|
if (n >= 1000000) {
|
|
final val = n / 1000000;
|
|
return '${val.toStringAsFixed(1)}M';
|
|
} else if (n >= 1000) {
|
|
final val = n / 1000;
|
|
return '${val.toStringAsFixed(1)}K';
|
|
}
|
|
return n.toString();
|
|
}
|
|
|
|
// ───────── Data Loading (unchanged) ─────────
|
|
|
|
Future<void> _loadProfile() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final currentEmail =
|
|
prefs.getString('current_email') ?? prefs.getString('email') ?? '';
|
|
|
|
_email = currentEmail.isNotEmpty ? currentEmail : 'not provided';
|
|
|
|
final displayKey =
|
|
currentEmail.isNotEmpty ? 'display_name_$currentEmail' : 'display_name';
|
|
final profileImageKey =
|
|
currentEmail.isNotEmpty ? 'profileImage_$currentEmail' : 'profileImage';
|
|
|
|
_username = prefs.getString(displayKey) ??
|
|
prefs.getString('display_name') ??
|
|
_email;
|
|
_profileImage =
|
|
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
|
|
|
|
await _loadEventsForProfile(prefs);
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
Future<void> _loadEventsForProfile([SharedPreferences? prefs]) async {
|
|
setState(() {
|
|
_loadingEvents = true;
|
|
_ongoingEvents = [];
|
|
_upcomingEvents = [];
|
|
_pastEvents = [];
|
|
});
|
|
|
|
prefs ??= await SharedPreferences.getInstance();
|
|
final pincode = prefs.getString('pincode') ?? 'all';
|
|
|
|
try {
|
|
final events = await _eventsService.getEventsByPincode(pincode);
|
|
|
|
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;
|
|
try {
|
|
return DateTime.tryParse(s);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
for (final e in events) {
|
|
final parsedStart = tryParseDate(e.startDate);
|
|
final parsedEnd = tryParseDate(e.endDate);
|
|
|
|
if (parsedStart == null) {
|
|
upcoming.add(e);
|
|
} 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);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('Failed to load events: $e')));
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _loadingEvents = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveProfile(
|
|
String name, String email, String profileImage) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final currentEmail =
|
|
prefs.getString('current_email') ?? prefs.getString('email') ?? email;
|
|
|
|
final displayKey =
|
|
currentEmail.isNotEmpty ? 'display_name_$currentEmail' : 'display_name';
|
|
final profileImageKey =
|
|
currentEmail.isNotEmpty ? 'profileImage_$currentEmail' : 'profileImage';
|
|
|
|
await prefs.setString(displayKey, name);
|
|
await prefs.setString(profileImageKey, profileImage);
|
|
await prefs.setString('email', currentEmail);
|
|
await prefs.setString('current_email', currentEmail);
|
|
|
|
setState(() {
|
|
_username = name;
|
|
_email = currentEmail.isNotEmpty ? currentEmail : 'not provided';
|
|
_profileImage = profileImage;
|
|
});
|
|
}
|
|
|
|
// ───────── Image picking (unchanged) ─────────
|
|
|
|
Future<void> _pickFromGallery() async {
|
|
try {
|
|
final XFile? xfile =
|
|
await _picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
|
|
if (xfile == null) return;
|
|
if (kIsWeb) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
content:
|
|
Text('Use "Enter Asset Path" on web/desktop or add server URL.')));
|
|
return;
|
|
}
|
|
final String path = xfile.path;
|
|
await _saveProfile(_username, _email, path);
|
|
} catch (e) {
|
|
debugPrint('Image pick error: $e');
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text('Failed to pick image: $e')));
|
|
}
|
|
}
|
|
|
|
Future<void> _enterAssetPathDialog() async {
|
|
final ctl = TextEditingController(text: _profileImage);
|
|
final result = await showDialog<String?>(
|
|
context: context,
|
|
builder: (ctx) {
|
|
// Note: ctl is disposed after dialog closes below
|
|
final theme = Theme.of(ctx);
|
|
return AlertDialog(
|
|
title: const Text('Enter image path or URL'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: ctl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Asset path (e.g. assets/images/profile.jpg) or URL',
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Use an asset path (for bundled images) or an https:// URL (web).',
|
|
style:
|
|
theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(ctx).pop(null),
|
|
child: const Text('Cancel')),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(ctx).pop(ctl.text.trim()),
|
|
child: const Text('Use')),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
ctl.dispose();
|
|
if (result == null || result.isEmpty) return;
|
|
await _saveProfile(_username, _email, result);
|
|
}
|
|
|
|
Future<void> _openEditDialog() async {
|
|
final nameCtl = TextEditingController(text: _username);
|
|
final emailCtl = TextEditingController(text: _email);
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (ctx) {
|
|
// nameCtl and emailCtl are disposed via .then() below
|
|
final theme = Theme.of(ctx);
|
|
return DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.44,
|
|
minChildSize: 0.28,
|
|
maxChildSize: 0.92,
|
|
builder: (context, scrollController) {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 18),
|
|
decoration: BoxDecoration(
|
|
color: theme.cardColor,
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 48,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: theme.dividerColor,
|
|
borderRadius: BorderRadius.circular(6))),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Text('Edit profile',
|
|
style: theme.textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.bold)),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.photo_camera),
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
await _pickFromGallery();
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.link),
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_enterAssetPathDialog();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: nameCtl,
|
|
decoration: InputDecoration(
|
|
labelText: 'Name',
|
|
filled: true,
|
|
fillColor: theme.cardColor,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8)))),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: emailCtl,
|
|
decoration: InputDecoration(
|
|
labelText: 'Email',
|
|
filled: true,
|
|
fillColor: theme.cardColor,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8)))),
|
|
const SizedBox(height: 14),
|
|
Row(
|
|
children: [
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Cancel')),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
final newName = nameCtl.text.trim().isEmpty
|
|
? _email
|
|
: nameCtl.text.trim();
|
|
final newEmail = emailCtl.text.trim().isEmpty
|
|
? _email
|
|
: emailCtl.text.trim();
|
|
await _saveProfile(newName, newEmail, _profileImage);
|
|
Navigator.of(context).pop();
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Tip: tap the camera icon to pick from gallery (mobile). Or tap the link icon to paste an asset path or URL.',
|
|
style: theme.textTheme.bodySmall
|
|
?.copyWith(color: theme.hintColor),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
).then((_) {
|
|
nameCtl.dispose();
|
|
emailCtl.dispose();
|
|
});
|
|
}
|
|
|
|
// ───────── Avatar builder (reused, with size param) ─────────
|
|
|
|
Widget _buildProfileAvatar({double size = 96}) {
|
|
final path = _profileImage.trim();
|
|
if (path.startsWith('http')) {
|
|
return ClipOval(
|
|
child: CachedNetworkImage(
|
|
imageUrl: path,
|
|
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)));
|
|
}
|
|
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)));
|
|
}
|
|
|
|
// ───────── Event list tile (updated styling) ─────────
|
|
|
|
Widget _eventListTileFromModel(EventModel ev, {bool faded = false}) {
|
|
final theme = Theme.of(context);
|
|
final title = ev.title ?? ev.name ?? '';
|
|
final dateLabel =
|
|
(ev.startDate != null && ev.endDate != null && ev.startDate == ev.endDate)
|
|
? ev.startDate!
|
|
: ((ev.startDate != null && ev.endDate != null)
|
|
? '${ev.startDate} - ${ev.endDate}'
|
|
: (ev.startDate ?? ''));
|
|
final location = ev.place ?? '';
|
|
final imageUrl = (ev.thumbImg != null && ev.thumbImg!.isNotEmpty)
|
|
? ev.thumbImg!
|
|
: (ev.images.isNotEmpty ? ev.images.first.image : null);
|
|
|
|
final titleStyle = theme.textTheme.bodyLarge?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: faded ? theme.hintColor : theme.textTheme.bodyLarge?.color);
|
|
final subtitleStyle = theme.textTheme.bodySmall?.copyWith(
|
|
fontSize: 13,
|
|
color: faded
|
|
? theme.hintColor.withValues(alpha: 0.7)
|
|
: theme.hintColor);
|
|
|
|
Widget leadingWidget() {
|
|
if (imageUrl != null && imageUrl.trim().isNotEmpty) {
|
|
if (imageUrl.startsWith('http')) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: CachedNetworkImage(
|
|
imageUrl: imageUrl,
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.cover,
|
|
placeholder: (_, __) => Container(
|
|
width: 60,
|
|
height: 60,
|
|
color: const Color(0xFFE5E7EB)),
|
|
errorWidget: (_, __, ___) => Container(
|
|
width: 60,
|
|
height: 60,
|
|
color: theme.dividerColor,
|
|
child: Icon(Icons.image, color: theme.hintColor))));
|
|
} else if (!kIsWeb) {
|
|
final path = imageUrl;
|
|
if (path.startsWith('/') || path.contains(Platform.pathSeparator)) {
|
|
final file = File(path);
|
|
if (file.existsSync()) {
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.file(file,
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
width: 60,
|
|
height: 60,
|
|
color: theme.dividerColor,
|
|
child: Icon(Icons.image, color: theme.hintColor))));
|
|
}
|
|
}
|
|
}
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.asset(imageUrl,
|
|
width: 60,
|
|
height: 60,
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
width: 60,
|
|
height: 60,
|
|
color: theme.dividerColor,
|
|
child: Icon(Icons.image, color: theme.hintColor))));
|
|
}
|
|
return Container(
|
|
width: 60,
|
|
height: 60,
|
|
decoration: BoxDecoration(
|
|
color: theme.dividerColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(Icons.image, color: theme.hintColor));
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: theme.cardColor,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 4))
|
|
],
|
|
),
|
|
child: ListTile(
|
|
onTap: () {
|
|
if (ev.id != null) {
|
|
Navigator.of(context)
|
|
.push(MaterialPageRoute(builder: (_) => LearnMoreScreen(eventId: ev.id)));
|
|
}
|
|
},
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
leading: leadingWidget(),
|
|
title: Text(title, style: titleStyle),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
Text(dateLabel, style: subtitleStyle),
|
|
const SizedBox(height: 2),
|
|
Text(location, style: subtitleStyle),
|
|
],
|
|
),
|
|
trailing: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(10)),
|
|
child: Icon(Icons.qr_code_scanner, color: theme.colorScheme.primary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// NEW UI WIDGETS — matching web profile layout
|
|
// ═══════════════════════════════════════════════
|
|
|
|
// ───────── Gradient Header ─────────
|
|
|
|
Widget _buildGradientHeader(BuildContext context, double height) {
|
|
return Container(
|
|
width: double.infinity,
|
|
height: height,
|
|
decoration: AppDecoration.blueGradient.copyWith(
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(40),
|
|
bottomRight: Radius.circular(40),
|
|
),
|
|
),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(width: 40), // balance
|
|
const Spacer(),
|
|
Text(
|
|
'Profile',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: Colors.white, fontWeight: FontWeight.w600),
|
|
),
|
|
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ───────── Cover Banner ─────────
|
|
|
|
Widget _buildCoverBanner() {
|
|
return Stack(
|
|
children: [
|
|
Container(
|
|
height: 160,
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
gradient: const LinearGradient(
|
|
colors: [
|
|
Color(0xFFE879A8), // rose/pink tint (page bg bleed)
|
|
Color(0xFF7DD3FC), // sky-300
|
|
Color(0xFF67E8F9), // cyan-300
|
|
Color(0xFF0284C7), // sky-600
|
|
],
|
|
stops: [0.0, 0.35, 0.6, 1.0],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
),
|
|
// Edit pencil button (top-right, matching web)
|
|
Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: GestureDetector(
|
|
onTap: () => _openEditDialog(),
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.2),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(Icons.edit, color: Colors.white, size: 18),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ───────── Avatar overlapping banner + EXP bar ─────────
|
|
|
|
Widget _buildAvatarSection() {
|
|
// Use a Stack so the avatar overlaps the bottom of the banner
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
// Banner
|
|
_buildCoverBanner(),
|
|
// Avatar positioned at bottom-left, half overlapping
|
|
Positioned(
|
|
bottom: -36,
|
|
left: 16,
|
|
child: GestureDetector(
|
|
onTap: () => _openEditDialog(),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 3),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.1),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: _buildProfileAvatar(size: 80),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ───────── Animated EXP Progress Bar ─────────
|
|
|
|
Widget _buildExpBar() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'exp.',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: AnimatedBuilder(
|
|
animation: _animController,
|
|
builder: (context, _) => LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final fullWidth = constraints.maxWidth;
|
|
final filledWidth = fullWidth * _expProgress;
|
|
return Container(
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
color: Colors.grey.shade200,
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Container(
|
|
width: filledWidth,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(4),
|
|
gradient: _expGradient,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ───────── Stats Row (animated counters + top/bottom borders) ─────────
|
|
|
|
Widget _buildStatsRow() {
|
|
Widget statColumn(String value, String label) {
|
|
return Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppConstants.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppConstants.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(color: Colors.grey.shade200, width: 1),
|
|
bottom: BorderSide(color: Colors.grey.shade200, width: 1),
|
|
),
|
|
),
|
|
child: AnimatedBuilder(
|
|
animation: _animController,
|
|
builder: (context, _) => IntrinsicHeight(
|
|
child: Row(
|
|
children: [
|
|
statColumn(_formatNumber(_animatedLikes), 'Likes'),
|
|
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
|
statColumn(_formatNumber(_animatedPosts), 'Posts'),
|
|
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
|
statColumn(_formatNumber(_animatedViews), 'Views'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ───────── Social Icons Row ─────────
|
|
|
|
Widget _buildSocialIcons() {
|
|
final icons = [
|
|
Icons.emoji_events_outlined, // trophy (matching web)
|
|
Icons.camera_alt_outlined, // instagram camera
|
|
Icons.flutter_dash, // twitter-like bird
|
|
Icons.layers_outlined, // layers/stack
|
|
];
|
|
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: icons.map((icon) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Icon(icon, size: 24, color: Colors.grey.shade500),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
// ───────── Rainbow Bar ─────────
|
|
|
|
Widget _buildRainbowBar() {
|
|
return Container(
|
|
height: 4,
|
|
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(2),
|
|
gradient: _expGradient,
|
|
),
|
|
);
|
|
}
|
|
|
|
// ───────── Profile Card (white, overlapping header) ─────────
|
|
|
|
Widget _buildProfileCard(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: theme.cardColor,
|
|
borderRadius: BorderRadius.circular(32),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Cover Banner + Avatar
|
|
_buildAvatarSection(),
|
|
|
|
// Spacer for avatar overflow (avatar extends 36px below banner)
|
|
const SizedBox(height: 44),
|
|
|
|
// EXP bar
|
|
_buildExpBar(),
|
|
const SizedBox(height: 16),
|
|
|
|
// Name
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
_username,
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppConstants.textPrimary,
|
|
letterSpacing: -0.3,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Email
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
_email,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w300,
|
|
color: AppConstants.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Stats Row
|
|
_buildStatsRow(),
|
|
const SizedBox(height: 24),
|
|
|
|
// Social Icons
|
|
_buildSocialIcons(),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ───────── Event Sections ─────────
|
|
|
|
Widget _buildEventSections(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
Widget sectionHeading(String text) {
|
|
return Text(
|
|
text,
|
|
style: theme.textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w600, fontSize: 18),
|
|
);
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Ongoing Events
|
|
if (_ongoingEvents.isNotEmpty) ...[
|
|
sectionHeading('Ongoing Events'),
|
|
const SizedBox(height: 12),
|
|
Column(
|
|
children:
|
|
_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()),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════
|
|
// BUILD
|
|
// ═══════════════════════════════════════════════
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
const double headerHeight = 200.0;
|
|
const double cardTopOffset = 130.0; // card starts overlapping into header
|
|
|
|
return Scaffold(
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
body: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
// Header + Profile Card overlap using Stack
|
|
Stack(
|
|
children: [
|
|
_buildGradientHeader(context, headerHeight),
|
|
Padding(
|
|
padding: EdgeInsets.only(top: cardTopOffset),
|
|
child: _buildProfileCard(context),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Event sections
|
|
_buildEventSections(context),
|
|
|
|
const SizedBox(height: 32),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|