feat(share): rebuild share rank with dart:ui Canvas generator
Replace RepaintBoundary widget capture approach with a pure dart:ui PictureRecorder + Canvas implementation. - Add share_card_generator.dart: generates 1080×1920 PNG via Canvas without embedding any widget in the tree - Remove share_rank_card.dart (widget approach no longer needed) - Remove GlobalKey, _buildHiddenShareCard, RepaintBoundary, _fmtEp from profile_screen.dart - Simplify desktop + mobile Stacks to direct ScrollViews - Fix Android GPU compositing timing crash (no retry needed) - Add avatarImage.dispose() to prevent GPU memory leak - Guard byteData null return with StateError - Replace MaterialIcons bolt with Unicode ⚡ (tree-shake safe) - Align tier in share text with tier rendered on card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
547
lib/features/share/share_card_generator.dart
Normal file
547
lib/features/share/share_card_generator.dart
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
// lib/features/share/share_card_generator.dart
|
||||||
|
//
|
||||||
|
// Pure dart:ui Canvas generator — produces a 1080×1920 PNG story card
|
||||||
|
// without embedding any widget in the tree. Drop-in replacement for
|
||||||
|
// the old RepaintBoundary + ShareRankCard approach.
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
// ── Tier theme data (ported from share_rank_card.dart) ─────────────────────
|
||||||
|
|
||||||
|
const _tierThemes = <String, _TierTheme>{
|
||||||
|
'Bronze': _TierTheme(
|
||||||
|
stops: [Color(0xFF92400E), Color(0xFFB45309), Color(0xFFD97706)],
|
||||||
|
ring: Color(0xFFD97706),
|
||||||
|
),
|
||||||
|
'Silver': _TierTheme(
|
||||||
|
stops: [Color(0xFF334155), Color(0xFF475569), Color(0xFF64748B)],
|
||||||
|
ring: Color(0xFF94A3B8),
|
||||||
|
),
|
||||||
|
'Gold': _TierTheme(
|
||||||
|
stops: [Color(0xFF78350F), Color(0xFF92400E), Color(0xFFB45309)],
|
||||||
|
ring: Color(0xFFF59E0B),
|
||||||
|
),
|
||||||
|
'Platinum': _TierTheme(
|
||||||
|
stops: [Color(0xFF4C1D95), Color(0xFF5B21B6), Color(0xFF7C3AED)],
|
||||||
|
ring: Color(0xFF8B5CF6),
|
||||||
|
),
|
||||||
|
'Diamond': _TierTheme(
|
||||||
|
stops: [Color(0xFF312E81), Color(0xFF4338CA), Color(0xFF6366F1)],
|
||||||
|
ring: Color(0xFF6366F1),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
class _TierTheme {
|
||||||
|
final List<Color> stops;
|
||||||
|
final Color ring;
|
||||||
|
const _TierTheme({required this.stops, required this.ring});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Generates a 1080×1920 PNG share card entirely via dart:ui Canvas.
|
||||||
|
/// Returns raw PNG bytes ready for [Share.shareXFiles].
|
||||||
|
Future<Uint8List> generateShareCardPng({
|
||||||
|
required String username,
|
||||||
|
required String tier,
|
||||||
|
required int lifetimeEp,
|
||||||
|
required int currentEp,
|
||||||
|
required int rewardPoints,
|
||||||
|
String? eventifyId,
|
||||||
|
String? district,
|
||||||
|
String? imageUrl,
|
||||||
|
}) async {
|
||||||
|
const double w = 1080;
|
||||||
|
const double h = 1920;
|
||||||
|
|
||||||
|
// Resolve tier theme
|
||||||
|
final capTier = tier.isEmpty
|
||||||
|
? 'Bronze'
|
||||||
|
: (tier[0].toUpperCase() + tier.substring(1).toLowerCase());
|
||||||
|
final theme = _tierThemes[capTier] ?? _tierThemes['Bronze']!;
|
||||||
|
|
||||||
|
// Load avatar (if available)
|
||||||
|
ui.Image? avatarImage;
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
avatarImage = await _loadNetworkImage(imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw ────────────────────────────────────────────────────────────────
|
||||||
|
final recorder = ui.PictureRecorder();
|
||||||
|
final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, w, h));
|
||||||
|
|
||||||
|
// Layout constants (all at 3x of the original 360×640 widget)
|
||||||
|
const headerH = 894.0; // flex 45 of (1920-132)
|
||||||
|
const panelH = 894.0; // flex 45
|
||||||
|
const footerH = 132.0; // 44 * 3
|
||||||
|
const panelTop = headerH;
|
||||||
|
const footerTop = panelTop + panelH;
|
||||||
|
const cornerR = 84.0; // 28 * 3
|
||||||
|
const pad = 60.0; // 20 * 3
|
||||||
|
|
||||||
|
// 1. Gradient header background
|
||||||
|
final gradientPaint = Paint()
|
||||||
|
..shader = ui.Gradient.linear(
|
||||||
|
const Offset(w / 2, 0),
|
||||||
|
Offset(w / 2, headerH),
|
||||||
|
theme.stops,
|
||||||
|
[0.0, 0.5, 1.0],
|
||||||
|
);
|
||||||
|
canvas.drawRect(const Rect.fromLTWH(0, 0, w, headerH), gradientPaint);
|
||||||
|
|
||||||
|
// 2. White panel (rounded top corners)
|
||||||
|
final panelRRect = RRect.fromRectAndCorners(
|
||||||
|
const Rect.fromLTWH(0, panelTop, w, panelH),
|
||||||
|
topLeft: const Radius.circular(cornerR),
|
||||||
|
topRight: const Radius.circular(cornerR),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(panelRRect, Paint()..color = Colors.white);
|
||||||
|
|
||||||
|
// 3. Footer
|
||||||
|
canvas.drawRect(
|
||||||
|
const Rect.fromLTWH(0, footerTop, w, footerH),
|
||||||
|
Paint()..color = const Color(0xFF0F45CF),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Header content ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
const double avatarSize = 228; // 76 * 3
|
||||||
|
const double ringGap = 9; // 3 * 3
|
||||||
|
const double ringWidth = 15; // 5 * 3
|
||||||
|
const double totalSize = avatarSize + (ringGap + ringWidth) * 2;
|
||||||
|
const double avatarCenterY = 340;
|
||||||
|
const avatarCenter = Offset(w / 2, avatarCenterY);
|
||||||
|
|
||||||
|
// Draw ring
|
||||||
|
final ringPaint = Paint()
|
||||||
|
..color = theme.ring
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = ringWidth;
|
||||||
|
canvas.drawCircle(avatarCenter, totalSize / 2 - ringWidth / 2, ringPaint);
|
||||||
|
|
||||||
|
// Draw avatar image or initials
|
||||||
|
const double avatarRadius = avatarSize / 2;
|
||||||
|
if (avatarImage != null) {
|
||||||
|
canvas.save();
|
||||||
|
final clipPath = Path()
|
||||||
|
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
|
||||||
|
canvas.clipPath(clipPath);
|
||||||
|
final src = Rect.fromLTWH(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
avatarImage.width.toDouble(),
|
||||||
|
avatarImage.height.toDouble(),
|
||||||
|
);
|
||||||
|
final dst = Rect.fromCircle(center: avatarCenter, radius: avatarRadius);
|
||||||
|
canvas.drawImageRect(avatarImage, src, dst, Paint());
|
||||||
|
canvas.restore();
|
||||||
|
} else {
|
||||||
|
// Initials fallback
|
||||||
|
canvas.save();
|
||||||
|
final clipPath = Path()
|
||||||
|
..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius));
|
||||||
|
canvas.clipPath(clipPath);
|
||||||
|
canvas.drawCircle(
|
||||||
|
avatarCenter,
|
||||||
|
avatarRadius,
|
||||||
|
Paint()..color = Colors.white.withValues(alpha: 0.25),
|
||||||
|
);
|
||||||
|
final initials = username.length >= 2
|
||||||
|
? username.substring(0, 2).toUpperCase()
|
||||||
|
: username.toUpperCase();
|
||||||
|
final tp = _layoutText(
|
||||||
|
initials,
|
||||||
|
fontSize: avatarSize * 0.32,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
tp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
avatarCenter.dx - tp.width / 2,
|
||||||
|
avatarCenter.dy - tp.height / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username (below avatar)
|
||||||
|
final displayName =
|
||||||
|
username.length > 20 ? username.substring(0, 20) : username;
|
||||||
|
final userTp = _layoutText(
|
||||||
|
displayName,
|
||||||
|
fontSize: 66, // 22 * 3
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: -0.9,
|
||||||
|
maxWidth: w - pad * 2,
|
||||||
|
);
|
||||||
|
userTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset((w - userTp.width) / 2, avatarCenterY + totalSize / 2 + 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tier badge pill
|
||||||
|
final tierLabel = tier.isEmpty ? 'CONTRIBUTOR' : tier.toUpperCase();
|
||||||
|
final badgeText = '\u2605 $tierLabel EXPLORER';
|
||||||
|
final badgeTp = _layoutText(
|
||||||
|
badgeText,
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
);
|
||||||
|
const badgePadH = 42.0; // 14 * 3
|
||||||
|
const badgePadV = 15.0; // 5 * 3
|
||||||
|
final badgeW = badgeTp.width + badgePadH * 2;
|
||||||
|
final badgeH = badgeTp.height + badgePadV * 2;
|
||||||
|
final badgeY =
|
||||||
|
avatarCenterY + totalSize / 2 + 30 + userTp.height + 18;
|
||||||
|
final badgeRRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, badgeY + badgeH / 2),
|
||||||
|
width: badgeW,
|
||||||
|
height: badgeH,
|
||||||
|
),
|
||||||
|
const Radius.circular(60),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
badgeRRect,
|
||||||
|
Paint()..color = Colors.black.withValues(alpha: 0.35),
|
||||||
|
);
|
||||||
|
badgeTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset((w - badgeTp.width) / 2, badgeY + badgePadV),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── White panel content ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
double cy = panelTop + pad; // running y cursor inside panel
|
||||||
|
|
||||||
|
// Lifetime EP hero card
|
||||||
|
const heroCardH = 195.0; // approximate height for label + number + subtitle
|
||||||
|
final heroRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(pad, cy, w - pad * 2, heroCardH),
|
||||||
|
const Radius.circular(42), // 14 * 3
|
||||||
|
);
|
||||||
|
final heroBgPaint = Paint()
|
||||||
|
..shader = ui.Gradient.linear(
|
||||||
|
Offset(pad, cy),
|
||||||
|
Offset(w - pad, cy),
|
||||||
|
[
|
||||||
|
theme.stops.first.withValues(alpha: 0.12),
|
||||||
|
theme.stops.last.withValues(alpha: 0.06),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
canvas.drawRRect(heroRect, heroBgPaint);
|
||||||
|
|
||||||
|
// "LIFETIME EP ⚡"
|
||||||
|
const heroInnerPad = 48.0; // 16 * 3
|
||||||
|
const heroInnerPadV = 42.0; // 14 * 3
|
||||||
|
final labelTp = _layoutText(
|
||||||
|
'LIFETIME EP \u26A1',
|
||||||
|
fontSize: 30, // 10 * 3
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
);
|
||||||
|
labelTp.paint(canvas, Offset(pad + heroInnerPad, cy + heroInnerPadV));
|
||||||
|
|
||||||
|
// Big EP number
|
||||||
|
final bigNumTp = _layoutText(
|
||||||
|
formatEp(lifetimeEp),
|
||||||
|
fontSize: 108, // 36 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: theme.stops.first,
|
||||||
|
);
|
||||||
|
bigNumTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(pad + heroInnerPad, cy + heroInnerPadV + labelTp.height + 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Eventify Points earned"
|
||||||
|
final subTp = _layoutText(
|
||||||
|
'Eventify Points earned',
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
color: const Color(0xFF94A3B8),
|
||||||
|
);
|
||||||
|
subTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
pad + heroInnerPad,
|
||||||
|
cy + heroInnerPadV + labelTp.height + 12 + bigNumTp.height + 3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy += heroCardH + 30; // 10 * 3 gap
|
||||||
|
|
||||||
|
// ── Liquid EP + Reward Points side-by-side pills ──────────────────────
|
||||||
|
const pillGap = 24.0; // 8 * 3
|
||||||
|
final pillW = (w - pad * 2 - pillGap) / 2;
|
||||||
|
const pillH = 120.0;
|
||||||
|
|
||||||
|
// Left pill — Liquid EP
|
||||||
|
_drawStatPill(
|
||||||
|
canvas,
|
||||||
|
x: pad,
|
||||||
|
y: cy,
|
||||||
|
width: pillW,
|
||||||
|
height: pillH,
|
||||||
|
emoji: '\u26A1',
|
||||||
|
label: 'LIQUID EP',
|
||||||
|
value: formatEp(currentEp),
|
||||||
|
bgColor: const Color(0xFFEFF6FF),
|
||||||
|
textColor: const Color(0xFF1D4ED8),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Right pill — Reward Points
|
||||||
|
_drawStatPill(
|
||||||
|
canvas,
|
||||||
|
x: pad + pillW + pillGap,
|
||||||
|
y: cy,
|
||||||
|
width: pillW,
|
||||||
|
height: pillH,
|
||||||
|
emoji: '\uD83C\uDFC6',
|
||||||
|
label: 'REWARD POINTS',
|
||||||
|
value: formatEp(rewardPoints),
|
||||||
|
bgColor: const Color(0xFFFFFBEB),
|
||||||
|
textColor: const Color(0xFF92400E),
|
||||||
|
);
|
||||||
|
|
||||||
|
cy += pillH + 36; // 12 * 3
|
||||||
|
|
||||||
|
// ── Dashed divider ────────────────────────────────────────────────────
|
||||||
|
final dashPaint = Paint()
|
||||||
|
..color = const Color(0xFFE2E8F0)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
const dashW = 15.0;
|
||||||
|
const dashGap = 15.0;
|
||||||
|
double dx = pad;
|
||||||
|
while (dx < w - pad) {
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(dx, cy),
|
||||||
|
Offset((dx + dashW).clamp(0, w - pad), cy),
|
||||||
|
dashPaint,
|
||||||
|
);
|
||||||
|
dx += dashW + dashGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
cy += 30; // 10 * 3
|
||||||
|
|
||||||
|
// ── CTA text ──────────────────────────────────────────────────────────
|
||||||
|
final ctaTp = _layoutText(
|
||||||
|
'Join me on Eventify Plus!',
|
||||||
|
fontSize: 42, // 14 * 3
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: const Color(0xFF0F172A),
|
||||||
|
);
|
||||||
|
ctaTp.paint(canvas, Offset((w - ctaTp.width) / 2, cy));
|
||||||
|
cy += ctaTp.height + 6;
|
||||||
|
|
||||||
|
final ctaSubTp = _layoutText(
|
||||||
|
'Discover events. Earn rewards.',
|
||||||
|
fontSize: 33, // 11 * 3
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
ctaSubTp.paint(canvas, Offset((w - ctaSubTp.width) / 2, cy));
|
||||||
|
cy += ctaSubTp.height;
|
||||||
|
|
||||||
|
// ── Optional eventifyId pill ──────────────────────────────────────────
|
||||||
|
if (eventifyId != null && eventifyId.isNotEmpty) {
|
||||||
|
cy += 30;
|
||||||
|
final idTp = _layoutText(
|
||||||
|
eventifyId,
|
||||||
|
fontSize: 33,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF1D4ED8),
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
);
|
||||||
|
final idPillW = idTp.width + 72; // 12*3 * 2
|
||||||
|
final idPillH = idTp.height + 24; // 4*3 * 2
|
||||||
|
final idRRect = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, cy + idPillH / 2),
|
||||||
|
width: idPillW,
|
||||||
|
height: idPillH,
|
||||||
|
),
|
||||||
|
const Radius.circular(36),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(idRRect, Paint()..color = const Color(0xFFEFF6FF));
|
||||||
|
idTp.paint(canvas, Offset((w - idTp.width) / 2, cy + 12));
|
||||||
|
cy += idPillH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Optional district ─────────────────────────────────────────────────
|
||||||
|
if (district != null && district.isNotEmpty) {
|
||||||
|
cy += 18; // 6 * 3
|
||||||
|
final distTp = _layoutText(
|
||||||
|
'\uD83D\uDCCD $district',
|
||||||
|
fontSize: 33,
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
);
|
||||||
|
distTp.paint(canvas, Offset((w - distTp.width) / 2, cy));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Footer content ────────────────────────────────────────────────────
|
||||||
|
final boltTp = _layoutText(
|
||||||
|
'\u26A1',
|
||||||
|
fontSize: 42,
|
||||||
|
color: Colors.white,
|
||||||
|
);
|
||||||
|
|
||||||
|
final brandTp = _layoutText(
|
||||||
|
'E V E N T I F Y',
|
||||||
|
fontSize: 39, // 13 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 9,
|
||||||
|
);
|
||||||
|
|
||||||
|
final urlTp = _layoutText(
|
||||||
|
'eventifyplus.com',
|
||||||
|
fontSize: 30, // 10 * 3
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: const Color(0xFF93C5FD),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Center the row: bolt + 18px + brand + 36px + url
|
||||||
|
const gap1 = 18.0;
|
||||||
|
const gap2 = 36.0;
|
||||||
|
final totalRowW =
|
||||||
|
boltTp.width + gap1 + brandTp.width + gap2 + urlTp.width;
|
||||||
|
final rowX = (w - totalRowW) / 2;
|
||||||
|
final footerCenterY = footerTop + footerH / 2;
|
||||||
|
|
||||||
|
boltTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(rowX, footerCenterY - boltTp.height / 2),
|
||||||
|
);
|
||||||
|
brandTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(rowX + boltTp.width + gap1, footerCenterY - brandTp.height / 2),
|
||||||
|
);
|
||||||
|
urlTp.paint(
|
||||||
|
canvas,
|
||||||
|
Offset(
|
||||||
|
rowX + boltTp.width + gap1 + brandTp.width + gap2,
|
||||||
|
footerCenterY - urlTp.height / 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Finalize ──────────────────────────────────────────────────────────
|
||||||
|
avatarImage?.dispose();
|
||||||
|
final picture = recorder.endRecording();
|
||||||
|
final image = await picture.toImage(w.toInt(), h.toInt());
|
||||||
|
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
if (byteData == null) {
|
||||||
|
throw StateError('Failed to encode share card to PNG');
|
||||||
|
}
|
||||||
|
return byteData.buffer.asUint8List();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Loads a network image as a [ui.Image] for Canvas drawing.
|
||||||
|
Future<ui.Image?> _loadNetworkImage(String url) async {
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5));
|
||||||
|
if (response.statusCode != 200) return null;
|
||||||
|
final codec = await ui.instantiateImageCodec(response.bodyBytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
return frame.image;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Share card avatar load failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates and lays out a [TextPainter] for Canvas drawing.
|
||||||
|
TextPainter _layoutText(
|
||||||
|
String text, {
|
||||||
|
required double fontSize,
|
||||||
|
FontWeight fontWeight = FontWeight.w400,
|
||||||
|
Color color = Colors.black,
|
||||||
|
double letterSpacing = 0,
|
||||||
|
String fontFamily = 'Gilroy',
|
||||||
|
double maxWidth = 1080,
|
||||||
|
}) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: fontFamily,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
color: color,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
)..layout(maxWidth: maxWidth);
|
||||||
|
return tp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draws a stat pill (e.g. Liquid EP, Reward Points).
|
||||||
|
void _drawStatPill(
|
||||||
|
Canvas canvas, {
|
||||||
|
required double x,
|
||||||
|
required double y,
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
required String emoji,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required Color bgColor,
|
||||||
|
required Color textColor,
|
||||||
|
}) {
|
||||||
|
const r = 36.0;
|
||||||
|
const pad = 36.0;
|
||||||
|
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(Rect.fromLTWH(x, y, width, height), const Radius.circular(r)),
|
||||||
|
Paint()..color = bgColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
final labelTp = _layoutText(
|
||||||
|
'$emoji $label',
|
||||||
|
fontSize: 27, // 9 * 3
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: textColor,
|
||||||
|
letterSpacing: 0.9,
|
||||||
|
maxWidth: width - pad * 2,
|
||||||
|
);
|
||||||
|
labelTp.paint(canvas, Offset(x + pad, y + pad * 0.6));
|
||||||
|
|
||||||
|
final valTp = _layoutText(
|
||||||
|
value,
|
||||||
|
fontSize: 60, // 20 * 3
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: textColor,
|
||||||
|
maxWidth: width - pad * 2,
|
||||||
|
);
|
||||||
|
valTp.paint(canvas, Offset(x + pad, y + pad * 0.6 + labelTp.height + 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats a number with commas (e.g. 1234 → "1,234", 1234567 → "1.2M").
|
||||||
|
String formatEp(int n) {
|
||||||
|
if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
if (n >= 1000) {
|
||||||
|
final s = n.toString();
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
if (i > 0 && (s.length - i) % 3 == 0) buf.write(',');
|
||||||
|
buf.write(s[i]);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
return n.toString();
|
||||||
|
}
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:share_plus/share_plus.dart';
|
|
||||||
import '../../widgets/tier_avatar_ring.dart';
|
|
||||||
|
|
||||||
class ShareRankCard extends StatefulWidget {
|
|
||||||
final String username;
|
|
||||||
final String tier;
|
|
||||||
final int rank;
|
|
||||||
final int ep;
|
|
||||||
final int rewardPoints;
|
|
||||||
|
|
||||||
const ShareRankCard({
|
|
||||||
super.key,
|
|
||||||
required this.username,
|
|
||||||
required this.tier,
|
|
||||||
required this.rank,
|
|
||||||
required this.ep,
|
|
||||||
this.rewardPoints = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ShareRankCard> createState() => _ShareRankCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ShareRankCardState extends State<ShareRankCard> {
|
|
||||||
final GlobalKey _boundaryKey = GlobalKey();
|
|
||||||
bool _sharing = false;
|
|
||||||
|
|
||||||
static const _tierGradients = {
|
|
||||||
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
|
|
||||||
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
|
|
||||||
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
|
|
||||||
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
|
|
||||||
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
|
|
||||||
};
|
|
||||||
|
|
||||||
List<Color> get _gradient {
|
|
||||||
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _share() async {
|
|
||||||
if (_sharing) return;
|
|
||||||
setState(() => _sharing = true);
|
|
||||||
try {
|
|
||||||
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
|
||||||
final image = await boundary.toImage(pixelRatio: 3.0);
|
|
||||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
|
||||||
if (byteData == null) return;
|
|
||||||
final bytes = byteData.buffer.asUint8List();
|
|
||||||
|
|
||||||
final tempDir = await getTemporaryDirectory();
|
|
||||||
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
|
|
||||||
await file.writeAsBytes(bytes);
|
|
||||||
|
|
||||||
await Share.shareXFiles(
|
|
||||||
[XFile(file.path)],
|
|
||||||
subject: 'My Eventify Rank',
|
|
||||||
text: 'I\'m a ${widget.tier.toUpperCase()} Explorer on Eventify Plus! ${widget.ep} EP earned. Let\'s connect on the platform for more.\n\nhttps://app.eventifyplus.com',
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Could not share rank card')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _sharing = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
RepaintBoundary(
|
|
||||||
key: _boundaryKey,
|
|
||||||
child: Container(
|
|
||||||
width: 320,
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Tier gradient header bar
|
|
||||||
Container(
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(colors: _gradient),
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// Avatar
|
|
||||||
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
// Username
|
|
||||||
Text(
|
|
||||||
widget.username,
|
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
// Tier badge
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(colors: _gradient),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
widget.tier.isEmpty ? 'Contributor' : widget.tier,
|
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// Stats row
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
_stat('Rank', '#${widget.rank}'),
|
|
||||||
Container(width: 1, height: 40, color: Colors.white12),
|
|
||||||
_stat('EP', '${widget.ep}'),
|
|
||||||
Container(width: 1, height: 40, color: Colors.white12),
|
|
||||||
_stat('RP', '${widget.rewardPoints}'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
// Branding
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'EVENTIFY',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: Color(0xFF3B82F6),
|
|
||||||
letterSpacing: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _sharing ? null : _share,
|
|
||||||
icon: _sharing
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.share, size: 18),
|
|
||||||
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFF1D4ED8),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _stat(String label, String value) {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -20,10 +22,11 @@ import '../widgets/tier_avatar_ring.dart';
|
|||||||
import 'learn_more_screen.dart';
|
import 'learn_more_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import '../core/api/api_endpoints.dart';
|
import '../core/api/api_endpoints.dart';
|
||||||
|
import '../core/api/api_client.dart';
|
||||||
import '../core/app_decoration.dart';
|
import '../core/app_decoration.dart';
|
||||||
import '../core/constants.dart';
|
import '../core/constants.dart';
|
||||||
import '../widgets/landscape_section_header.dart';
|
import '../widgets/landscape_section_header.dart';
|
||||||
import '../features/share/share_rank_card.dart';
|
import '../features/share/share_card_generator.dart';
|
||||||
import '../core/analytics/posthog_service.dart';
|
import '../core/analytics/posthog_service.dart';
|
||||||
|
|
||||||
class ProfileScreen extends StatefulWidget {
|
class ProfileScreen extends StatefulWidget {
|
||||||
@@ -35,6 +38,7 @@ class ProfileScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ProfileScreenState extends State<ProfileScreen>
|
class _ProfileScreenState extends State<ProfileScreen>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
|
final ApiClient _apiClient = ApiClient();
|
||||||
String _username = '';
|
String _username = '';
|
||||||
String _email = 'not provided';
|
String _email = 'not provided';
|
||||||
String _profileImage = '';
|
String _profileImage = '';
|
||||||
@@ -44,6 +48,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
DateTime? _districtChangedAt;
|
DateTime? _districtChangedAt;
|
||||||
final ImagePicker _picker = ImagePicker();
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
|
// Share rank
|
||||||
|
bool _sharingRank = false;
|
||||||
|
|
||||||
// 14 Kerala districts
|
// 14 Kerala districts
|
||||||
static const List<String> _districts = [
|
static const List<String> _districts = [
|
||||||
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||||
@@ -180,6 +187,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
_profileImage =
|
_profileImage =
|
||||||
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
|
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
|
||||||
|
|
||||||
|
// Always fetch fresh data from server (fire-and-forget)
|
||||||
|
// Ensures profile photo, district, and cooldown are always up-to-date
|
||||||
|
_fetchAndCacheStatusData(prefs, profileImageKey);
|
||||||
|
|
||||||
// AUTH-003/PROF-001: Eventify ID
|
// AUTH-003/PROF-001: Eventify ID
|
||||||
_eventifyId = prefs.getString('eventify_id');
|
_eventifyId = prefs.getString('eventify_id');
|
||||||
|
|
||||||
@@ -199,6 +210,48 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches fresh data from /user/status/ on every profile open.
|
||||||
|
/// Updates profile photo, district, and district_changed_at.
|
||||||
|
Future<void> _fetchAndCacheStatusData(SharedPreferences prefs, String profileImageKey) async {
|
||||||
|
try {
|
||||||
|
final res = await _apiClient.post(ApiEndpoints.status);
|
||||||
|
|
||||||
|
// Profile photo
|
||||||
|
final raw = res['profile_photo']?.toString() ?? '';
|
||||||
|
if (raw.isNotEmpty && !raw.contains('default.png')) {
|
||||||
|
final url = raw.startsWith('http') ? raw : 'https://em.eventifyplus.com$raw';
|
||||||
|
await prefs.setString(profileImageKey, url);
|
||||||
|
await prefs.setString('profileImage', url);
|
||||||
|
if (mounted) setState(() => _profileImage = url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eventify ID
|
||||||
|
final eventifyId = res['eventify_id']?.toString() ?? '';
|
||||||
|
if (eventifyId.isNotEmpty) {
|
||||||
|
await prefs.setString('eventify_id', eventifyId);
|
||||||
|
if (mounted) setState(() => _eventifyId = eventifyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// District + cooldown timestamp
|
||||||
|
final districtFromServer = res['district']?.toString() ?? '';
|
||||||
|
final changedAtStr = res['district_changed_at']?.toString() ?? '';
|
||||||
|
if (districtFromServer.isNotEmpty) {
|
||||||
|
await prefs.setString('district', districtFromServer);
|
||||||
|
}
|
||||||
|
if (changedAtStr.isNotEmpty) {
|
||||||
|
await prefs.setString('district_changed_at', changedAtStr);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (districtFromServer.isNotEmpty) _district = districtFromServer;
|
||||||
|
if (changedAtStr.isNotEmpty) _districtChangedAt = DateTime.tryParse(changedAtStr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Non-critical — silently ignore if status call fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadEventsForProfile([SharedPreferences? prefs]) async {
|
Future<void> _loadEventsForProfile([SharedPreferences? prefs]) async {
|
||||||
_ongoingEvents = [];
|
_ongoingEvents = [];
|
||||||
|
|
||||||
@@ -341,26 +394,24 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final res = await _apiClient.post(
|
||||||
final token = prefs.getString('access_token') ?? '';
|
ApiEndpoints.updateProfile,
|
||||||
final response = await http.patch(
|
body: {'district': district},
|
||||||
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer $token',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: jsonEncode({'district': district}),
|
|
||||||
);
|
);
|
||||||
if (response.statusCode == 200) {
|
final savedDistrict = res['district']?.toString() ?? district;
|
||||||
final now = DateTime.now();
|
final changedAtStr = res['district_changed_at']?.toString() ?? '';
|
||||||
await prefs.setString('district', district);
|
final now = DateTime.now();
|
||||||
await prefs.setString('district_changed_at', now.toIso8601String());
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (mounted) {
|
await prefs.setString('district', savedDistrict);
|
||||||
setState(() {
|
final changedAt = changedAtStr.isNotEmpty
|
||||||
_district = district;
|
? (DateTime.tryParse(changedAtStr) ?? now)
|
||||||
_districtChangedAt = now;
|
: now;
|
||||||
});
|
await prefs.setString('district_changed_at', changedAt.toIso8601String());
|
||||||
}
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_district = savedDistrict;
|
||||||
|
_districtChangedAt = changedAt;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('District update error: $e');
|
debugPrint('District update error: $e');
|
||||||
@@ -371,6 +422,198 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates the share card via dart:ui Canvas and shares as PNG + caption.
|
||||||
|
Future<void> _shareRank(String tier, int lifetimeEp) async {
|
||||||
|
if (_sharingRank) return;
|
||||||
|
setState(() => _sharingRank = true);
|
||||||
|
try {
|
||||||
|
final imageUrl = _profileImage.isNotEmpty &&
|
||||||
|
!_profileImage.contains('default.png')
|
||||||
|
? (_profileImage.startsWith('http')
|
||||||
|
? _profileImage
|
||||||
|
: 'https://em.eventifyplus.com$_profileImage')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final gam = context.read<GamificationProvider>();
|
||||||
|
final p = gam.profile;
|
||||||
|
final stats = gam.currentUserStats;
|
||||||
|
|
||||||
|
final resolvedTier = stats?.level ?? _userTier ?? tier;
|
||||||
|
final resolvedEp = p?.lifetimeEp ?? lifetimeEp;
|
||||||
|
|
||||||
|
final bytes = await generateShareCardPng(
|
||||||
|
username: _username,
|
||||||
|
tier: resolvedTier,
|
||||||
|
lifetimeEp: resolvedEp,
|
||||||
|
currentEp: p?.currentEp ?? 0,
|
||||||
|
rewardPoints: p?.currentRp ?? 0,
|
||||||
|
eventifyId: _eventifyId,
|
||||||
|
district: _district,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
final shareText =
|
||||||
|
"I'm a ${resolvedTier.toUpperCase()} Explorer on Eventify Plus! "
|
||||||
|
"${formatEp(resolvedEp)} EP earned. "
|
||||||
|
"Let's connect on the platform for more.\nhttps://app.eventifyplus.com";
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
try {
|
||||||
|
final xfile = XFile.fromData(
|
||||||
|
bytes,
|
||||||
|
name: 'eventify-rank.png',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
);
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[xfile],
|
||||||
|
subject: 'My Eventify Rank',
|
||||||
|
text: shareText,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
await Clipboard.setData(ClipboardData(text: shareText));
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Caption copied! Save the image and paste caption when sharing.'),
|
||||||
|
duration: Duration(seconds: 5),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final file = File('${tempDir.path}/eventify_rank.png');
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
await Share.shareXFiles(
|
||||||
|
[XFile(file.path)],
|
||||||
|
subject: 'My Eventify Rank',
|
||||||
|
text: shareText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint('_shareRank error: $e\n$st');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Share failed: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _sharingRank = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a bottom sheet with the 14 Kerala district pills.
|
||||||
|
/// Used both from the header district row and from the Edit Profile sheet.
|
||||||
|
void _showDistrictPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) {
|
||||||
|
final theme = Theme.of(ctx);
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx2, setInner) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 14, 18, 32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Select Your District',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Used for leaderboard rankings. Can be changed once every 6 months.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_districtLocked)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_outline, size: 14, color: Colors.amber),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Locked until $_districtNextChange',
|
||||||
|
style: const TextStyle(fontSize: 12, color: Colors.amber, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _districts.map((d) {
|
||||||
|
final isSelected = _district == d;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _districtLocked
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
setInner(() {});
|
||||||
|
Navigator.of(ctx2).pop();
|
||||||
|
await _updateDistrict(d);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF3B82F6)
|
||||||
|
: (_districtLocked
|
||||||
|
? theme.dividerColor.withOpacity(0.5)
|
||||||
|
: theme.cardColor),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF3B82F6)
|
||||||
|
: theme.dividerColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
d,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isSelected
|
||||||
|
? Colors.white
|
||||||
|
: (_districtLocked
|
||||||
|
? theme.hintColor
|
||||||
|
: theme.textTheme.bodyMedium?.color),
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _enterAssetPathDialog() async {
|
Future<void> _enterAssetPathDialog() async {
|
||||||
final ctl = TextEditingController(text: _profileImage);
|
final ctl = TextEditingController(text: _profileImage);
|
||||||
final result = await showDialog<String?>(
|
final result = await showDialog<String?>(
|
||||||
@@ -520,83 +763,37 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// PROF-002: District picker
|
// PROF-002: District — tap to open dedicated picker sheet
|
||||||
Align(
|
GestureDetector(
|
||||||
alignment: Alignment.centerLeft,
|
onTap: () {
|
||||||
child: Text(
|
Navigator.of(context).pop();
|
||||||
'District',
|
_showDistrictPicker();
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
},
|
||||||
fontWeight: FontWeight.w600,
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: theme.dividerColor),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// AUTH-005: Cooldown lock indicator
|
|
||||||
if (_districtLocked)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.lock_outline,
|
const Icon(Icons.location_on_outlined, size: 18),
|
||||||
size: 14, color: Colors.amber),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 4),
|
Expanded(
|
||||||
Text(
|
child: Text(
|
||||||
'District locked until $_districtNextChange',
|
_district ?? 'Tap to set district',
|
||||||
style: const TextStyle(
|
style: theme.textTheme.bodyMedium,
|
||||||
fontSize: 12, color: Colors.amber),
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
_districtLocked ? Icons.lock_outline : Icons.chevron_right,
|
||||||
|
size: 18,
|
||||||
|
color: _districtLocked ? Colors.amber : theme.hintColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 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),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -616,11 +813,13 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
|
|
||||||
Widget _buildProfileAvatar({double size = 96}) {
|
Widget _buildProfileAvatar({double size = 96}) {
|
||||||
final path = _profileImage.trim();
|
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;
|
String? imageUrl;
|
||||||
if (path.startsWith('http')) {
|
if (path.isNotEmpty && !path.contains('default.png')) {
|
||||||
imageUrl = path;
|
if (path.startsWith('http')) {
|
||||||
|
imageUrl = path;
|
||||||
|
} else if (path.startsWith('/media/')) {
|
||||||
|
imageUrl = 'https://em.eventifyplus.com$path';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return TierAvatarRing(
|
return TierAvatarRing(
|
||||||
username: _username.isNotEmpty ? _username : _email,
|
username: _username.isNotEmpty ? _username : _email,
|
||||||
@@ -876,17 +1075,26 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Location (District)
|
// Location (District) — tappable to open picker
|
||||||
Row(
|
GestureDetector(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
onTap: _showDistrictPicker,
|
||||||
children: [
|
child: Row(
|
||||||
const Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Text(
|
const Icon(Icons.location_on_outlined, color: Colors.white, size: 18),
|
||||||
p?.district ?? _district ?? 'No district selected',
|
const SizedBox(width: 4),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
|
Text(
|
||||||
),
|
_district ?? 'Tap to set district',
|
||||||
],
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
_districtLocked ? Icons.lock_outline : Icons.edit_outlined,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_districtNextChange.isNotEmpty)
|
if (_districtNextChange.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
@@ -935,23 +1143,11 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildHeaderButton(
|
_buildHeaderButton(
|
||||||
label: 'Share Rank',
|
label: _sharingRank ? 'Generating...' : 'Share Rank',
|
||||||
icon: Icons.share_outlined,
|
icon: _sharingRank ? Icons.hourglass_top_outlined : Icons.share_outlined,
|
||||||
onTap: () {
|
onTap: _sharingRank
|
||||||
showDialog(
|
? () {}
|
||||||
context: context,
|
: () => _shareRank(tier, p?.lifetimeEp ?? 0),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1295,28 +1491,22 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
// Share rank card button
|
// Share rank card button
|
||||||
Consumer<GamificationProvider>(
|
Consumer<GamificationProvider>(
|
||||||
builder: (context, gam, _) => GestureDetector(
|
builder: (context, gam, _) => GestureDetector(
|
||||||
onTap: () {
|
onTap: _sharingRank
|
||||||
showDialog(
|
? null
|
||||||
context: context,
|
: () => _shareRank(
|
||||||
builder: (_) => Dialog(
|
gam.currentUserStats?.level ?? _userTier ?? 'Bronze',
|
||||||
backgroundColor: Colors.transparent,
|
gam.profile?.lifetimeEp ?? 0,
|
||||||
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(
|
child: Container(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white24,
|
color: Colors.white24,
|
||||||
borderRadius: BorderRadius.circular(10)),
|
borderRadius: BorderRadius.circular(10)),
|
||||||
child: const Icon(Icons.share_outlined, color: Colors.white),
|
child: Icon(
|
||||||
|
_sharingRank ? Icons.hourglass_top_outlined : Icons.share_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1934,35 +2124,37 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
Widget _buildDesktopLayout(BuildContext context, ThemeData theme) {
|
Widget _buildDesktopLayout(BuildContext context, ThemeData theme) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
body: SingleChildScrollView(
|
body: Consumer<GamificationProvider>(
|
||||||
physics: const BouncingScrollPhysics(),
|
builder: (context, gam, _) {
|
||||||
child: Column(
|
return SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [
|
child: Column(
|
||||||
// Full-width profile header + card (reuse existing widgets)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Stack(
|
|
||||||
children: [
|
children: [
|
||||||
_buildGradientHeader(context, 200),
|
// Full-width profile header + card (reuse existing widgets)
|
||||||
Padding(
|
Stack(
|
||||||
padding: const EdgeInsets.only(top: 130),
|
children: [
|
||||||
child: _buildProfileCard(context),
|
_buildGradientHeader(context, 200),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 130),
|
||||||
|
child: _buildProfileCard(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Ongoing Events (only if non-empty)
|
||||||
|
if (_ongoingEvents.isNotEmpty)
|
||||||
|
_buildDesktopEventSection(
|
||||||
|
context,
|
||||||
|
title: 'Ongoing Events',
|
||||||
|
events: _ongoingEvents,
|
||||||
|
faded: false,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
|
},
|
||||||
// Ongoing Events (only if non-empty)
|
|
||||||
if (_ongoingEvents.isNotEmpty)
|
|
||||||
_buildDesktopEventSection(
|
|
||||||
context,
|
|
||||||
title: 'Ongoing Events',
|
|
||||||
events: _ongoingEvents,
|
|
||||||
faded: false,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2237,24 +2429,16 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
|
|
||||||
// ── MOBILE layout ─────────────────────────────────────────────────────
|
// ── MOBILE layout ─────────────────────────────────────────────────────
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF9FAFB), // Softer light bg
|
backgroundColor: const Color(0xFFF9FAFB),
|
||||||
body: Consumer<GamificationProvider>(
|
body: Consumer<GamificationProvider>(
|
||||||
builder: (context, gam, _) {
|
builder: (context, gam, _) {
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(child: _buildModernHeader(context, theme)),
|
||||||
child: _buildModernHeader(context, theme),
|
SliverToBoxAdapter(child: _buildModernStatCards(gam, theme)),
|
||||||
),
|
SliverToBoxAdapter(child: _buildTierProgressCard(gam, theme)),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(child: _buildContributedEventsSection(gam, theme)),
|
||||||
child: _buildModernStatCards(gam, theme),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _buildTierProgressCard(gam, theme),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _buildContributedEventsSection(gam, theme),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user