Files
archery-bookkeeper/lib/presentation/pages/scoring_page.dart
T
Mikkeli Matlock 398ac5af63 Implement persistent state across navigation views
Lifts scoring state from ScoringPage to MainNavigationPage level to preserve
scores and rule modes when switching between navigation views.

State management improvements:
- MainNavigationPage now manages persistent scoring state (arrows per end, scores array)
- ScoringPage accepts initial state parameters and callback functions
- All score modifications notify parent to maintain state consistency
- Deep copying ensures proper state isolation between parent and child

Persistence features:
- Scores remain intact when navigating to Settings and back to Main scorer
- Arrow count mode (3/6 arrows) preserved across view switches
- All sophisticated end interaction behaviors maintained within scoring context
- Confirmation dialogs for rule changes still work with persistent state

Technical implementation:
- State lifting pattern following Flutter best practices
- Callback functions for bidirectional communication between parent and child
- Deep copying of score arrays prevents reference sharing issues
- Clean separation of navigation state from scoring logic

This enhancement ensures professional UX where users can navigate between
app sections without losing their scoring progress or configuration settings.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-23 22:51:33 +09:00

389 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../widgets/scoring_grid/scoring_grid.dart';
import '../widgets/scoring_grid/scoring_keypad.dart';
import '../widgets/common/toggle_switches.dart';
class ScoringPage extends StatefulWidget {
final int initialArrowsPerEnd;
final int initialEndsPerSet;
final List<List<String>> initialScores;
final Function(int) onArrowsPerEndChanged;
final Function(List<List<String>>) onScoresUpdated;
const ScoringPage({
super.key,
required this.initialArrowsPerEnd,
required this.initialEndsPerSet,
required this.initialScores,
required this.onArrowsPerEndChanged,
required this.onScoresUpdated,
});
@override
State<ScoringPage> createState() => _ScoringPageState();
}
class _ScoringPageState extends State<ScoringPage> with SingleTickerProviderStateMixin {
late int arrowsPerEnd;
late int endsPerSet;
int selectedRow = -1;
int selectedColumn = -1;
late List<List<String>> scores;
bool isKeypadVisible = false;
int? activeEndIndex;
late AnimationController _animationController;
late Animation<double> _keypadAnimation;
@override
void initState() {
super.initState();
// Initialize with values from parent
arrowsPerEnd = widget.initialArrowsPerEnd;
endsPerSet = widget.initialEndsPerSet;
scores = _deepCopyScores(widget.initialScores);
_animationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_keypadAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
}
List<List<String>> _deepCopyScores(List<List<String>> originalScores) {
return originalScores.map((end) => List<String>.from(end)).toList();
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _initializeScores() {
scores = List.generate(
endsPerSet,
(index) => List.generate(arrowsPerEnd, (index) => ''),
);
}
bool _hasAnyScores() {
for (int i = 0; i < scores.length; i++) {
for (int j = 0; j < scores[i].length; j++) {
if (scores[i][j].isNotEmpty) {
return true;
}
}
}
return false;
}
Future<void> _onArrowsPerEndChanged(int arrows) async {
// Check if there are existing scores and ask for confirmation
if (_hasAnyScores()) {
final bool? keepScores = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Keep current scores?'),
content: const Text(
'You have existing scores on the scorecard. Do you want to keep them when switching arrow count modes?\n\n'
'• Yes: Existing scores will be preserved\n'
'• No: All scores will be cleared',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('No - Clear scores'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Yes - Keep scores'),
),
],
);
},
);
if (keepScores == null) {
// User cancelled, don't change anything
return;
}
setState(() {
final oldArrowsPerEnd = arrowsPerEnd;
final oldScores = List<List<String>>.from(scores.map((end) => List<String>.from(end)));
arrowsPerEnd = arrows;
// Infer ends per set based on ruleset
endsPerSet = arrows == 3 ? 10 : 6;
if (keepScores) {
_preserveScoresWithNewArrowCount(oldScores, oldArrowsPerEnd);
} else {
_initializeScores();
}
// Notify parent of changes
widget.onArrowsPerEndChanged(arrows);
widget.onScoresUpdated(scores);
});
} else {
// No scores exist, just switch modes normally
setState(() {
arrowsPerEnd = arrows;
// Infer ends per set based on ruleset
endsPerSet = arrows == 3 ? 10 : 6;
_initializeScores();
// Notify parent of changes
widget.onArrowsPerEndChanged(arrows);
widget.onScoresUpdated(scores);
});
}
}
void _preserveScoresWithNewArrowCount(List<List<String>> oldScores, int oldArrowsPerEnd) {
// Initialize new score structure
scores = List.generate(
endsPerSet,
(index) => List.generate(arrowsPerEnd, (index) => ''),
);
// Copy over existing scores, respecting the new structure
for (int endIndex = 0; endIndex < oldScores.length && endIndex < scores.length; endIndex++) {
for (int arrowIndex = 0; arrowIndex < oldScores[endIndex].length && arrowIndex < arrowsPerEnd; arrowIndex++) {
scores[endIndex][arrowIndex] = oldScores[endIndex][arrowIndex];
}
}
}
void _sortEndScores(int endIndex) {
if (endIndex < 0 || endIndex >= scores.length) return;
List<String> endScores = List.from(scores[endIndex]);
// Separate empty and non-empty scores
List<String> nonEmptyScores = endScores.where((score) => score.isNotEmpty).toList();
List<String> emptyScores = endScores.where((score) => score.isEmpty).toList();
// Sort non-empty scores in descending order (X > 10 > 9 > ... > 1 > M)
nonEmptyScores.sort((a, b) {
int getScoreValue(String score) {
if (score == 'X') return 11;
if (score == 'M') return -1;
return int.tryParse(score) ?? 0;
}
return getScoreValue(b).compareTo(getScoreValue(a));
});
// Combine sorted non-empty scores with empty scores
List<String> sortedScores = [...nonEmptyScores, ...emptyScores];
// Update scores array
scores[endIndex] = sortedScores;
// Notify parent of score changes
widget.onScoresUpdated(scores);
}
int _findFirstEmptyCellInEnd(int endIndex) {
if (endIndex < 0 || endIndex >= scores.length) return 0;
for (int i = 0; i < arrowsPerEnd; i++) {
if (i < scores[endIndex].length && scores[endIndex][i].isEmpty) {
return i;
}
}
return 0; // Fallback to first cell if all filled
}
void _onCellSelected(int row, int column) {
setState(() {
selectedRow = row;
activeEndIndex = row;
if (!isKeypadVisible) {
isKeypadVisible = true;
_animationController.forward();
}
// If selecting an empty cell, snap to first empty cell in end
if (column < scores[row].length && scores[row][column].isEmpty) {
selectedColumn = _findFirstEmptyCellInEnd(row);
} else {
selectedColumn = column;
}
});
}
void _onBackgroundTapped() {
if (isKeypadVisible) {
_animationController.reverse().then((_) {
setState(() {
isKeypadVisible = false;
activeEndIndex = null;
selectedRow = -1;
selectedColumn = -1;
});
});
}
}
void _onScoreInput(String score) {
if (score == 'CLOSE') {
// Close keypad - same as background tap
_onBackgroundTapped();
return;
}
if (selectedRow < 0 || selectedRow >= scores.length) return;
if (score == 'CLEAR') {
// Clear the selected cell
if (selectedColumn < scores[selectedRow].length && scores[selectedRow][selectedColumn].isNotEmpty) {
scores[selectedRow][selectedColumn] = '';
_sortEndScores(selectedRow); // This already notifies parent
// Retract keypad and disarm after clearing
_animationController.reverse().then((_) {
setState(() {
isKeypadVisible = false;
activeEndIndex = null;
selectedRow = -1;
selectedColumn = -1;
});
});
}
} else {
// Input score
if (selectedColumn < scores[selectedRow].length) {
// Check if this was an empty cell before we modify it
bool wasEmpty = scores[selectedRow][selectedColumn].isEmpty;
setState(() {
scores[selectedRow][selectedColumn] = score;
_sortEndScores(selectedRow);
if (wasEmpty) {
// Find next empty cell in same end
int nextEmptyCell = _findFirstEmptyCellInEnd(selectedRow);
bool hasMoreEmptyInEnd = nextEmptyCell < arrowsPerEnd &&
nextEmptyCell < scores[selectedRow].length &&
scores[selectedRow][nextEmptyCell].isEmpty;
if (hasMoreEmptyInEnd) {
// Move to next empty cell in same end
selectedColumn = nextEmptyCell;
} else {
// End is complete - disarm completely
_animationController.reverse().then((_) {
setState(() {
isKeypadVisible = false;
activeEndIndex = null;
selectedRow = -1;
selectedColumn = -1;
});
});
}
} else {
// Was editing existing cell - disarm after modification
_animationController.reverse().then((_) {
setState(() {
isKeypadVisible = false;
activeEndIndex = null;
selectedRow = -1;
selectedColumn = -1;
});
});
}
});
}
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _keypadAnimation,
builder: (context, child) {
// Calculate viewport heights based on animation progress
// AppBar is in parent navigation, account for status bar and safe area
final availableHeight = (MediaQuery.of(context).size.height -
MediaQuery.of(context).padding.top -
kToolbarHeight).clamp(200.0, double.infinity); // Minimum 200px
final keypadHeight = (availableHeight * 0.30 * _keypadAnimation.value).clamp(0.0, availableHeight * 0.30);
final upperViewportHeight = (availableHeight - keypadHeight).clamp(100.0, availableHeight);
return Scaffold(
body: GestureDetector(
onTap: _onBackgroundTapped,
behavior: HitTestBehavior.opaque,
child: Column(
children: [
// Upper viewport - Settings and Scoring grid (scrollable)
SizedBox(
height: upperViewportHeight,
child: Column(
children: [
// Settings toggles
Padding(
padding: const EdgeInsets.all(16.0),
child: ToggleSwitches(
arrowsPerEnd: arrowsPerEnd,
onArrowsPerEndChanged: _onArrowsPerEndChanged,
),
),
// Scoring grid (scrollable within upper viewport)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ScoringGrid(
scores: scores,
arrowsPerEnd: arrowsPerEnd,
endsPerSet: endsPerSet,
selectedRow: selectedRow,
selectedColumn: selectedColumn,
activeEndIndex: activeEndIndex,
onCellSelected: _onCellSelected,
),
),
),
],
),
),
// Lower viewport - Keypad (animated height)
if (keypadHeight > 0)
SizedBox(
height: keypadHeight,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(
top: BorderSide(color: Colors.grey.shade300, width: 1),
),
),
child: Opacity(
opacity: _keypadAnimation.value,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ScoringKeypad(
onScoreInput: _onScoreInput,
),
),
),
),
),
],
),
),
);
},
);
}
}