fuck it we're going live
@@ -1,6 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="decky_app"
|
||||
android:label="omotomo"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 54 KiB |
@@ -12,7 +12,7 @@ class DeckyApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Decky - Practice Engine',
|
||||
title: 'omotomo',
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.dark,
|
||||
|
||||
@@ -81,7 +81,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
late PageController _pageController;
|
||||
Timer? _timer;
|
||||
int _remainingSeconds = 0;
|
||||
int _startTime = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -148,7 +147,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
|
||||
}
|
||||
} else {
|
||||
_startTime = DateTime.now().millisecondsSinceEpoch;
|
||||
_remainingSeconds = _deck!.config.timeLimitSeconds!;
|
||||
}
|
||||
_startTimer();
|
||||
@@ -222,6 +220,39 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
|
||||
/// Returns true if at least one question has been answered (progress has been made)
|
||||
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
|
||||
|
||||
/// Returns true if there are unanswered questions
|
||||
bool get _hasUnansweredQuestions {
|
||||
if (_attempt == null) return false;
|
||||
for (final question in _attempt!.questions) {
|
||||
if (!_answers.containsKey(question.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns the index of the first unanswered question, or null if all are answered
|
||||
int? get _firstUnansweredQuestionIndex {
|
||||
if (_attempt == null) return null;
|
||||
for (int i = 0; i < _attempt!.questions.length; i++) {
|
||||
if (!_answers.containsKey(_attempt!.questions[i].id)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns the index of the next unanswered question after current, or null
|
||||
int? get _nextUnansweredQuestionIndex {
|
||||
if (_attempt == null) return null;
|
||||
for (int i = _currentQuestionIndex + 1; i < _attempt!.questions.length; i++) {
|
||||
if (!_answers.containsKey(_attempt!.questions[i].id)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _goToPreviousQuestion() {
|
||||
if (_currentQuestionIndex > 0) {
|
||||
@@ -315,6 +346,82 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
_goToNextQuestion();
|
||||
}
|
||||
}
|
||||
|
||||
void _completeAttemptWithUnanswered() async {
|
||||
// Ask for confirmation
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Complete Attempt?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('There are still unanswered questions.'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Are you sure you want to complete this attempt?',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Complete Anyway'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
_completeAttempt();
|
||||
}
|
||||
}
|
||||
|
||||
void _jumpToFirstUnanswered() {
|
||||
// Save current answer if any
|
||||
if (_hasAnswer) {
|
||||
if (_hasMultipleCorrect) {
|
||||
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
||||
} else {
|
||||
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
||||
}
|
||||
}
|
||||
|
||||
final firstUnanswered = _firstUnansweredQuestionIndex;
|
||||
if (firstUnanswered != null && _pageController.hasClients) {
|
||||
_pageController.animateToPage(
|
||||
firstUnanswered,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _goToNextUnanswered() {
|
||||
// Save current answer if any
|
||||
if (_hasAnswer) {
|
||||
if (_hasMultipleCorrect) {
|
||||
_answers[_currentQuestion.id] = _selectedAnswerIndices.toList()..sort();
|
||||
} else {
|
||||
_answers[_currentQuestion.id] = _selectedAnswerIndex!;
|
||||
}
|
||||
}
|
||||
|
||||
final nextUnanswered = _nextUnansweredQuestionIndex;
|
||||
if (nextUnanswered != null && _pageController.hasClients) {
|
||||
_pageController.animateToPage(
|
||||
nextUnanswered,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _saveForLater() {
|
||||
if (_deck == null || _attempt == null) return;
|
||||
@@ -448,12 +555,19 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text('Attempt - ${_deck!.title}'),
|
||||
actions: [
|
||||
// Show "Jump to First Unanswered" if there are unanswered questions
|
||||
if (_hasUnansweredQuestions)
|
||||
IconButton(
|
||||
onPressed: _jumpToFirstUnanswered,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
tooltip: 'Jump to first unanswered question',
|
||||
),
|
||||
// Only show "Continue Later" button if there's progress
|
||||
if (_hasAnyProgress)
|
||||
TextButton.icon(
|
||||
IconButton(
|
||||
onPressed: _saveForLater,
|
||||
icon: const Icon(Icons.pause),
|
||||
label: const Text('Continue Later'),
|
||||
tooltip: 'Continue Later',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -614,8 +728,14 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
}
|
||||
_answers[question.id] = selectedIndices.toList()..sort();
|
||||
} else {
|
||||
selectedIndex = answerIndex;
|
||||
_answers[question.id] = selectedIndex;
|
||||
// Toggle: if already selected, deselect; otherwise select
|
||||
if (selectedIndex == answerIndex) {
|
||||
selectedIndex = null;
|
||||
_answers.remove(question.id);
|
||||
} else {
|
||||
selectedIndex = answerIndex;
|
||||
_answers[question.id] = selectedIndex;
|
||||
}
|
||||
}
|
||||
// Update current question state if viewing it
|
||||
if (index == _currentQuestionIndex) {
|
||||
@@ -707,10 +827,11 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
); // Close AnimatedBuilder
|
||||
},
|
||||
pageSnapping: true,
|
||||
),
|
||||
),
|
||||
pageSnapping: true,
|
||||
),
|
||||
),
|
||||
|
||||
// Navigation Buttons
|
||||
Container(
|
||||
@@ -753,31 +874,9 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
const SizedBox(width: 12),
|
||||
// Next/Complete Button
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _isLastQuestion
|
||||
? (_hasAnswer ? _submitAnswer : null)
|
||||
: _goToNextQuestion,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
_isLastQuestion ? Icons.check_circle : Icons.arrow_forward,
|
||||
size: 24,
|
||||
color: _isLastQuestion ? Colors.green : null,
|
||||
),
|
||||
label: Text(
|
||||
_isLastQuestion ? 'Complete' : 'Next',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _isLastQuestion
|
||||
? _buildCompleteButton()
|
||||
: _buildNextButton(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -788,5 +887,65 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompleteButton() {
|
||||
final hasUnanswered = _hasUnansweredQuestions;
|
||||
// Allow completing if current question is answered OR if there are unanswered questions (to show warning)
|
||||
final canComplete = _hasAnswer || hasUnanswered;
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: canComplete
|
||||
? (hasUnanswered ? _completeAttemptWithUnanswered : _submitAnswer)
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
hasUnanswered ? Icons.warning : Icons.check_circle,
|
||||
size: 24,
|
||||
color: hasUnanswered ? null : Colors.green,
|
||||
),
|
||||
label: const Text(
|
||||
'Complete',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNextButton() {
|
||||
final nextUnanswered = _nextUnansweredQuestionIndex;
|
||||
final hasUnansweredAhead = nextUnanswered != null;
|
||||
|
||||
return FilledButton.icon(
|
||||
onPressed: hasUnansweredAhead ? _goToNextUnanswered : _goToNextQuestion,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 24),
|
||||
minimumSize: const Size(0, 56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
icon: const Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 24,
|
||||
),
|
||||
label: const Text(
|
||||
'Next',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Decky'),
|
||||
title: const Text('omotomo'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
|
||||