import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:data_table_2/data_table_2.dart'; import 'package:lanebow/models/public_event.dart'; import 'package:lanebow/models/public_participant.dart'; import 'package:lanebow/models/public_team.dart'; import 'package:lanebow/widgets/public_user_widgets.dart'; import 'package:lanebow/theme/badges.dart'; class PersonalCardView extends StatelessWidget { final PublicEvent? event; final List participants; final Map partRankMap; final bool canInputScore; final List teams; final String Function(String pid, int gameNumber) keyFor; final Widget Function(String key) stateIcon; final void Function(PublicParticipant p, int gameNumber, int value) saveInlineScore; final void Function(PublicParticipant p, int gameNumber, int value) saveInlineHandicap; final num? Function(List rawScores) calcAvg; final int? Function(String pid, Map map) rankForParticipant; const PersonalCardView({ super.key, required this.event, required this.participants, required this.partRankMap, required this.canInputScore, required this.teams, required this.keyFor, required this.stateIcon, required this.saveInlineScore, required this.saveInlineHandicap, required this.calcAvg, required this.rankForParticipant, }); @override Widget build(BuildContext context) { // 팀의 per-game 핸디 우선 적용을 위해 map 빌드 final Map> teamHcByPid = {}; for (final t in teams) { for (final m in t.members) { if ((m.participantId ?? '').isNotEmpty) { teamHcByPid[m.participantId!] = List.from(m.handicaps); } } } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), const Text( '참가자', style: TextStyle(fontWeight: FontWeight.bold), key: Key('section_participants'), ), Text('총 ${participants.length}명', key: const Key('participants_count')), const SizedBox(height: 8), Expanded( child: Card( child: participants.isEmpty ? const ListTile( key: Key('empty_participants'), title: Text('참가자가 없습니다'), ) : ListView.separated( padding: EdgeInsets.zero, itemCount: participants.length, separatorBuilder: (_, i) => const Padding( padding: EdgeInsets.symmetric(horizontal: 8), child: Divider(height: 12, thickness: 0.6), ), itemBuilder: (_, i) { final p = participants[i]; return ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), key: Key('participant_tile_${p.participantId}'), title: PublicUserNameRow( name: p.name, gender: p.gender, memberType: p.memberType, userHandicap: p.handicap, userAverage: p.average, rank: rankForParticipant( p.participantId, partRankMap, ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ PublicUserChipsRow( status: p.status, paymentStatus: p.paymentStatus, memberType: p.memberType, ), if (event != null) Padding( padding: const EdgeInsets.only(top: 8), child: PublicUserGameEditors( gameCount: event!.gameCount, participantId: p.participantId, rawScores: p.rawScores, handicaps: teamHcByPid[p.participantId] ?? p.handicaps, enabled: canInputScore, keyFor: keyFor, stateIcon: stateIcon, onScoreChanged: (g, val) => saveInlineScore(p, g, val), onHandicapChanged: (g, val) => saveInlineHandicap(p, g, val), eventAverage: calcAvg( p.rawScores, )?.toDouble(), memberHandicap: p.handicap, ), ), ], ), ); }, ), ), ), ], ); } } class PersonalTableView extends StatefulWidget { final PublicEvent event; final List participants; final bool canInputScore; final List teams; final void Function(PublicParticipant p, int gameNumber, int value) saveInlineScore; final void Function(PublicParticipant p, int gameNumber, int value) saveInlineHandicap; final Map? partRankMap; final int? Function(String pid, Map map)? rankForParticipant; const PersonalTableView({ super.key, required this.event, required this.participants, required this.canInputScore, required this.teams, required this.saveInlineScore, required this.saveInlineHandicap, this.partRankMap, this.rankForParticipant, }); @override State createState() => _PersonalTableViewState(); } class _PersonalTableViewState extends State { int? _sortColumnIndex = 0; bool _sortAscending = true; List _rows = []; Future _openEditSheet({ required PublicParticipant participant, required int gameNumber, required int? currentRaw, required int currentHc, }) async { final rawCtrl = TextEditingController( text: currentRaw == null || currentRaw == 0 ? '' : '$currentRaw', ); final hcCtrl = TextEditingController( text: currentHc == 0 ? '' : '$currentHc', ); Future _commit(BuildContext ctx) async { FocusScope.of(ctx).unfocus(); final rawVal = int.tryParse(rawCtrl.text.trim()) ?? 0; final hcVal = int.tryParse(hcCtrl.text.trim()) ?? 0; final prevRaw = currentRaw ?? 0; final prevHc = currentHc; final changedRaw = rawVal != prevRaw; final changedHc = hcVal != prevHc; if (changedRaw && !changedHc) { widget.saveInlineScore(participant, gameNumber, rawVal); } else if (!changedRaw && changedHc) { widget.saveInlineHandicap(participant, gameNumber, hcVal); } else if (changedRaw && changedHc) { // Debouncer key is the same for score/handicap; sequence to avoid cancellation widget.saveInlineScore(participant, gameNumber, rawVal); await Future.delayed(const Duration(milliseconds: 650)); widget.saveInlineHandicap(participant, gameNumber, hcVal); } Navigator.pop(ctx, true); } final res = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (ctx) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 16, right: 16, top: 16, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: TextFormField( controller: rawCtrl, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(labelText: '점수'), onFieldSubmitted: (_) => _commit(ctx), ), ), const SizedBox(width: 12), Expanded( child: TextFormField( controller: hcCtrl, keyboardType: TextInputType.number, textInputAction: TextInputAction.done, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(labelText: '핸디'), onFieldSubmitted: (_) => _commit(ctx), ), ), ], ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('취소'), ), const SizedBox(width: 8), ElevatedButton( onPressed: () => _commit(ctx), child: const Text('저장'), ), ], ), const SizedBox(height: 12), ], ), ); }, ); if (res == true) { setState(() {}); } } @override void initState() { super.initState(); _rows = List.of(widget.participants); final rf = widget.rankForParticipant; final prm = widget.partRankMap; if (rf != null && prm != null) { _rows.sort((a, b) { final ra = (rf(a.participantId, prm) ?? 99999); final rb = (rf(b.participantId, prm) ?? 99999); return ra.compareTo(rb); }); } } @override void didUpdateWidget(covariant PersonalTableView oldWidget) { super.didUpdateWidget(oldWidget); if (!identical(oldWidget.participants, widget.participants) || oldWidget.participants.length != widget.participants.length) { _rows = List.of(widget.participants); final rf = widget.rankForParticipant; final prm = widget.partRankMap; if (rf != null && prm != null) { _rows.sort((a, b) { final ra = (rf(a.participantId, prm) ?? 99999); final rb = (rf(b.participantId, prm) ?? 99999); return ra.compareTo(rb); }); } } } @override Widget build(BuildContext context) { final event = widget.event; final participants = widget.participants; final bool enabled = widget.canInputScore && (event.isActive == true); final teams = widget.teams; final partRankMap = widget.partRankMap; final rankForParticipant = widget.rankForParticipant; final List rows = _rows; final Map> teamHcByPid = {}; final Map teamLabelByPid = {}; for (final t in teams) { for (final m in t.members) { if ((m.participantId ?? '').isNotEmpty) { teamHcByPid[m.participantId!] = List.from(m.handicaps); teamLabelByPid[m.participantId!] = '팀 ${t.teamNumber}'; } } } const double headH = 40.0; const double rowH = 100.0; int rawAt(List raw, int g) => (g - 1 >= 0 && g - 1 < raw.length && raw[g - 1] != null) ? raw[g - 1]! : 0; bool hasRaw(List raw, int g) => (g - 1 >= 0 && g - 1 < raw.length && raw[g - 1] != null); int hcAt(List perGame, int? memberHc, int g) { int hv = (g - 1 >= 0 && g - 1 < perGame.length) ? perGame[g - 1] : 0; if (hv == 0 && (memberHc ?? 0) != 0) return memberHc!; return hv; } int overallRawTotal(List raw, int gameCount) { int s = 0; for (int g = 1; g <= gameCount; g++) { if (hasRaw(raw, g)) s += rawAt(raw, g); } return s; } int overallWithHcTotal( List raw, List perGame, int? memberHc, int gameCount, ) { int s = 0; for (int g = 1; g <= gameCount; g++) { if (hasRaw(raw, g)) s += rawAt(raw, g) + hcAt(perGame, memberHc, g); } return s; } double? avgRaw(List raw, int gameCount) { int count = 0, sum = 0; for (int g = 1; g <= gameCount; g++) { if (hasRaw(raw, g)) { sum += rawAt(raw, g); count++; } } if (count == 0) return null; return sum / count; } double? avgWithHc( List raw, List perGame, int? memberHc, int gameCount, ) { int count = 0, sum = 0; for (int g = 1; g <= gameCount; g++) { if (hasRaw(raw, g)) { sum += rawAt(raw, g) + hcAt(perGame, memberHc, g); count++; } } if (count == 0) return null; return sum / count; } Color valueColor(num v) => v >= 200 ? Colors.red : Colors.black87; int playedGames(List raw, int gameCount) { int c = 0; for (int g = 1; g <= gameCount; g++) { if (hasRaw(raw, g)) c++; } return c; } Color totalColor(int total, int nGames) { if (nGames <= 0) return Colors.blueGrey; final thresh = 200 * nGames; return total >= thresh ? Colors.red : Colors.blueGrey; } 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: 8, backgroundColor: color.withValues(alpha: 0.1), child: Icon(icon, size: 12, color: color), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), const Text( '개인별 (표 보기)', style: TextStyle(fontWeight: FontWeight.bold), key: Key('participants_table_title'), ), Text( '총 ${participants.length}명', key: const Key('participants_table_count'), ), const SizedBox(height: 8), Expanded( child: DataTable2( headingRowHeight: headH, horizontalMargin: 0, columnSpacing: 8, dataRowHeight: rowH, minWidth: 600, fixedTopRows: 1, fixedLeftColumns: 2, sortColumnIndex: _sortColumnIndex, sortAscending: _sortAscending, border: TableBorder.all(color: Colors.black12, width: 0.5), columns: [ DataColumn2( fixedWidth: 55, label: const Center(child: Text('순위')), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort((a, b) { final ra = (rankForParticipant?.call( a.participantId, partRankMap ?? const {}, ) ?? 99999); final rb = (rankForParticipant?.call( b.participantId, partRankMap ?? const {}, ) ?? 99999); return asc ? ra.compareTo(rb) : rb.compareTo(ra); }); }); }, numeric: true, ), DataColumn2( size: ColumnSize.L, label: const Text('이름'), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort( (a, b) => asc ? a.name.compareTo(b.name) : b.name.compareTo(a.name), ); }); }, ), DataColumn2( size: ColumnSize.S, label: const Text('팀'), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort((a, b) { final ta = teamLabelByPid[a.participantId] ?? ''; final tb = teamLabelByPid[b.participantId] ?? ''; return asc ? ta.compareTo(tb) : tb.compareTo(ta); }); }); }, ), for (int g = 1; g <= event.gameCount; g++) DataColumn( label: Text('${g}G'), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort((a, b) { final perA = teamHcByPid[a.participantId] ?? a.handicaps; final perB = teamHcByPid[b.participantId] ?? b.handicaps; final sa = (hasRaw(a.rawScores, g) ? rawAt(a.rawScores, g) + hcAt(perA, a.handicap, g) : -999999); final sb = (hasRaw(b.rawScores, g) ? rawAt(b.rawScores, g) + hcAt(perB, b.handicap, g) : -999999); return asc ? sa.compareTo(sb) : sb.compareTo(sa); }); }); }, numeric: true, ), DataColumn( label: const Text('총점'), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort((a, b) { final perGameA = teamHcByPid[a.participantId] ?? a.handicaps; final perGameB = teamHcByPid[b.participantId] ?? b.handicaps; final ta = overallWithHcTotal( a.rawScores, perGameA, a.handicap, event.gameCount, ); final tb = overallWithHcTotal( b.rawScores, perGameB, b.handicap, event.gameCount, ); return asc ? ta.compareTo(tb) : tb.compareTo(ta); }); }); }, numeric: true, ), DataColumn( label: const Text('에버리지'), onSort: (i, asc) { setState(() { _sortColumnIndex = i; _sortAscending = asc; rows.sort((a, b) { final perGameA = teamHcByPid[a.participantId] ?? a.handicaps; final perGameB = teamHcByPid[b.participantId] ?? b.handicaps; final aa = (avgWithHc( a.rawScores, perGameA, a.handicap, event.gameCount, ) ?? 0); final ab = (avgWithHc( b.rawScores, perGameB, b.handicap, event.gameCount, ) ?? 0); return asc ? aa.compareTo(ab) : ab.compareTo(aa); }); }); }, numeric: true, ), ], rows: [ for (final p in rows) DataRow( cells: [ DataCell( SizedBox( width: 28, child: Center( child: Text( (rankForParticipant?.call( p.participantId, partRankMap ?? const {}, ) ?? 0) .toString(), style: const TextStyle(fontWeight: FontWeight.w600), ), ), ), ), DataCell( Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ Row( children: [ genderIcon(p.gender), const SizedBox(width: 6), Flexible( child: Text( p.name, overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 2), Builder( builder: (context) { final String? avgText = (p.average == null) ? null : ((p.average is double) ? (p.average as double) : (p.average as num).toDouble()) .toStringAsFixed(1); final String? hcText = (p.handicap == 0) ? null : (p.handicap > 0 ? '+${p.handicap}' : '${p.handicap}'); return Wrap( spacing: 4, runSpacing: 2, children: [ if (hcText != null) Chip( label: Text( hcText, style: const TextStyle( fontSize: 10, height: 1.0, ), ), backgroundColor: BadgeStyles.successBg, padding: EdgeInsets.zero, labelPadding: const EdgeInsets.symmetric( horizontal: 4, vertical: 0, ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity( horizontal: -4, vertical: -4, ), ), if (avgText != null) Chip( label: Text( avgText, style: const TextStyle( fontSize: 10, height: 1.0, ), ), backgroundColor: BadgeStyles.primaryBg, padding: EdgeInsets.zero, labelPadding: const EdgeInsets.symmetric( horizontal: 4, vertical: 0, ), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity( horizontal: -4, vertical: -4, ), ), ], ); }, ), ], ), ), DataCell( Text( teamLabelByPid[p.participantId] ?? '', overflow: TextOverflow.ellipsis, ), ), for (int g = 1; g <= event.gameCount; g++) DataCell( Builder( builder: (context) { final perGame = teamHcByPid[p.participantId] ?? p.handicaps; final effHc = hcAt(perGame, p.handicap, g); final has = hasRaw(p.rawScores, g); if (!has) { return const SizedBox.expand( child: Center(child: Text('')), ); } final raw = rawAt(p.rawScores, g); final sum = raw + effHc; if (effHc == 0) { return SizedBox.expand( child: Center( child: Text( '$raw', style: const TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: Colors.black87, ), ), ), ); } return SizedBox.expand( child: Stack( children: [ Positioned.fill( child: CustomPaint( painter: _DiagonalSlashPainter( Colors.black26, ), ), ), Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only( left: 2, top: 2, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( '$raw', style: const TextStyle( fontWeight: FontWeight.w600, color: Colors.black87, ), ), Text( '+$effHc', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey.shade700, ), ), ], ), ), ), Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only( right: 2, bottom: 2, ), child: Text( '$sum', textAlign: TextAlign.right, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: valueColor(sum), ), ), ), ), ], ), ); }, ), onTap: enabled ? () { final perGame = teamHcByPid[p.participantId] ?? p.handicaps; final hc = hcAt(perGame, p.handicap, g); final has = hasRaw(p.rawScores, g); final current = has ? rawAt(p.rawScores, g) : null; _openEditSheet( participant: p, gameNumber: g, currentRaw: current, currentHc: hc, ); } : null, ), DataCell( Builder( builder: (context) { final perGame = teamHcByPid[p.participantId] ?? p.handicaps; final raw = overallRawTotal( p.rawScores, event.gameCount, ); final withHc = overallWithHcTotal( p.rawScores, perGame, p.handicap, event.gameCount, ); final n = playedGames(p.rawScores, event.gameCount); if (withHc != raw) { return SizedBox.expand( child: Stack( children: [ Positioned.fill( child: CustomPaint( painter: _DiagonalSlashPainter( Colors.black26, ), ), ), Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only( left: 2, top: 2, ), child: Text( '$raw', style: TextStyle( fontWeight: FontWeight.w600, color: totalColor(raw, n), ), ), ), ), Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only( right: 2, bottom: 2, ), child: Text( '$withHc', textAlign: TextAlign.right, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: totalColor(withHc, n), ), ), ), ), ], ), ); } return SizedBox.expand( child: Center( child: Text( '$raw', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: totalColor(raw, n), ), ), ), ); }, ), ), DataCell( Builder( builder: (context) { final perGame = teamHcByPid[p.participantId] ?? p.handicaps; final rawAvg = (avgRaw(p.rawScores, event.gameCount) ?? 0) .toStringAsFixed(1); final withHcAvg = (avgWithHc( p.rawScores, perGame, p.handicap, event.gameCount, ) ?? 0) .toStringAsFixed(1); final avgNum = double.tryParse(withHcAvg) ?? double.tryParse(rawAvg) ?? 0; if (withHcAvg != rawAvg) { return SizedBox.expand( child: Stack( children: [ Positioned.fill( child: CustomPaint( painter: _DiagonalSlashPainter( Colors.black26, ), ), ), Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.only( left: 2, top: 2, ), child: Text( rawAvg, style: TextStyle( fontWeight: FontWeight.w600, color: valueColor( double.tryParse(rawAvg) ?? 0, ), ), ), ), ), Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only( right: 2, bottom: 2, ), child: Text( withHcAvg, textAlign: TextAlign.right, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: valueColor( double.tryParse(withHcAvg) ?? 0, ), ), ), ), ), ], ), ); } return SizedBox.expand( child: Center( child: Text( rawAvg, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 18, color: valueColor(avgNum), ), ), ), ); }, ), ), ], ), ], ), ), ], ); } } class _DiagonalSlashPainter extends CustomPainter { final Color color; const _DiagonalSlashPainter(this.color); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..strokeWidth = 1.0 ..style = PaintingStyle.stroke; // Draw diagonal from bottom-left to top-right canvas.drawLine(Offset(0, size.height), Offset(size.width, 0), paint); } @override bool shouldRepaint(covariant _DiagonalSlashPainter oldDelegate) { return oldDelegate.color != color; } }