feat: enhance profile card with animations matching React component
- Add animated EXP progress bar (0% → 65% with ease-out on gray track) - Add animated stat counters (count up from 0 to 72.9K/828/342.9K) - Expand rainbow gradient to 6 colors (purple→pink→orange→yellow→green→blue) - Add Follow/Following toggle button on cover banner - Add title/bio text between name and email - Add top/bottom borders to stats section - Add _formatNumber() helper for K/M formatting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@ class ProfileScreen extends StatefulWidget {
|
|||||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ProfileScreenState extends State<ProfileScreen> {
|
class _ProfileScreenState extends State<ProfileScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
String _username = '';
|
String _username = '';
|
||||||
String _email = 'not provided';
|
String _email = 'not provided';
|
||||||
String _profileImage = '';
|
String _profileImage = '';
|
||||||
@@ -33,15 +34,96 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
|
|
||||||
bool _loadingEvents = true;
|
bool _loadingEvents = true;
|
||||||
|
|
||||||
// Gradient used for EXP bar and rainbow bar
|
// Gradient used for EXP bar and rainbow bar (6-color rainbow)
|
||||||
static const LinearGradient _expGradient = LinearGradient(
|
static const LinearGradient _expGradient = LinearGradient(
|
||||||
colors: [Color(0xFFA855F7), Color(0xFFEAB308), Color(0xFF3B82F6)],
|
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;
|
||||||
|
bool _isFollowing = false;
|
||||||
|
String _title = 'Product Designer who focuses on simplicity & usability.';
|
||||||
|
|
||||||
|
// Target stat values
|
||||||
|
static const int _targetLikes = 72900;
|
||||||
|
static const int _targetPosts = 828;
|
||||||
|
static const int _targetViews = 342900;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
|
// Animation controller for EXP bar + stat counters
|
||||||
|
_animController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2000),
|
||||||
|
);
|
||||||
|
|
||||||
_loadProfile();
|
_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),
|
||||||
|
);
|
||||||
|
expAnim.addListener(() {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_expProgress = expTween.evaluate(expAnim);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate stat counters: 0 → target over full 2s
|
||||||
|
_animController.addListener(() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final t = _animController.value;
|
||||||
|
setState(() {
|
||||||
|
_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) ─────────
|
// ───────── Data Loading (unchanged) ─────────
|
||||||
@@ -585,20 +667,36 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Edit pencil button (top-right)
|
// Follow / Following toggle button (top-right)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _openEditDialog(),
|
onTap: () => setState(() => _isFollowing = !_isFollowing),
|
||||||
child: Container(
|
child: AnimatedContainer(
|
||||||
width: 36,
|
duration: const Duration(milliseconds: 200),
|
||||||
height: 36,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.15),
|
color: _isFollowing ? Colors.white : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_isFollowing ? 'Following \u2713' : 'Follow +',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _isFollowing
|
||||||
|
? AppConstants.textSecondary
|
||||||
|
: AppConstants.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.edit, color: Colors.white, size: 18),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -641,7 +739,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────── EXP Progress Bar ─────────
|
// ───────── Animated EXP Progress Bar ─────────
|
||||||
|
|
||||||
Widget _buildExpBar() {
|
Widget _buildExpBar() {
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -658,7 +756,20 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: 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, // gray track
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
|
width: filledWidth,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
@@ -666,12 +777,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────── Stats Row ─────────
|
// ───────── Stats Row (animated counters + top/bottom borders) ─────────
|
||||||
|
|
||||||
Widget _buildStatsRow() {
|
Widget _buildStatsRow() {
|
||||||
Widget statColumn(String value, String label) {
|
Widget statColumn(String value, String label) {
|
||||||
@@ -701,16 +816,23 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
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: IntrinsicHeight(
|
child: IntrinsicHeight(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
statColumn('1.2K', 'Likes'),
|
statColumn(_formatNumber(_animatedLikes), 'Likes'),
|
||||||
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
||||||
statColumn('45', 'Posts'),
|
statColumn(_formatNumber(_animatedPosts), 'Posts'),
|
||||||
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
VerticalDivider(color: Colors.grey.shade200, thickness: 1, width: 1),
|
||||||
statColumn('3.4K', 'Views'),
|
statColumn(_formatNumber(_animatedViews), 'Views'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -795,6 +917,21 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
|
||||||
|
// Title / Bio
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Text(
|
||||||
|
_title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
color: AppConstants.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
@@ -802,10 +939,10 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_email,
|
_email,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w300,
|
fontWeight: FontWeight.w300,
|
||||||
color: AppConstants.textSecondary,
|
color: Colors.grey.shade400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user