bb7b1cf6b2
- Add flutter_localizations dependency and l10n configuration - Create ARB files for English (en) and Finnish (fi) translations - Localize UI strings across all major components: * Navigation drawer and page titles * Scoring interface (arrows per end, ruleset names) * Scorecard headers (End, Arrows, Sum of 3/6, Accumulative) * Keypad functional buttons (CLEAR/TYHJENNÄ, CLOSE/SULJE) * Settings page placeholder content * Dialog messages and confirmation prompts - Preserve universal archery scoring notation (X, 10, 9, M remain unlocalised) - Use authentic Finnish archery terminology throughout - Configure main app with proper localization delegates and supported locales - Generate localization classes automatically via flutter_localizations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
464 lines
15 KiB
Dart
464 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../config/localization/generated/app_localizations.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;
|
|
bool _isPreservingScores = false;
|
|
|
|
@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) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l10n.dialogKeepScoresTitle),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(l10n.dialogNo),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(l10n.dialogYes),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
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();
|
|
}
|
|
|
|
// Clear active state and retract keypad
|
|
_disarmActiveState();
|
|
|
|
// 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();
|
|
|
|
// Clear active state and retract keypad
|
|
_disarmActiveState();
|
|
|
|
// Notify parent of changes
|
|
widget.onArrowsPerEndChanged(arrows);
|
|
widget.onScoresUpdated(scores);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _preserveScoresWithNewArrowCount(List<List<String>> oldScores, int oldArrowsPerEnd) {
|
|
_isPreservingScores = true; // Disable parent notifications during preservation
|
|
|
|
// Initialize new score structure
|
|
scores = List.generate(
|
|
endsPerSet,
|
|
(index) => List.generate(arrowsPerEnd, (index) => ''),
|
|
);
|
|
|
|
// Flatten old scores by visual rows, then redistribute to new structure
|
|
List<String> allOldScores = [];
|
|
|
|
// Extract all scores in visual row order (12 rows total)
|
|
for (int visualRow = 0; visualRow < 12; visualRow++) {
|
|
if (oldArrowsPerEnd == 3) {
|
|
// 3-arrow mode: 1 visual row = 1 logical end
|
|
if (visualRow < oldScores.length) {
|
|
for (int col = 0; col < 3 && col < oldScores[visualRow].length; col++) {
|
|
allOldScores.add(oldScores[visualRow][col]);
|
|
}
|
|
}
|
|
} else {
|
|
// 6-arrow mode: 2 visual rows = 1 logical end
|
|
int logicalEnd = visualRow ~/ 2;
|
|
int rowInEnd = visualRow % 2;
|
|
if (logicalEnd < oldScores.length) {
|
|
for (int col = 0; col < 3; col++) {
|
|
int arrowIndex = (rowInEnd * 3) + col;
|
|
if (arrowIndex < oldScores[logicalEnd].length) {
|
|
allOldScores.add(oldScores[logicalEnd][arrowIndex]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Redistribute to new structure by visual rows
|
|
int scoreIndex = 0;
|
|
for (int visualRow = 0; visualRow < 12; visualRow++) {
|
|
if (arrowsPerEnd == 3) {
|
|
// 3-arrow mode: 1 visual row = 1 logical end
|
|
if (visualRow < scores.length) {
|
|
for (int col = 0; col < 3; col++) {
|
|
if (scoreIndex < allOldScores.length && allOldScores[scoreIndex].isNotEmpty) {
|
|
scores[visualRow][col] = allOldScores[scoreIndex];
|
|
}
|
|
scoreIndex++;
|
|
}
|
|
// Sort the end after adding scores
|
|
_sortEndScores(visualRow);
|
|
}
|
|
} else {
|
|
// 6-arrow mode: 2 visual rows = 1 logical end
|
|
int logicalEnd = visualRow ~/ 2;
|
|
int rowInEnd = visualRow % 2;
|
|
if (logicalEnd < scores.length) {
|
|
for (int col = 0; col < 3; col++) {
|
|
int arrowIndex = (rowInEnd * 3) + col;
|
|
if (arrowIndex < arrowsPerEnd && scoreIndex < allOldScores.length && allOldScores[scoreIndex].isNotEmpty) {
|
|
scores[logicalEnd][arrowIndex] = allOldScores[scoreIndex];
|
|
}
|
|
scoreIndex++;
|
|
}
|
|
// Sort the end after completing both rows of the logical end
|
|
if (rowInEnd == 1) {
|
|
_sortEndScores(logicalEnd);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_isPreservingScores = false; // Re-enable parent notifications
|
|
}
|
|
|
|
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 (unless we're preserving scores)
|
|
if (!_isPreservingScores) {
|
|
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 _disarmActiveState() {
|
|
if (isKeypadVisible) {
|
|
_animationController.reverse();
|
|
}
|
|
isKeypadVisible = false;
|
|
activeEndIndex = null;
|
|
selectedRow = -1;
|
|
selectedColumn = -1;
|
|
}
|
|
|
|
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
|
|
// Since we're inside MainNavigationPage's Scaffold body, use LayoutBuilder to get actual constraints
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final availableHeight = constraints.maxHeight.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 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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
} |