공개페이지 완료

This commit is contained in:
2025-11-10 14:14:08 +09:00
parent 2ce0b81483
commit ac43ddb55e
67 changed files with 3220 additions and 224696 deletions
+744
View File
@@ -0,0 +1,744 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lanebow/theme/badges.dart';
import 'package:lanebow/utils/enum_mappings.dart';
class PublicUserNameRow extends StatelessWidget {
final String name;
final String? gender;
final String? memberType;
final int? userHandicap;
final num? userAverage;
final int? rank; // 1: 금, 2: 은, 3: 동
const PublicUserNameRow({
super.key,
required this.name,
this.gender,
this.memberType,
this.userHandicap,
this.userAverage,
this.rank,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
if (rank != null) ...[_rankBadge(rank!), const SizedBox(width: 6)],
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors
.black, // DefaultTextStyle.of(context).style.color 사용도 가능
),
children: [
TextSpan(text: name),
const WidgetSpan(child: SizedBox(width: 4)), // 이름-아이콘 간격
WidgetSpan(
alignment: PlaceholderAlignment.middle, // 베이스라인 정렬
child: _genderIcon(gender),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
_hcBadge(userHandicap),
if (userAverage != null) ...[
const SizedBox(width: 4),
_avgBadge(userAverage),
],
],
);
}
Widget _genderIcon(String? gender) {
final g = (gender ?? '').toLowerCase();
final isFemale = g == 'female' || g == 'f' || g == '' || g == '여성';
final icon = isFemale ? Icons.female : Icons.male;
final color = isFemale ? Colors.pink : Colors.blue;
return CircleAvatar(
radius: 10,
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, size: 14, color: color),
);
}
Widget _avgBadge(num? avg) {
if (avg == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.withOpacity(0.25)),
),
child: Text(
(avg is double ? avg : avg.toDouble()).toStringAsFixed(1),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.indigo,
),
),
);
}
Widget _hcBadge(int? hc) {
if (hc == null || hc == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.deepPurple.withOpacity(0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.deepPurple.withOpacity(0.25)),
),
child: Text(
hc > 0 ? '+$hc' : '$hc',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.deepPurple,
),
),
);
}
Widget _rankBadge(int r) {
String label;
Color bg;
Color fg = Colors.black87;
if (r == 1) {
label = r.toString();
bg = Colors.amber.shade200;
} else if (r == 2) {
label = r.toString();
bg = Colors.blueGrey.shade200;
} else if (r == 3) {
label = r.toString();
bg = Colors.brown.shade200;
} else {
label = r.toString();
bg = Colors.grey.shade300;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: fg),
),
);
}
}
class PublicUserGameEditors extends StatelessWidget {
final int gameCount;
final String participantId;
final List<int?> rawScores;
final List<int> handicaps;
final bool enabled;
final String Function(String pid, int gameNumber) keyFor;
final Widget Function(String key) stateIcon;
final void Function(int gameNumber, int value) onScoreChanged;
final void Function(int gameNumber, int value) onHandicapChanged;
final double? eventAverage; // optional, to show at right
final int? memberHandicap; // optional default per-game handicap
const PublicUserGameEditors({
super.key,
required this.gameCount,
required this.participantId,
required this.rawScores,
required this.handicaps,
required this.enabled,
required this.keyFor,
required this.stateIcon,
required this.onScoreChanged,
required this.onHandicapChanged,
this.eventAverage,
this.memberHandicap,
});
bool _hasRaw(int g) =>
(g - 1 >= 0 && g - 1 < rawScores.length && rawScores[g - 1] != null);
int _rawAt(int g) => _hasRaw(g) ? rawScores[g - 1]! : 0;
int _hcAt(int g) {
int hv = (g - 1 >= 0 && g - 1 < handicaps.length) ? handicaps[g - 1] : 0;
if (hv == 0 && (memberHandicap ?? 0) != 0) return memberHandicap!;
return hv;
}
int _overallRawTotal() {
int s = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) s += _rawAt(g);
}
return s;
}
double? _avgRaw() {
int count = 0;
int sum = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) {
sum += _rawAt(g);
count++;
}
}
if (count == 0) return null;
return sum / count;
}
bool _hasAnyPerGameHandicap() {
// Consider effective handicap (per-game or member fallback)
for (int g = 1; g <= gameCount; g++) {
if (_hcAt(g) != 0) return true;
}
return false;
}
int _totalWithPerGameHcAt(int g) {
if (!_hasRaw(g)) return 0;
// Use effective handicap (includes memberHandicap fallback)
return _rawAt(g) + _hcAt(g);
}
int _overallWithPerGameHcTotal() {
int s = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) s += _totalWithPerGameHcAt(g);
}
return s;
}
double? _avgWithPerGameHc() {
int count = 0;
int sum = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) {
sum += _totalWithPerGameHcAt(g);
count++;
}
}
if (count == 0) return null;
return sum / count;
}
Color _valueColor(num v) => v >= 200 ? Colors.red : Colors.black87;
int _playedGames() {
int c = 0;
for (int g = 1; g <= gameCount; g++) {
if (_hasRaw(g)) c++;
}
return c;
}
Color _totalColor(int total) {
final n = _playedGames();
if (n <= 0) return Colors.blueGrey;
final thresh = 200 * n;
return total >= thresh ? Colors.red : Colors.blueGrey;
}
@override
Widget build(BuildContext context) {
if (!enabled) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int g = 1; g <= gameCount; g++)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 60,
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
vertical: 2,
),
alignment: Alignment.center,
child: Text(
_hasRaw(g) ? _rawAt(g).toString() : '',
style: TextStyle(
fontSize: _hcAt(g) != 0 ? 11 : 17,
fontWeight: _hcAt(g) != 0
? FontWeight.w300
: FontWeight.w700,
color: _valueColor(_rawAt(g)),
),
),
),
// tighter spacing between raw and handicap
if (_hcAt(g) != 0)
Text(
(_hcAt(g) > 0
? '+${_hcAt(g)}'
: _hcAt(g).toString()),
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
height: 1.0,
),
),
if (_hcAt(g) != 0) ...[
Align(
alignment: Alignment.centerRight,
child: Transform.rotate(
angle: -0.2,
child: Container(
width: 40,
height: 1,
color: Colors.black26,
),
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
_totalWithPerGameHcAt(g).toString(),
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: _valueColor(
_totalWithPerGameHcAt(g),
),
),
),
),
],
],
),
),
),
],
),
),
),
const SizedBox(width: 4),
Align(
alignment: Alignment.topRight,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.10),
border: Border.all(
color: Colors.blueGrey.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final raw = _overallRawTotal();
if (hasGameHc) {
final withHc = _overallWithPerGameHcTotal();
if (withHc != raw) {
return Text(
'$raw ($withHc)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _totalColor(withHc),
),
);
}
}
return Text(
'$raw',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _totalColor(raw),
),
);
},
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
border: Border.all(
color: Colors.indigo.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final rawAvg = (eventAverage ?? _avgRaw() ?? 0)
.toStringAsFixed(1);
if (hasGameHc) {
final withHcAvg = (_avgWithPerGameHc() ?? 0)
.toStringAsFixed(1);
if (withHcAvg != rawAvg) {
return Text(
'$rawAvg ($withHcAvg)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(withHcAvg) ?? 0,
),
),
);
}
}
return Text(
rawAvg,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(rawAvg) ?? 0,
),
),
);
},
),
),
],
),
),
),
],
),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int g = 1; g <= gameCount; g++)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 60,
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
TextFormField(
key: Key(
'cell_${participantId}_${g}_${_rawAt(g)}',
),
initialValue:
(g - 1 < rawScores.length &&
rawScores[g - 1] != null)
? rawScores[g - 1].toString()
: '',
keyboardType: TextInputType.number,
style: TextStyle(
fontSize: _hcAt(g) != 0 ? 12 : 14,
fontWeight: FontWeight.w600,
color: _valueColor(_rawAt(g)),
),
decoration: InputDecoration(
isDense: true,
labelText: '${g}G 점수',
floatingLabelBehavior:
FloatingLabelBehavior.auto,
labelStyle: const TextStyle(fontSize: 10),
floatingLabelStyle: const TextStyle(
fontSize: 10,
),
hintText: '${g}G 점수',
hintStyle: const TextStyle(fontSize: 9),
counterText: '',
contentPadding:
const EdgeInsets.symmetric(
vertical: 6,
horizontal: 8,
),
),
maxLength: 3,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: enabled,
onChanged: (v) {
if (v.isEmpty) {
onScoreChanged(g, 0);
return;
}
final val = int.tryParse(v);
if (val != null &&
val >= 0 &&
val <= 300) {
onScoreChanged(g, val);
}
},
),
Positioned(
top: -8,
right: -8,
child: IgnorePointer(
child: stateIcon(
keyFor(participantId, g),
),
),
),
],
),
const SizedBox(height: 4),
TextFormField(
key: Key(
'cell_hc_${participantId}_${g}_${_hcAt(g)}',
),
initialValue: (() {
int hv = (g - 1 < handicaps.length)
? handicaps[g - 1]
: 0;
if (hv == 0 && (memberHandicap ?? 0) != 0) {
return memberHandicap!.toString();
}
return hv.toString();
})(),
keyboardType: TextInputType.number,
decoration: InputDecoration(
isDense: true,
labelText: '${g}G 핸디캡',
floatingLabelBehavior:
FloatingLabelBehavior.auto,
labelStyle: const TextStyle(fontSize: 10),
floatingLabelStyle: const TextStyle(
fontSize: 10,
),
hintText: '${g}G 핸디캡',
hintStyle: const TextStyle(fontSize: 9),
counterText: '',
contentPadding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
),
maxLength: 3,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
enabled: enabled,
onChanged: (v) {
if (v.isEmpty) {
onHandicapChanged(g, 0);
return;
}
final val = int.tryParse(v);
if (val != null && val >= 0 && val <= 100) {
onHandicapChanged(g, val);
}
},
),
const SizedBox(height: 2),
if (_hcAt(g) != 0) ...[
Align(
alignment: Alignment.centerRight,
child: Transform.rotate(
angle: -0.2,
child: Container(
width: 40,
height: 1,
color: Colors.black26,
),
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
_totalWithPerGameHcAt(g).toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: _valueColor(
_totalWithPerGameHcAt(g),
),
),
),
),
],
],
),
),
),
],
),
),
),
const SizedBox(width: 4),
Align(
alignment: Alignment.topRight,
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.blueGrey.withOpacity(0.10),
border: Border.all(
color: Colors.blueGrey.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final raw = _overallRawTotal();
if (hasGameHc) {
final withHc = _overallWithPerGameHcTotal();
if (withHc != raw) {
return Text(
'$raw ($withHc)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _totalColor(withHc),
),
);
}
}
return Text(
'$raw',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _totalColor(raw),
),
);
},
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.indigo.withOpacity(0.10),
border: Border.all(
color: Colors.indigo.withOpacity(0.25),
),
borderRadius: BorderRadius.circular(12),
),
child: Builder(
builder: (context) {
final hasGameHc = _hasAnyPerGameHandicap();
final rawAvg = (eventAverage ?? _avgRaw() ?? 0)
.toStringAsFixed(1);
if (hasGameHc) {
final withHcAvg = (_avgWithPerGameHc() ?? 0)
.toStringAsFixed(1);
if (withHcAvg != rawAvg) {
return Text(
'$rawAvg ($withHcAvg)',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _valueColor(
double.tryParse(withHcAvg) ?? 0,
),
),
);
}
}
return Text(
rawAvg,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _valueColor(double.tryParse(rawAvg) ?? 0),
),
);
},
),
),
],
),
),
),
],
),
],
);
}
}
class PublicUserChipsRow extends StatelessWidget {
final String? status;
final String? paymentStatus;
final String? memberType;
const PublicUserChipsRow({
super.key,
this.status,
this.paymentStatus,
this.memberType,
});
bool _isPaid(String? paymentStatus) {
final s = (paymentStatus ?? '').toLowerCase();
return s == 'paid' || s == '납부' || s == '납부완료' || s == '완납';
}
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 6,
runSpacing: 6,
children: [
BadgeStyles.participantStatus(toKoreanParticipantStatus(status)),
if ((paymentStatus ?? '').isNotEmpty)
BadgeStyles.payment(_isPaid(paymentStatus)),
if ((memberType ?? '').isNotEmpty)
BadgeStyles.memberType(getMemberTypeLabel(memberType)),
],
);
}
}