https://github.com/arcsymer/pasicard
Native Samsung Tizen NUI flashcard app (C#/.NET 10) with a Leitner spaced-repetition engine and TizenFX integration
https://github.com/arcsymer/pasicard
csharp dotnet maui portfolio spaced-repetition tizen
Last synced: 23 days ago
JSON representation
Native Samsung Tizen NUI flashcard app (C#/.NET 10) with a Leitner spaced-repetition engine and TizenFX integration
- Host: GitHub
- URL: https://github.com/arcsymer/pasicard
- Owner: arcsymer
- Created: 2026-06-22T14:37:27.000Z (30 days ago)
- Default Branch: main
- Last Pushed: 2026-06-23T01:53:54.000Z (29 days ago)
- Last Synced: 2026-06-23T03:21:36.283Z (29 days ago)
- Topics: csharp, dotnet, maui, portfolio, spaced-repetition, tizen
- Language: C#
- Size: 69.3 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# PasiCard — Tizen .NET Spaced-Repetition Flashcard Trainer
[](https://github.com/arcsymer/pasicard/actions/workflows/ci.yml)
[](https://github.com/arcsymer/pasicard/actions)
[](https://dotnet.microsoft.com/)
A native Samsung Tizen NUI app that teaches Polish food vocabulary using Leitner-box
spaced repetition (SRS). I built it as a portfolio piece to show C#/.NET 10 work for
Samsung R&D Poland.
---
## Problem
Learning menu vocabulary at a Polish fast-food chain (Pasibus) requires repeated exposure
to Polish terms at the right time — not all at once. Standard flashcard apps are desktop
or Android-only and ignore the Tizen platform entirely.
## Solution
PasiCard is a self-contained Tizen app:
- Reviews only cards that are due today, using a 6-box Leitner algorithm.
- Gives haptic feedback (via `Tizen.System.Feedback`) on each grade tap, with distinct
patterns for Again / Good / Easy.
- Stores each card's next-due date across app restarts using
`Tizen.Applications.Preference` (platform key-value store, no SQLite needed).
- Keeps all SRS logic in a platform-independent Core library with 171 passing unit tests
(152 test methods, some `[Theory]`-expanded), so it runs without any Tizen workload installed.
---
## Tech Stack
| Component | Technology |
|-----------|-----------|
| Language | C# 13, .NET 10 |
| UI layer | Tizen NUI (`Tizen.NUI.NUIApplication`, `TextLabel`, `LinearLayout`) |
| Haptic feedback | TizenFX `Tizen.System.Feedback` API |
| Persistence | TizenFX `Tizen.Applications.Preference` API |
| ViewModel | CommunityToolkit.Mvvm 8.4 (`[ObservableProperty]`, `[RelayCommand]`) |
| Tests | xUnit 2.9, 171 passing tests, net10.0 |
| Package | .NET `maui-tizen` workload (10.0.x) |
| Artifact | `.tpk` signed package (`org.pasicard.app-1.0.0.tpk`) |
---
## Architecture
```
tizen/
├── PasiCard.sln
├── src/
│ ├── PasiCard.Core/ # net10.0 — pure business logic (no platform deps)
│ │ ├── Card.cs # Flashcard model: Front / Back / LeitnerBox / DueDate / IsSuspended / ReviewHistory / RetentionRate
│ │ ├── Deck.cs # Named collection + due-date filtering + GetDueCount + GetDueForecast
│ │ ├── DeckSerializer.cs # JSON import/export + TryImport with validation (DeckImportResult)
│ │ ├── LeitnerConfig.cs # Configurable per-box intervals (default: 1/3/7/14/30/60 days)
│ │ ├── ReviewGrade.cs # Enum: Again / Hard / Good / Easy
│ │ ├── ReviewSession.cs # Daily queue + SessionStats with AccuracyPercent; records CardReviewRecord per grade
│ │ ├── SrsScheduler.cs # Leitner scheduler — O(1) per grade, config+clock injectable
│ │ ├── ICardSchedulePersister.cs# Persistence abstraction (NullCardSchedulePersister default)
│ │ └── ReviewController.cs # Grade+persist orchestration (testable, fixes graded-card-id bug)
│ └── PasiCard.Tizen/ # net10.0-tizen — Samsung NUI application
│ ├── Program.cs # NUIApplication entry point; binds NUI widgets to VM
│ ├── ViewModels/
│ │ └── ReviewViewModel.cs # MVVM state + RelayCommands; Tizen Preference persister + deck loader
│ └── tizen-manifest.xml # Tizen application manifest (org.pasicard.app)
└── tests/
└── PasiCard.Tests/ # net10.0 xUnit — 171 passing tests, no Tizen workload needed
├── SrsSchedulerTests.cs
├── ReviewSessionTests.cs
├── ReviewControllerTests.cs # grade+persist: schedule saved against the GRADED card's id
├── LeitnerConfigTests.cs
├── CardSuspendResetTests.cs
├── DeckSerializerTests.cs
├── SessionStatsTests.cs
├── CardReviewHistoryTests.cs # per-card history, RetentionRate, CardReviewRecord
├── DueForecastTests.cs # GetDueForecast, overdue-priority ordering
└── DeckImportValidationTests.cs # TryImport, DeckImportResult, clamping + warnings
```
### Three-layer architecture
```mermaid
graph TD
subgraph "PasiCard.Core (net10.0 — platform-independent)"
LC[LeitnerConfig\nconfigurable intervals]
S[SrsScheduler\nO(1) per grade]
C[Card\nFront/Back/Box/Due/IsSuspended]
D[Deck\nGetDueCards · GetDueCount]
RS[ReviewSession\nqueue · stats · AgainRequeue]
DS[DeckSerializer\nJSON import/export]
LC --> S
S --> RS
C --> D
D --> RS
end
subgraph "ViewModel (net10.0-tizen)"
VM[ReviewViewModel\nObservableObject · RelayCommands\nTizenFX: Feedback · Preference]
end
subgraph "NUI UI (net10.0-tizen)"
UI[Program.cs\nTextLabel · LinearLayout\nPropertyChanged listener]
end
RS --> VM
S --> VM
VM --> UI
```
### Leitner box flow
```mermaid
stateDiagram-v2
direction LR
[*] --> Box1 : new card
Box1 --> Box1 : Hard
Box1 --> Box2 : Good (+1)
Box1 --> Box3 : Easy (+2)
Box2 --> Box1 : Again (reset)
Box2 --> Box2 : Hard
Box2 --> Box3 : Good
Box2 --> Box4 : Easy
Box3 --> Box1 : Again
Box3 --> Box3 : Hard
Box3 --> Box4 : Good
Box3 --> Box5 : Easy
Box4 --> Box1 : Again
Box4 --> Box4 : Hard
Box4 --> Box5 : Good
Box4 --> Box6 : Easy
Box5 --> Box1 : Again
Box5 --> Box5 : Hard
Box5 --> Box6 : Good / Easy
Box6 --> Box1 : Again
Box6 --> Box6 : Hard / Good / Easy (retired)
Box6 --> [*] : retired (60 d interval)
```
| Box | Default interval | Configurable via `LeitnerConfig` |
|-----|-----------------|----------------------------------|
| 1 | 1 day | yes |
| 2 | 3 days | yes |
| 3 | 7 days | yes |
| 4 | 14 days | yes |
| 5 | 30 days | yes |
| 6 (retired) | 60 days | yes |
Grade rules: Again resets to box 1, due today; Hard stays in the current box;
Good advances 1 box; Easy advances 2 boxes.
### Layer isolation
| Layer | Knows about | Does NOT know about |
|-------|-------------|---------------------|
| `PasiCard.Core` | Domain models, SRS rules, `System.Text.Json` | NUI, TizenFX, MVVM toolkit |
| `ReviewViewModel` | Core domain, MVVM toolkit | NUI widget types |
| `Program.cs` | NUI widgets, ViewModel | SRS rules, grading logic |
The three-layer split means the SRS algorithm is testable on any platform
(Windows, Linux, CI) without a Tizen SDK or emulator.
---
## Notable bits
- 171 passing unit tests covering all grade paths, edge cases (box clamping, Again re-queue,
session completion), clock injection, suspend/reset lifecycle, JSON roundtrip,
configurable intervals, accuracy statistics, per-card retention, due forecasting,
overdue-priority ordering, validated import with field-level error reporting, and the
grade+persist flow (`ReviewController` — schedule result saved against the graded card's id).
- `LeitnerConfig` makes intervals a first-class injectable value — swap the schedule
without recompiling. `SrsScheduler(LeitnerConfig, Func)` gives full control
over both dimensions; the no-arg constructor keeps existing call sites unchanged.
- `Card.IsSuspended` allows a learner to hide cards from sessions temporarily.
`SrsScheduler.ResetCard()` clears the flag, sets box 1, and uses the injected clock.
- `DeckSerializer` provides portable JSON export and import using only the built-in
`System.Text.Json` — no extra NuGet packages. The roundtrip preserves all card fields
including `Id`, `LeitnerBox`, `DueDate`, and `IsSuspended`.
`DeckSerializer.TryImport` returns a `DeckImportResult` with the parsed deck and a list
of human-readable warnings for out-of-range `LeitnerBox` values (clamped), blank
`Front`/`Back` text, and empty-Guid `Id` fields. Structural JSON errors still throw.
- `SessionStats.AccuracyPercent` computes the share of non-Again reviews (0–100 %).
`Deck.GetDueCount(DateOnly)` returns a due-card count without allocating a list.
- `Card.ReviewHistory` is a chronological `List` appended to by
`ReviewSession.Grade`. `Card.RetentionRate` (0.0–1.0) computes the fraction of
non-Again reviews across all recorded sessions, enabling per-card difficulty tracking.
- `Deck.GetDueForecast(DateOnly from, int days)` projects how many cards will become
due on each of the next 1–365 calendar days, enabling a load-preview UI widget.
- `Deck.GetDueCards` now uses a secondary sort by `DueDate` ascending within the same
Leitner box, so the most overdue card in each box is always reviewed first.
- The ViewModel layer (`ReviewViewModel`) uses CommunityToolkit.Mvvm source generators.
`Program.cs` subscribes to `INotifyPropertyChanged` and routes touch events to
`IRelayCommand`, so there's no business logic in the UI class.
- Two real TizenFX API integrations, not stubs: `Tizen.System.Feedback` for haptic
patterns and `Tizen.Applications.Preference` for persistent schedule storage.
- Both TizenFX calls are wrapped in `try/catch` so the Core still runs in non-Tizen
environments (CI, unit tests, emulators without vibration support).
---
## Build
### Prerequisites
| Requirement | Version | Notes |
|-------------|---------|-------|
| .NET SDK | 10.0.300+ | `dotnet --version` |
| `maui-tizen` workload | 10.0.x | Required only for the Tizen head build |
| Tizen Studio (emulator) | 5.6+ | Required only to run the `.tpk` |
### Step 1 — Install the Tizen workload (once)
The NUI head targets `net10.0-tizen`, which the `maui-tizen` workload provides (it carries
the Samsung Tizen SDK packs and the `.tpk` packaging targets).
```bash
dotnet workload install maui-tizen
```
Verify:
```bash
dotnet workload list
# maui-tizen 10.0.20/10.0.100 SDK 10.0.300
```
### Step 2 — Build the full solution
```bash
dotnet build PasiCard.sln -c Release
```
Expected output (verified locally on 2026-06-23, .NET 10.0.300 + `maui-tizen` 10.0.20):
```
PasiCard.Tizen is signed with Default Certificates!
PasiCard.Tizen -> .../bin/Release/net10.0-tizen/org.pasicard.app-1.0.0.tpk
Build succeeded.
0 Warning(s)
0 Error(s)
# Produces a signed ~116 KB .tpk: src/PasiCard.Tizen/bin/Release/net10.0-tizen/org.pasicard.app-1.0.0.tpk
```
To build just the Tizen head and its `.tpk`:
```bash
dotnet build src/PasiCard.Tizen/PasiCard.Tizen.csproj -c Release
```
The resulting `.tpk` is a signed Tizen package (a zip) containing `tizen-manifest.xml`,
`author-signature.xml` / `signature1.xml`, and the compiled `PasiCard.Tizen.dll` +
`PasiCard.Core.dll` + `CommunityToolkit.Mvvm.dll`. CI builds and uploads this `.tpk` as an
artifact (`org.pasicard.app-tpk`) on a runner with the `maui-tizen` workload installed.
### Building Core only (no Tizen workload needed)
```bash
dotnet build src/PasiCard.Core/PasiCard.Core.csproj -c Release
```
---
## Tests
```bash
dotnet test -c Release
```
Expected:
```
Passed! - Failed: 0, Passed: 171, Skipped: 0, Total: 171
```
(152 test methods; some `[Theory]` methods expand into multiple `[InlineData]` cases,
so the runner reports 171 total cases.)
Tests run on `net10.0` and require no Tizen workload. They cover:
- All four grade paths (Again / Hard / Good / Easy) for every box 1–6.
- Box clamping at min (1) and max (6).
- Again re-queue behaviour within a session.
- Session completion detection.
- Clock injection for deterministic due-date assertions.
- `LeitnerConfig` validation, custom intervals, and `SrsScheduler` with custom config.
- `Card.IsSuspended` filtering in `GetDueCards` / `GetDueCount` / `ReviewSession`.
- `SrsScheduler.ResetCard` — box reset, date reset, flag clear, clock injection.
- `DeckSerializer` JSON roundtrip (name, all card fields, special chars, empty deck).
- `SessionStats.AccuracyPercent` at 0%, 50%, 66.7%, 100% and edge cases.
- `Card.ReviewHistory` populated by `ReviewSession.Grade`; `RetentionRate` across all grades.
- `CardReviewRecord` date and grade tracking with multi-card session independence.
- `Deck.GetDueForecast` — 1-day, 7-day, 90-day windows; suspended/overdue card exclusion; guard rails.
- Overdue-priority secondary sort in `GetDueCards` (most overdue card within a box first).
- `DeckSerializer.TryImport` — clean decks, `LeitnerBox` clamping (0, negative, 99), blank text,
empty-Guid, multi-card accumulated warnings, structural errors still throwing.
- `ReviewController` grade+persist flow — the schedule result is persisted against the id of
the card that was just graded (not the next card in the queue), across single-card "Again"
re-queues and multi-card sessions; null-argument guards and completed-session throwing.
---
## Running the `.tpk` (emulator / device)
The `.tpk` is built and signed locally and in CI. **Running** it is an environment blocker,
not a code one: it needs Tizen Studio + the Emulator (with hardware acceleration, often admin
rights) or a real Samsung device with an author/distributor certificate from the Tizen
Certificate Manager. None of that is installed on the build machine, so the run step is
documented honestly here rather than faked.
To run once a Tizen environment is available:
1. Install **Tizen Studio** (https://developer.samsung.com/tizen/run.html) with the
**Tizen SDK Tools**, a platform profile (Wearable/TV), the **Emulator**, and
**Certificate Manager** packages.
2. Enable hardware acceleration (Intel HAXM or Windows Hypervisor Platform); may need admin.
3. In **Certificate Manager**, create an author + distributor certificate. (Emulator runs
accept the Default Certificate the `.tpk` is already signed with; device deploy needs a
Samsung-account-backed certificate.)
4. Start an image via **Emulator Manager** (e.g. wearable-circle), then install + launch:
```bash
sdb install src/PasiCard.Tizen/bin/Release/net10.0-tizen/org.pasicard.app-1.0.0.tpk
sdb shell app_launcher -s org.pasicard.app
```
Or, from a configured environment, `dotnet build src/PasiCard.Tizen/PasiCard.Tizen.csproj -c Debug -t:Run`.
Notes: haptic feedback (`Tizen.System.Feedback`) may produce a sound effect instead of
vibration on the emulator; on real Samsung hardware both fire. Schedule persistence
(`Tizen.Applications.Preference`) survives app restarts on device or emulator.
---
## Screenshots
A live UI capture means deploying the `.tpk` to a Tizen Emulator via Tizen Studio (a
~2 GB GUI toolkit). For now the signed build artifact (`org.pasicard.app-1.0.0.tpk`) and the
171 passing Core tests verify the behaviour; the layout below is the reference for the review screen.
### UI layout (review screen)
```
┌─────────────────────────────────────────┐
│ PasiCard 1 / 10 │
│ │
│ pasztet │
│ │
│ ┌─────────────────┐ │
│ │ Show Answer │ │
│ └─────────────────┘ │
│ │
│ ─ ─ ─ ─ (after reveal) ─ ─ ─ ─ │
│ │
│ pâté / meat paste │
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │Again │ │ Hard │ │ Good │ │ Easy │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ │
│ │
│ Box 2 — due 25 Jun │
└─────────────────────────────────────────┘
```
---
## TizenFX APIs Used
| API | Integration point | Behaviour |
|-----|------------------|-----------|
| `Tizen.System.Feedback` | `ReviewViewModel.TriggerHapticFeedback()` | Vibration pattern per grade: `Error` for Again, `SuccessTap` for Easy, `Tap` for Hard/Good. |
| `Tizen.Applications.Preference` | `ReviewViewModel.PersistCardSchedule()` | Stores `card_{id}_box` and `card_{id}_due` keys after every grade. |
Both are wrapped in `try/catch` and silently ignored outside the Tizen runtime.
---
## License
MIT