Files
bowlingManager/mobile/lib/screens/public_event/widgets/personal_views.dart
T
2025-11-10 14:14:08 +09:00

1004 lines
40 KiB
Dart

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<PublicParticipant> participants;
final Map<String, Map> partRankMap;
final bool canInputScore;
final List<PublicTeam> 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<int?> rawScores) calcAvg;
final int? Function(String pid, Map<String, 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<String, List<int>> teamHcByPid = {};
for (final t in teams) {
for (final m in t.members) {
if ((m.participantId ?? '').isNotEmpty) {
teamHcByPid[m.participantId!] = List<int>.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<PublicParticipant> participants;
final bool canInputScore;
final List<PublicTeam> teams;
final void Function(PublicParticipant p, int gameNumber, int value)
saveInlineScore;
final void Function(PublicParticipant p, int gameNumber, int value)
saveInlineHandicap;
final Map<String, Map>? partRankMap;
final int? Function(String pid, Map<String, 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<PersonalTableView> createState() => _PersonalTableViewState();
}
class _PersonalTableViewState extends State<PersonalTableView> {
int? _sortColumnIndex = 0;
bool _sortAscending = true;
List<PublicParticipant> _rows = [];
Future<void> _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<void> _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<bool>(
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<PublicParticipant> rows = _rows;
final Map<String, List<int>> teamHcByPid = {};
final Map<String, String> teamLabelByPid = {};
for (final t in teams) {
for (final m in t.members) {
if ((m.participantId ?? '').isNotEmpty) {
teamHcByPid[m.participantId!] = List<int>.from(m.handicaps);
teamLabelByPid[m.participantId!] = '팀 ${t.teamNumber}';
}
}
}
const double headH = 40.0;
const double rowH = 100.0;
int rawAt(List<int?> raw, int g) =>
(g - 1 >= 0 && g - 1 < raw.length && raw[g - 1] != null)
? raw[g - 1]!
: 0;
bool hasRaw(List<int?> raw, int g) =>
(g - 1 >= 0 && g - 1 < raw.length && raw[g - 1] != null);
int hcAt(List<int> 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<int?> 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<int?> raw,
List<int> 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<int?> 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<int?> raw,
List<int> 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<int?> 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;
}
}