Implement comprehensive end interaction system with split viewport keypad
This major enhancement transforms the scoring interface into a sophisticated end interaction system that mirrors professional archery scoring workflows. Core behaviors: per-end descending sorting, smart cell selection snapping, active end visual highlighting, cross-end disarm, enhanced keypad pop-up/retract. Split viewport: upper viewport with scrollable scorecard, lower viewport with 30% keypad, 250ms animated transitions, proper separation prevents UI obstruction. Enhanced 4x4 keypad: responsive Expanded widgets, CLEAR and CLOSE buttons, percentage-based sizing eliminates overflow. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,19 +10,37 @@ class ScoringPage extends StatefulWidget {
|
||||
State<ScoringPage> createState() => _ScoringPageState();
|
||||
}
|
||||
|
||||
class _ScoringPageState extends State<ScoringPage> {
|
||||
class _ScoringPageState extends State<ScoringPage> with SingleTickerProviderStateMixin {
|
||||
int arrowsPerEnd = 3;
|
||||
int endsPerSet = 12;
|
||||
int selectedRow = 0;
|
||||
int selectedColumn = 0;
|
||||
List<List<String>> scores = [];
|
||||
bool isKeypadVisible = false;
|
||||
int? activeEndIndex;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _keypadAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
vsync: this,
|
||||
);
|
||||
_keypadAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
_initializeScores();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeScores() {
|
||||
scores = List.generate(
|
||||
endsPerSet,
|
||||
@@ -39,32 +57,141 @@ class _ScoringPageState extends State<ScoringPage> {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
selectedColumn = column;
|
||||
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 (selectedRow < scores.length && selectedColumn < scores[selectedRow].length) {
|
||||
setState(() {
|
||||
scores[selectedRow][selectedColumn] = '';
|
||||
if (selectedColumn < scores[selectedRow].length && scores[selectedRow][selectedColumn].isNotEmpty) {
|
||||
scores[selectedRow][selectedColumn] = '';
|
||||
_sortEndScores(selectedRow);
|
||||
// Retract keypad and disarm after clearing
|
||||
_animationController.reverse().then((_) {
|
||||
setState(() {
|
||||
isKeypadVisible = false;
|
||||
activeEndIndex = null;
|
||||
selectedRow = -1;
|
||||
selectedColumn = -1;
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Input score
|
||||
if (selectedRow < scores.length && selectedColumn < scores[selectedRow].length) {
|
||||
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;
|
||||
// Auto-advance to next cell
|
||||
if (selectedColumn < arrowsPerEnd - 1) {
|
||||
selectedColumn++;
|
||||
} else if (selectedRow < endsPerSet - 1) {
|
||||
selectedRow++;
|
||||
selectedColumn = 0;
|
||||
_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;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -73,50 +200,87 @@ class _ScoringPageState extends State<ScoringPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Archery Scoring'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Settings toggles
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ToggleSwitches(
|
||||
arrowsPerEnd: arrowsPerEnd,
|
||||
onArrowsPerEndChanged: _onArrowsPerEndChanged,
|
||||
return AnimatedBuilder(
|
||||
animation: _keypadAnimation,
|
||||
builder: (context, child) {
|
||||
// Calculate viewport heights based on animation progress
|
||||
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(
|
||||
appBar: AppBar(
|
||||
title: const Text('Archery Scoring'),
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scoring grid
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: ScoringGrid(
|
||||
scores: scores,
|
||||
arrowsPerEnd: arrowsPerEnd,
|
||||
endsPerSet: endsPerSet,
|
||||
selectedRow: selectedRow,
|
||||
selectedColumn: selectedColumn,
|
||||
onCellSelected: _onCellSelected,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Scoring keypad
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ScoringKeypad(
|
||||
onScoreInput: _onScoreInput,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import '../../../config/constants/scoring_colors.dart';
|
||||
class ScoringCell extends StatelessWidget {
|
||||
final String score;
|
||||
final bool isSelected;
|
||||
final bool isInActiveEnd;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const ScoringCell({
|
||||
super.key,
|
||||
required this.score,
|
||||
required this.isSelected,
|
||||
this.isInActiveEnd = false,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@@ -27,14 +29,29 @@ class ScoringCell extends StatelessWidget {
|
||||
width: ScoringColors.getBorderWidth(isSelected),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
// Add active end highlighting
|
||||
boxShadow: isInActiveEnd ? [
|
||||
BoxShadow(
|
||||
color: Colors.blue.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
] : null,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
score,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: ScoringColors.getScoreTextColor(score),
|
||||
fontWeight: ScoringColors.shouldUseBoldText(score) ? FontWeight.bold : FontWeight.normal,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
// Add subtle background highlight for active end
|
||||
color: isInActiveEnd ? Colors.blue.withValues(alpha: 0.1) : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
score,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: ScoringColors.getScoreTextColor(score),
|
||||
fontWeight: ScoringColors.shouldUseBoldText(score) ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ class ScoringGrid extends StatelessWidget {
|
||||
final int endsPerSet;
|
||||
final int selectedRow;
|
||||
final int selectedColumn;
|
||||
final int? activeEndIndex;
|
||||
final Function(int row, int column) onCellSelected;
|
||||
|
||||
const ScoringGrid({
|
||||
@@ -17,6 +18,7 @@ class ScoringGrid extends StatelessWidget {
|
||||
required this.endsPerSet,
|
||||
required this.selectedRow,
|
||||
required this.selectedColumn,
|
||||
this.activeEndIndex,
|
||||
required this.onCellSelected,
|
||||
});
|
||||
|
||||
@@ -99,9 +101,12 @@ class ScoringGrid extends StatelessWidget {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Keep legacy method for accumulative calculation
|
||||
int _calculateSumOfSix(int endIndex) {
|
||||
return _calculateSumOfSixForLogicalEnd(endIndex);
|
||||
|
||||
bool _isVisualRowInActiveEnd(int visualRow) {
|
||||
if (activeEndIndex == null) return false;
|
||||
|
||||
final logicalEnd = _getLogicalEndFromVisualRow(visualRow);
|
||||
return logicalEnd == activeEndIndex;
|
||||
}
|
||||
|
||||
int _calculateAccumulative(int endIndex) {
|
||||
@@ -279,6 +284,7 @@ class ScoringGrid extends StatelessWidget {
|
||||
child: cellActive ? ScoringCell(
|
||||
score: scores[actualRow][actualColumn],
|
||||
isSelected: selectedRow == actualRow && selectedColumn == actualColumn,
|
||||
isInActiveEnd: _isVisualRowInActiveEnd(visualRow),
|
||||
onTap: () => onCellSelected(actualRow, actualColumn),
|
||||
) : Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -11,51 +11,97 @@ class ScoringKeypad extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Keypad layout matching user's design: 4 rows × 3 columns + CLEAR
|
||||
final List<List<String>> keypadLayout = [
|
||||
// Score buttons: 4 rows × 3 columns
|
||||
final List<List<String>> scoreLayout = [
|
||||
['X', '10', '9'],
|
||||
['8', '7', '6'],
|
||||
['5', '4', '3'],
|
||||
['2', '1', 'M'],
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Main keypad grid (4×3)
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
// Functional buttons: 4 rows × 1 column
|
||||
final List<String> functionalButtons = [
|
||||
'CLEAR',
|
||||
'CLOSE',
|
||||
'', // Reserved for future functions
|
||||
'', // Reserved for future functions
|
||||
];
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Use Flex widgets for proper percentage distribution
|
||||
return Row(
|
||||
children: [
|
||||
// Score buttons section (75% width)
|
||||
Expanded(
|
||||
flex: 75,
|
||||
child: Column(
|
||||
children: List.generate(4, (rowIndex) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: Row(
|
||||
children: List.generate(3, (colIndex) {
|
||||
final score = scoreLayout[rowIndex][colIndex];
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: _buildKeypadButton(context, score),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
final row = index ~/ 3;
|
||||
final col = index % 3;
|
||||
final score = keypadLayout[row][col];
|
||||
return _buildKeypadButton(context, score);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// CLEAR button (full width)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50,
|
||||
child: _buildKeypadButton(context, 'CLEAR', isWide: true),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(width: 2),
|
||||
|
||||
// Functional buttons section (25% width)
|
||||
Expanded(
|
||||
flex: 25,
|
||||
child: Column(
|
||||
children: List.generate(4, (index) {
|
||||
final buttonText = functionalButtons[index];
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||
child: buttonText.isNotEmpty
|
||||
? _buildKeypadButton(context, buttonText, isFunctional: true)
|
||||
: Container(), // Empty container for reserved buttons
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeypadButton(BuildContext context, String score, {bool isWide = false}) {
|
||||
// Use centralized color configuration for consistency
|
||||
final buttonColor = ScoringColors.getScoreBackgroundColor(score);
|
||||
final textColor = ScoringColors.getScoreTextColor(score);
|
||||
Widget _buildKeypadButton(BuildContext context, String score, {bool isFunctional = false}) {
|
||||
// Use different styling for functional buttons
|
||||
Color buttonColor;
|
||||
Color textColor;
|
||||
|
||||
if (isFunctional) {
|
||||
if (score == 'CLEAR') {
|
||||
buttonColor = Colors.red.shade400;
|
||||
textColor = Colors.white;
|
||||
} else if (score == 'CLOSE') {
|
||||
buttonColor = Colors.blue.shade400;
|
||||
textColor = Colors.white;
|
||||
} else {
|
||||
buttonColor = Colors.grey.shade300;
|
||||
textColor = Colors.grey.shade600;
|
||||
}
|
||||
} else {
|
||||
buttonColor = ScoringColors.getScoreBackgroundColor(score);
|
||||
textColor = ScoringColors.getScoreTextColor(score);
|
||||
}
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: () => onScoreInput(score),
|
||||
@@ -71,8 +117,9 @@ class ScoringKeypad extends StatelessWidget {
|
||||
child: Text(
|
||||
score,
|
||||
style: TextStyle(
|
||||
fontSize: isWide ? 16 : (score.length > 1 ? 14 : 18),
|
||||
fontWeight: ScoringColors.shouldUseBoldText(score) ? FontWeight.bold : FontWeight.w600,
|
||||
fontSize: isFunctional ? 12 : (score.length > 1 ? 14 : 18),
|
||||
fontWeight: isFunctional ? FontWeight.bold :
|
||||
(ScoringColors.shouldUseBoldText(score) ? FontWeight.bold : FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user