fuck it we're going live
@@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="decky_app"
|
android:label="omotomo"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Decky - Practice Engine',
|
title: 'omotomo',
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
darkTheme: AppTheme.darkTheme,
|
darkTheme: AppTheme.darkTheme,
|
||||||
themeMode: ThemeMode.dark,
|
themeMode: ThemeMode.dark,
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _remainingSeconds = 0;
|
int _remainingSeconds = 0;
|
||||||
int _startTime = 0;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -148,7 +147,6 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
|
_remainingSeconds = (_deck!.config.timeLimitSeconds! - elapsedSeconds).clamp(0, _deck!.config.timeLimitSeconds!);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_startTime = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
_remainingSeconds = _deck!.config.timeLimitSeconds!;
|
_remainingSeconds = _deck!.config.timeLimitSeconds!;
|
||||||
}
|
}
|
||||||
_startTimer();
|
_startTimer();
|
||||||
@@ -223,6 +221,39 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
/// Returns true if at least one question has been answered (progress has been made)
|
/// Returns true if at least one question has been answered (progress has been made)
|
||||||
bool get _hasAnyProgress => _answers.isNotEmpty || _manualOverrides.isNotEmpty;
|
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() {
|
void _goToPreviousQuestion() {
|
||||||
if (_currentQuestionIndex > 0) {
|
if (_currentQuestionIndex > 0) {
|
||||||
// Save current answer if any
|
// Save current answer if any
|
||||||
@@ -316,6 +347,82 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
void _saveForLater() {
|
||||||
if (_deck == null || _attempt == null) return;
|
if (_deck == null || _attempt == null) return;
|
||||||
|
|
||||||
@@ -448,12 +555,19 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Attempt - ${_deck!.title}'),
|
title: Text('Attempt - ${_deck!.title}'),
|
||||||
actions: [
|
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
|
// Only show "Continue Later" button if there's progress
|
||||||
if (_hasAnyProgress)
|
if (_hasAnyProgress)
|
||||||
TextButton.icon(
|
IconButton(
|
||||||
onPressed: _saveForLater,
|
onPressed: _saveForLater,
|
||||||
icon: const Icon(Icons.pause),
|
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();
|
_answers[question.id] = selectedIndices.toList()..sort();
|
||||||
} else {
|
} else {
|
||||||
selectedIndex = answerIndex;
|
// Toggle: if already selected, deselect; otherwise select
|
||||||
_answers[question.id] = selectedIndex;
|
if (selectedIndex == answerIndex) {
|
||||||
|
selectedIndex = null;
|
||||||
|
_answers.remove(question.id);
|
||||||
|
} else {
|
||||||
|
selectedIndex = answerIndex;
|
||||||
|
_answers[question.id] = selectedIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Update current question state if viewing it
|
// Update current question state if viewing it
|
||||||
if (index == _currentQuestionIndex) {
|
if (index == _currentQuestionIndex) {
|
||||||
@@ -707,10 +827,11 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
); // Close AnimatedBuilder
|
||||||
},
|
},
|
||||||
pageSnapping: true,
|
pageSnapping: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Navigation Buttons
|
// Navigation Buttons
|
||||||
Container(
|
Container(
|
||||||
@@ -753,31 +874,9 @@ class _AttemptScreenState extends State<AttemptScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Next/Complete Button
|
// Next/Complete Button
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.icon(
|
child: _isLastQuestion
|
||||||
onPressed: _isLastQuestion
|
? _buildCompleteButton()
|
||||||
? (_hasAnswer ? _submitAnswer : null)
|
: _buildNextButton(),
|
||||||
: _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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Decky'),
|
title: const Text('omotomo'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
|
|||||||