diff --git a/.gitignore b/.gitignore index 8c2b884..f9082ab 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ # Built Visual Studio Code Extensions *.vsix +bin/* +obj/* diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..976b161 --- /dev/null +++ b/App.axaml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..69d6910 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,28 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using EinmaleinsTrainer.Views; +using ReactiveUI; +using Avalonia.ReactiveUI; + +namespace EinmaleinsTrainer; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + + RxApp.MainThreadScheduler = AvaloniaScheduler.Instance; + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Assets/avalonia-logo.ico b/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/Assets/avalonia-logo.ico differ diff --git a/EinmaleinsTrainer.csproj b/EinmaleinsTrainer.csproj new file mode 100644 index 0000000..2e03865 --- /dev/null +++ b/EinmaleinsTrainer.csproj @@ -0,0 +1,28 @@ + + + WinExe + net8.0 + enable + app.manifest + true + + + + + + + + + + + + + + + + None + All + + + + diff --git a/EinmaleinsTrainer.sln b/EinmaleinsTrainer.sln new file mode 100644 index 0000000..5ee42bd --- /dev/null +++ b/EinmaleinsTrainer.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EinmaleinsTrainer", "EinmaleinsTrainer.csproj", "{660FFE01-9483-61B8-28ED-58FAD08A78FD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {660FFE01-9483-61B8-28ED-58FAD08A78FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {660FFE01-9483-61B8-28ED-58FAD08A78FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {660FFE01-9483-61B8-28ED-58FAD08A78FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {660FFE01-9483-61B8-28ED-58FAD08A78FD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E30069BC-F120-41C6-970C-3AE4CCC2F5C1} + EndGlobalSection +EndGlobal diff --git a/Models/Question.cs b/Models/Question.cs new file mode 100644 index 0000000..6a1d22d --- /dev/null +++ b/Models/Question.cs @@ -0,0 +1,35 @@ +using System; + +namespace EinmaleinsTrainer.Models; + +public class Question : IEquatable +{ + public int A { get; set; } + public int B { get; set; } + + public int CorrectAnswer => A * B; + + public bool Equals(Question? other) + { + if (other is null) return false; + + return A == other.A && + B == other.B; + } + + public override bool Equals(object? obj) + { + return Equals(obj as Question); + } + + public override int GetHashCode() + { + return HashCode.Combine(A, B); + } + + public override string ToString() + { + return $"{A} x {B}"; + } +} + diff --git a/Models/QuizModel.cs b/Models/QuizModel.cs new file mode 100644 index 0000000..65aa4f9 --- /dev/null +++ b/Models/QuizModel.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace EinmaleinsTrainer.Models; + +public class QuizModel +{ + public List Questions { get; set; } = []; + public bool UseTimer { get; set; } + public int Seconds { get; set; } = 10; +} diff --git a/Models/ResultModel.cs b/Models/ResultModel.cs new file mode 100644 index 0000000..49164a2 --- /dev/null +++ b/Models/ResultModel.cs @@ -0,0 +1,9 @@ +using System; +namespace EinmaleinsTrainer.Models; + +public class ResultModel +{ + public int Score { get; set; } = 0; + public int Total { get; set; } = 0; + public TimeSpan? TimeRequired = null; +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..2e3478e --- /dev/null +++ b/Program.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.ReactiveUI; +using System; + +namespace EinmaleinsTrainer; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .UseReactiveUI() + .WithInterFont() + .LogToTrace(); +} diff --git a/Services/QuestionGenerator.cs b/Services/QuestionGenerator.cs new file mode 100644 index 0000000..44a5a6c --- /dev/null +++ b/Services/QuestionGenerator.cs @@ -0,0 +1,33 @@ +using EinmaleinsTrainer.Models; +using System; +using System.Linq; +using System.Collections.Generic; + +namespace EinmaleinsTrainer.Services; + +public class QuestionGenerator +{ + private readonly Random _random = new(); + + public List Generate(List rows, bool includeSquares) + { + var pool = new List(); + + foreach (var r in rows) + { + for (int i = 1; i <= 10; i++) + pool.Add(new Question { A = r, B = i }); + } + + if (includeSquares) + { + for (int i = 1; i <= 10; i++) + pool.Add(new Question { A = i, B = i }); + } + + return pool.OrderBy(_ => _random.Next()) + .Take(10) + .ToList(); + } +} + diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..9728329 --- /dev/null +++ b/ViewModels/MainViewModel.cs @@ -0,0 +1,139 @@ +using ReactiveUI; +using System.Reactive; +using EinmaleinsTrainer.Services; +using EinmaleinsTrainer.Models; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive.Linq; +using System; + +namespace EinmaleinsTrainer.ViewModels; + +public class MainViewModel : ViewModelBase +{ + public ObservableCollection SelectedRows { get; } = new(); + + public event EventHandler? RedrawRequired; + + private bool _includeSquares; + public bool IncludeSquares + { + get => _includeSquares; + set => this.RaiseAndSetIfChanged(ref _includeSquares, value); + } + + private bool _useTimer; + public bool UseTimer + { + get => _useTimer; + set + { + this.RaiseAndSetIfChanged(ref _useTimer, value); + RedrawRequired?.Invoke(this, new()); + } + } + private int _secondsPerQuestion = 0; + public int SecondsPerQuestion + { + get => _secondsPerQuestion; + set + { + if (value < 2) value = 2; + if (value > 120) value = 120; + this.RaiseAndSetIfChanged(ref _secondsPerQuestion, value); + } + } + public ReactiveCommand StartCommand { get; } + + public MainViewModel() + { + StartCommand = ReactiveCommand.Create( + execute: () => + { + var generator = new QuestionGenerator(); + + var questions = generator.Generate(SelectedRows.ToList(), IncludeSquares); + + return new QuizModel(){ Questions = questions, UseTimer = UseTimer, Seconds = SecondsPerQuestion}; + }, + canExecute: Observable.Return(true).ObserveOn(RxApp.MainThreadScheduler), + outputScheduler: RxApp.MainThreadScheduler); + } + + public void ToggleRow(int row, bool isChecked) + { + if (isChecked) + SelectedRows.Add(row); + else + SelectedRows.Remove(row); + } + + public bool IsRow1Checked + { + get => SelectedRows.Contains(1); + set => ToggleRow(1, value); + } + + public bool IsRow2Checked + { + get => SelectedRows.Contains(2); + set => ToggleRow(2, value); + } + + public bool IsRow3Checked + { + get => SelectedRows.Contains(3); + set => ToggleRow(3, value); + } + + public bool IsRow4Checked + { + get => SelectedRows.Contains(4); + set => ToggleRow(4, value); + } + + public bool IsRow5Checked + { + get => SelectedRows.Contains(5); + set => ToggleRow(5, value); + } + + public bool IsRow6Checked + { + get => SelectedRows.Contains(6); + set => ToggleRow(6, value); + } + + public bool IsRow7Checked + { + get => SelectedRows.Contains(7); + set => ToggleRow(7, value); + } + + public bool IsRow8Checked + { + get => SelectedRows.Contains(8); + set => ToggleRow(8, value); + } + + public bool IsRow9Checked + { + get => SelectedRows.Contains(9); + set => ToggleRow(9, value); + } + + public bool IsRow10Checked + { + get => SelectedRows.Contains(10); + set => ToggleRow(10, value); + } + + public void Init() + { + SelectedRows.Clear(); + IncludeSquares = false; + UseTimer = false; + SecondsPerQuestion = 30; + } +} + diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..e0e5d71 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,73 @@ +using ReactiveUI; +using System; +using System.Reactive.Linq; + +namespace EinmaleinsTrainer.ViewModels; + +public class MainWindowViewModel : ViewModelBase +{ + private ViewModelBase _current; + public ViewModelBase Current + { + get => _current; + set => SetProperty(ref _current, value); + } + + public readonly MainViewModel MainModel = new(); + public readonly QuizViewModel QuizModel = new(); + public readonly ResultViewModel ResultModel = new(); + private readonly ViewModelBase _dummy = new(); + + public MainWindowViewModel() + { + MainModel.Init(); + _current = MainModel; + + ResultModel.RestartCommand + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(result => + { + // In order to keep the last value if required + int secondsPerQuestion = MainModel.SecondsPerQuestion; + MainModel.Init(); + if (result.TimeRequired != null) + { + MainModel.SecondsPerQuestion = (result.Score >= 10) ? + (int)Math.Round(0.15 * result.TimeRequired.Value.TotalSeconds, 0, MidpointRounding.AwayFromZero) : + secondsPerQuestion; + MainModel.UseTimer = true; + } + Current = MainModel; + }); + + QuizModel.Finished + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(result => + { + ResultModel.Init(result); + Current = ResultModel; + }); + + QuizModel.RedrawRequired += (s, a) => + { + Current = _dummy; + Current = QuizModel; + }; + + MainModel.StartCommand + .ObserveOn(RxApp.MainThreadScheduler) + .Subscribe(quiz => + { + if (quiz.Questions.Count == 0) return; + QuizModel.Init(quiz); + Current = QuizModel; + }); + + MainModel.RedrawRequired += (s, a) => + { + Current = _dummy; + Current = MainModel; + }; + + } +} diff --git a/ViewModels/QuizViewModel.cs b/ViewModels/QuizViewModel.cs new file mode 100644 index 0000000..a8dd479 --- /dev/null +++ b/ViewModels/QuizViewModel.cs @@ -0,0 +1,293 @@ +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using EinmaleinsTrainer.Models; + +namespace EinmaleinsTrainer.ViewModels; + +public class QuizViewModel : ViewModelBase +{ + // ========================= + // Basisdaten + // ========================= + + private QuizModel _model = new(); + + private List _questions = new(); + public List Questions + { + get => _questions; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _questions, value); + this.RaisePropertyChanged(nameof(Total)); + + // Wenn sich Questions ändern → Index resetten + Index = 0; + }); + } + } + + public int Total => Questions.Count; + + private int _index; + public int Index + { + get => _index; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + bool changed = _index == value; + this.RaiseAndSetIfChanged(ref _index, value); + if (!changed) + { + this.RaisePropertyChanged(); + } + UpdateDerivedProperties(); + }); + } + } + + // ========================= + // Abgeleitete Properties + // ========================= + + private Question _currentQuestion = new() { A = 0, B = 0 }; + public Question CurrentQuestion + { + get => _currentQuestion; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _currentQuestion, value); + }); + } + } + + private int _questionNumber; + public int QuestionNumber + { + get => _questionNumber; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _questionNumber, value); + }); + } + } + + private string _currentQuestionText = "0 × 0"; + public string CurrentQuestionText + { + get => _currentQuestionText; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _currentQuestionText, value); + }); + } + } + + // ========================= + // User Input / Status + // ========================= + + private string _userAnswer = ""; + public string UserAnswer + { + get => _userAnswer; + set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _userAnswer, value); + }); + } + } + + private int _remaining; + public int RemainingSeconds + { + get => _remaining; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _remaining, value); + if (value > 0) + RedrawRequired?.Invoke(this, new()); + }); + } + } + + private int _score; + public int Score + { + get => _score; + private set + { + RxApp.MainThreadScheduler.Schedule(() => + { + this.RaiseAndSetIfChanged(ref _score, value); + }); + } + } + + public bool UseTimer { get; private set; } + + private TimeSpan TimeRequired = TimeSpan.Zero; + private DateTime Started = DateTime.MinValue; + + // ========================= + // Commands / Events + // ========================= + + public ReactiveCommand SubmitCommand { get; } + + private readonly Subject _finished = new(); + public IObservable Finished => _finished.AsObservable(); + + public event EventHandler? RedrawRequired; + + private IDisposable? _timerSub; + + // ========================= + // ctor + // ========================= + + public QuizViewModel() + { + SubmitCommand = ReactiveCommand.Create( + Submit, + outputScheduler: RxApp.MainThreadScheduler + ); + } + + // ========================= + // Init + // ========================= + + public void Init(QuizModel model) + { + _timerSub?.Dispose(); + _model = model; + + TimeRequired = TimeSpan.Zero; + Questions = new List(model.Questions); + + Score = 0; + UserAnswer = ""; + UseTimer = model.UseTimer && model.Seconds > 1; + + RemainingSeconds = UseTimer ? model.Seconds : 0; + + // Abgeleitete Properties initial setzen + UpdateDerivedProperties(); + + if (UseTimer) + StartTimer(model.Seconds); + } + + // ========================= + // Ableitungen zentral + // ========================= + + private void UpdateDerivedProperties() + { + if (Questions.Count == 0 || Index < 0 || Index >= Questions.Count) + { + CurrentQuestion = new Question { A = 0, B = 0 }; + QuestionNumber = 0; + CurrentQuestionText = "0 × 0"; + return; + } + + var q = Questions[Index]; + CurrentQuestion = q; // Direkt zuweisen + QuestionNumber = Index + 1; + CurrentQuestionText = $"{q.A} × {q.B}"; + } + + // ========================= + // Timer + // ========================= + + private void StartTimer(int seconds) + { + Started = DateTime.Now; + RemainingSeconds = seconds; + + _timerSub?.Dispose(); + _timerSub = Observable + .Interval(TimeSpan.FromSeconds(1), RxApp.MainThreadScheduler) + .Select(i => seconds - (int)i - 1) + .TakeWhile(s => s >= 0) + .Subscribe(s => + { + RemainingSeconds = s; + + if (s == 0) + SubmitInternal(false); + }); + } + + // ========================= + // Submit + // ========================= + + private void Submit() + { + bool correct = + int.TryParse(UserAnswer, out int a) && + a == CurrentQuestion.CorrectAnswer; + + SubmitInternal(correct); + } + + private void SubmitInternal(bool correct) + { + if (correct) + RxApp.MainThreadScheduler.Schedule(() => Score++); + + _timerSub?.Dispose(); + if (UseTimer) + TimeRequired += DateTime.Now - Started; + + RxApp.MainThreadScheduler.Schedule(() => + { + Index++; + + if (Index >= Questions.Count) + { + _finished.OnNext(new ResultModel + { + Score = Score, + Total = Total, + TimeRequired = UseTimer ? TimeRequired : null + }); + return; + } + + // UserAnswer zurücksetzen + UserAnswer = ""; + + // Update der abgeleiteten Properties (CurrentQuestionText, QuestionNumber) + UpdateDerivedProperties(); + + RedrawRequired?.Invoke(this, new()); + }); + + if (UseTimer && Index < Questions.Count) + StartTimer(_model.Seconds); + } +} \ No newline at end of file diff --git a/ViewModels/ResultViewModel.cs b/ViewModels/ResultViewModel.cs new file mode 100644 index 0000000..b309286 --- /dev/null +++ b/ViewModels/ResultViewModel.cs @@ -0,0 +1,64 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; +using EinmaleinsTrainer.Models; +using ReactiveUI; + +namespace EinmaleinsTrainer.ViewModels; + +public class ResultViewModel : ViewModelBase +{ + public string Text { get; private set;} = ""; + public string TimeRequiredText { get; private set;} = ""; + public bool ShowTimeRequired => !string.IsNullOrEmpty(TimeRequiredText); + + private ResultModel _model = new(); + public void Init(ResultModel result) + { + double percent = result.Score * 1.0 / result.Total; + if (percent > 0.9) + { + Text = $"🎉 {result.Score} von {result.Total} richtig!"; + } + else if (percent > 0.5) + { + Text = $"👍 {result.Score} von {result.Total} richtig."; + } + else if (percent > 0.25) + { + Text = $"🤨 {result.Score} von {result.Total} richtig."; + } + else if (percent > 0.0) + { + Text = $"😑 {result.Score} von {result.Total} richtig."; + } + else + { + Text = $"😭 {result.Score} von {result.Total} richtig."; + } + if (result.TimeRequired != null) + { + int SecondsRequired = (int)Math.Round(result.TimeRequired.Value.TotalSeconds, 0, MidpointRounding.AwayFromZero); + TimeRequiredText = $"In {SecondsRequired} s gelöst."; + } + else + { + TimeRequiredText = ""; + } + _model = result; + } + + public ReactiveCommand RestartCommand { get; } + + public ResultViewModel() + { + RestartCommand = ReactiveCommand.Create( + execute: () => + { + return _model; + }, + canExecute: Observable.Return(true).ObserveOn(RxApp.MainThreadScheduler), + outputScheduler: RxApp.MainThreadScheduler); + } +} + diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..55b2aa4 --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using ReactiveUI; + +namespace EinmaleinsTrainer.ViewModels; + +public class ViewModelBase : IReactiveObject +{ + public event PropertyChangedEventHandler? PropertyChanged; + public event PropertyChangingEventHandler? PropertyChanging; + + public void RaisePropertyChanged(PropertyChangedEventArgs args) + { + PropertyChanged?.Invoke(this, args); + } + + public void RaisePropertyChanging(PropertyChangingEventArgs args) + { + PropertyChanging?.Invoke(this, args); + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + protected void OnPropertyChanging([CallerMemberName] string? propertyName = null) + { + PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(propertyName)); + } + + protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (Equals(field, value)) return false; + OnPropertyChanging(propertyName); + field = value; + OnPropertyChanged(propertyName); + return true; + } +} + + diff --git a/Views/MainView.axaml b/Views/MainView.axaml new file mode 100644 index 0000000..7249ff6 --- /dev/null +++ b/Views/MainView.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +