We were three weeks into KidSpark development when Linh pushed back from her desk, rubbed her eyes, and said something that changed the trajectory of the entire project: “This isn’t a todo app. Every question type is basically its own mini-application.”
She was right, and we should have seen it sooner. The lesson engine we’d been building — which Toan had casually described in the product spec as “a screen that shows questions and records answers” — was turning into the most complex piece of software any of us had ever built. Not because any single feature was hard. Because every single feature had to work across three age tiers, six question types, five difficulty levels, offline mode, and with animations smooth enough that a five-year-old wouldn’t lose interest during the 200 milliseconds between tapping an answer and seeing the result.
I had estimated the lesson engine at three weeks. It took seven. And those seven weeks taught me more about mobile engineering than the previous two years combined.
Here is the thing about building a kids’ learning app: the core feature loop is deceptively simple on paper. A child opens a lesson. The lesson presents questions. The child answers. The app tracks progress. The app rewards the child. Repeat. Five steps. Any junior developer could whiteboard that architecture in ten minutes.
But then you start asking questions. What happens when the child is offline? What happens when the child gets three wrong in a row and starts crying? What happens when a four-year-old encounters a question designed for a six-year-old because the adaptive algorithm made a bad prediction? What happens when the parent has set a fifteen-minute screen time limit and the child is mid-lesson? What happens when the device dies mid-quiz and the child loses their twenty-question streak? What happens when the child discovers they can just tap the same position four times to get through multiple-choice questions without reading?
Every single one of those questions spawned a feature. Every feature spawned edge cases. Every edge case spawned a conversation between me, Linh, Toan, and Hana that lasted longer than the original feature estimate.
This post is the story of building those features — the lesson engine, the quiz system, progress tracking, gamification, parental controls, and offline mode. I will show real code in both Flutter and React Native, because the patterns matter regardless of your framework choice. And I will be honest about what we got right, what we got wrong, and what we would do differently.
The Lesson Engine
The lesson engine is the heart of KidSpark. Everything else — quizzes, progress tracking, gamification — is built on top of it. Getting the data model right was the single most important technical decision we made, because every downstream feature inherits the assumptions baked into the lesson structure.
Content Data Model
We went through four iterations of the data model before landing on one that actually worked. The first version was too flat — a lesson was just a list of questions, and we kept hitting walls when we needed to group questions by skill or add metadata for the adaptive engine. The second version was too nested — questions contained sub-questions, which contained hints, which contained alternative hints per age tier, and serialization became a nightmare. The third version was close but didn’t account for offline storage efficiently.
The fourth version is what shipped. Here is the Flutter implementation:
// lib/models/lesson.dart
class Lesson {
final String id;
final String title;
final String description;
final AgeTier ageTier;
final Subject subject;
final int difficultyLevel; // 1-5
final List<LessonSection> sections;
final List<Question> questions;
final Map<String, dynamic> metadata;
final DateTime? downloadedAt; // for offline tracking
final int estimatedMinutes;
final String? thumbnailUrl;
const Lesson({
required this.id,
required this.title,
required this.description,
required this.ageTier,
required this.subject,
required this.difficultyLevel,
required this.sections,
required this.questions,
required this.metadata,
this.downloadedAt,
required this.estimatedMinutes,
this.thumbnailUrl,
});
factory Lesson.fromJson(Map<String, dynamic> json) {
return Lesson(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
ageTier: AgeTier.fromString(json['ageTier'] as String),
subject: Subject.fromString(json['subject'] as String),
difficultyLevel: json['difficultyLevel'] as int,
sections: (json['sections'] as List)
.map((s) => LessonSection.fromJson(s))
.toList(),
questions: (json['questions'] as List)
.map((q) => Question.fromJson(q))
.toList(),
metadata: json['metadata'] as Map<String, dynamic>,
downloadedAt: json['downloadedAt'] != null
? DateTime.parse(json['downloadedAt'] as String)
: null,
estimatedMinutes: json['estimatedMinutes'] as int,
thumbnailUrl: json['thumbnailUrl'] as String?,
);
}
bool get isAvailableOffline => downloadedAt != null;
int get totalPoints =>
questions.fold(0, (sum, q) => sum + q.points);
}
enum AgeTier {
preschool, // ages 4-5
elementary, // ages 6-9
preteen; // ages 10-12
static AgeTier fromString(String value) {
return AgeTier.values.firstWhere(
(tier) => tier.name == value,
orElse: () => AgeTier.elementary,
);
}
}
enum Subject {
math,
reading,
science,
language,
creativity;
static Subject fromString(String value) {
return Subject.values.firstWhere(
(s) => s.name == value,
orElse: () => Subject.math,
);
}
}
enum QuestionType {
multipleChoice,
dragAndDrop,
fillInBlank,
matching,
drawing,
audioResponse,
}
class Question {
final String id;
final QuestionType type;
final String prompt;
final String? audioPromptUrl;
final String? imageUrl;
final List<Answer> answers;
final String? hint;
final String? secondHint;
final int points;
final Duration? timeLimit;
final Map<String, dynamic>? typeSpecificData;
const Question({
required this.id,
required this.type,
required this.prompt,
this.audioPromptUrl,
this.imageUrl,
required this.answers,
this.hint,
this.secondHint,
required this.points,
this.timeLimit,
this.typeSpecificData,
});
factory Question.fromJson(Map<String, dynamic> json) {
return Question(
id: json['id'] as String,
type: QuestionType.values.firstWhere(
(t) => t.name == json['type'],
),
prompt: json['prompt'] as String,
audioPromptUrl: json['audioPromptUrl'] as String?,
imageUrl: json['imageUrl'] as String?,
answers: (json['answers'] as List)
.map((a) => Answer.fromJson(a))
.toList(),
hint: json['hint'] as String?,
secondHint: json['secondHint'] as String?,
points: json['points'] as int,
timeLimit: json['timeLimit'] != null
? Duration(seconds: json['timeLimit'] as int)
: null,
typeSpecificData: json['typeSpecificData'] as Map<String, dynamic>?,
);
}
}
class Answer {
final String id;
final String content;
final String? imageUrl;
final bool isCorrect;
final String? explanation;
const Answer({
required this.id,
required this.content,
this.imageUrl,
required this.isCorrect,
this.explanation,
});
factory Answer.fromJson(Map<String, dynamic> json) {
return Answer(
id: json['id'] as String,
content: json['content'] as String,
imageUrl: json['imageUrl'] as String?,
isCorrect: json['isCorrect'] as bool,
explanation: json['explanation'] as String?,
);
}
}
And the React Native TypeScript equivalent, which maps to the same .NET backend API from Kids Learn:
// src/models/lesson.ts
interface Lesson {
id: string;
title: string;
description: string;
ageTier: 'preschool' | 'elementary' | 'preteen';
subject: Subject;
difficultyLevel: number; // 1-5
sections: LessonSection[];
questions: Question[];
metadata: Record<string, unknown>;
downloadedAt?: Date;
estimatedMinutes: number;
thumbnailUrl?: string;
}
type Subject = 'math' | 'reading' | 'science' | 'language' | 'creativity';
type QuestionType =
| 'multipleChoice'
| 'dragAndDrop'
| 'fillInBlank'
| 'matching'
| 'drawing'
| 'audioResponse';
interface Question {
id: string;
type: QuestionType;
prompt: string;
audioPromptUrl?: string;
imageUrl?: string;
answers: Answer[];
hint?: string;
secondHint?: string;
points: number;
timeLimit?: number; // seconds
typeSpecificData?: Record<string, unknown>;
}
interface Answer {
id: string;
content: string;
imageUrl?: string;
isCorrect: boolean;
explanation?: string;
}
interface LessonSection {
id: string;
title: string;
type: 'instruction' | 'practice' | 'assessment';
content?: string;
questionIds: string[];
}
A few design decisions worth calling out. First, typeSpecificData is an escape hatch. Each question type has unique rendering requirements — drag-and-drop needs drop zone coordinates, matching needs pair definitions, drawing needs canvas constraints — and we chose to put that data in a flexible map rather than creating a massive union type. Hana initially pushed for strong typing on everything, and she was right in principle, but we were shipping to meet a school district deadline and pragmatism won.
Second, sections and questions are both at the lesson level. Sections group questions logically (an instruction section followed by a practice section followed by an assessment), but questions also exist as a flat list for the adaptive engine. This dual structure caused a few bugs early on — Linh once spent an afternoon debugging why a question appeared twice because it was being read from both the section and the flat list — but the flexibility was worth it for the adaptive difficulty system.
Third, downloadedAt is on the lesson model, not in a separate table. This was deliberate. When we render the lesson list, we need to know immediately whether each lesson is available offline, and joining across tables on every list render was adding noticeable lag on older devices.
Interactive Question Widgets
Each question type renders differently, and each renders differently depending on the child’s age tier. This is where Hana’s UX research from Part 3 became critical engineering requirements.
Multiple choice for preschoolers means four large, colorful image-based options with a minimum tap target of 64x64 logical pixels, text-to-speech reading the question aloud, and a generous touch area around each option. For elementary kids, it is six text-based options with icons, standard tap targets, and the question displayed as text with an optional audio button. For preteens, it is a clean radio-button list with text and an optional image, closer to what you would see on a standardized test.
We built a factory pattern for this:
// lib/widgets/question/question_widget_factory.dart
class QuestionWidgetFactory {
static Widget build({
required Question question,
required AgeTier ageTier,
required ValueChanged<Answer> onAnswerSelected,
required bool showFeedback,
Answer? selectedAnswer,
}) {
switch (question.type) {
case QuestionType.multipleChoice:
return MultipleChoiceWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
selectedAnswer: selectedAnswer,
);
case QuestionType.dragAndDrop:
return DragAndDropWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
);
case QuestionType.fillInBlank:
return ageTier == AgeTier.preschool
? WordBankFillInWidget(
question: question,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
)
: KeyboardFillInWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
);
case QuestionType.matching:
return MatchingWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
);
case QuestionType.drawing:
return DrawingCanvasWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
);
case QuestionType.audioResponse:
return AudioResponseWidget(
question: question,
ageTier: ageTier,
onAnswerSelected: onAnswerSelected,
showFeedback: showFeedback,
);
}
}
}
Drag and drop was the hardest question type to implement. Flutter’s built-in Draggable and DragTarget widgets work fine for simple cases, but we needed constrained dragging within specific zones, snap-to-grid behavior for younger kids, and multi-item drops for matching exercises. Linh ended up writing a custom gesture handler that tracks the drag position relative to predefined drop zones and provides haptic feedback when the dragged item enters a valid zone:
// lib/widgets/question/drag_and_drop_widget.dart
class DragAndDropWidget extends StatefulWidget {
final Question question;
final AgeTier ageTier;
final ValueChanged<Answer> onAnswerSelected;
final bool showFeedback;
const DragAndDropWidget({
super.key,
required this.question,
required this.ageTier,
required this.onAnswerSelected,
required this.showFeedback,
});
@override
State<DragAndDropWidget> createState() => _DragAndDropWidgetState();
}
class _DragAndDropWidgetState extends State<DragAndDropWidget>
with TickerProviderStateMixin {
final Map<String, String> _placements = {};
final Map<String, GlobalKey> _dropZoneKeys = {};
late final AnimationController _snapController;
@override
void initState() {
super.initState();
_snapController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_initializeDropZones();
}
void _initializeDropZones() {
final dropZones = widget.question.typeSpecificData?['dropZones']
as List<dynamic>? ?? [];
for (final zone in dropZones) {
_dropZoneKeys[zone['id'] as String] = GlobalKey();
}
}
void _onItemDropped(String itemId, String zoneId) {
HapticFeedback.mediumImpact();
setState(() {
_placements[itemId] = zoneId;
});
// Check if all items are placed
final totalItems = widget.question.typeSpecificData?['draggableItems']
as List<dynamic>? ?? [];
if (_placements.length == totalItems.length) {
_validatePlacements();
}
}
void _validatePlacements() {
final correctPlacements = widget.question.typeSpecificData?['correctPlacements']
as Map<String, dynamic>? ?? {};
bool allCorrect = true;
for (final entry in _placements.entries) {
if (correctPlacements[entry.key] != entry.value) {
allCorrect = false;
break;
}
}
// Find the matching answer (correct or incorrect)
final answer = widget.question.answers.firstWhere(
(a) => a.isCorrect == allCorrect,
);
widget.onAnswerSelected(answer);
}
@override
Widget build(BuildContext context) {
final itemSize = widget.ageTier == AgeTier.preschool ? 80.0 : 56.0;
return Column(
children: [
_buildPrompt(),
const SizedBox(height: 16),
_buildDropZones(itemSize),
const SizedBox(height: 24),
_buildDraggableItems(itemSize),
],
);
}
Widget _buildPrompt() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.question.prompt,
style: TextStyle(
fontSize: widget.ageTier == AgeTier.preschool ? 24 : 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildDropZones(double itemSize) {
final dropZones = widget.question.typeSpecificData?['dropZones']
as List<dynamic>? ?? [];
return Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: dropZones.map((zone) {
final zoneId = zone['id'] as String;
final label = zone['label'] as String;
final hasItem = _placements.containsValue(zoneId);
return DragTarget<String>(
key: _dropZoneKeys[zoneId],
onAcceptWithDetails: (details) {
_onItemDropped(details.data, zoneId);
},
builder: (context, candidateData, rejectedData) {
final isHovering = candidateData.isNotEmpty;
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: itemSize + 24,
height: itemSize + 24,
decoration: BoxDecoration(
color: isHovering
? Colors.blue.withOpacity(0.2)
: hasItem
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isHovering ? Colors.blue : Colors.grey.shade300,
width: isHovering ? 2 : 1,
strokeAlign: BorderSide.strokeAlignInside,
),
),
child: Center(
child: Text(
hasItem ? '✓' : label,
style: TextStyle(
fontSize: widget.ageTier == AgeTier.preschool ? 18 : 14,
color: Colors.grey.shade600,
),
),
),
);
},
);
}).toList(),
);
}
Widget _buildDraggableItems(double itemSize) {
final items = widget.question.typeSpecificData?['draggableItems']
as List<dynamic>? ?? [];
return Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: items.map((item) {
final itemId = item['id'] as String;
final isPlaced = _placements.containsKey(itemId);
if (isPlaced) return SizedBox(width: itemSize, height: itemSize);
return Draggable<String>(
data: itemId,
feedback: Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
child: _buildItemContent(item, itemSize, isDragging: true),
),
childWhenDragging: Opacity(
opacity: 0.3,
child: _buildItemContent(item, itemSize),
),
child: _buildItemContent(item, itemSize),
);
}).toList(),
);
}
Widget _buildItemContent(
dynamic item,
double size, {
bool isDragging = false,
}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: isDragging ? Colors.blue.shade100 : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDragging ? 0.2 : 0.1),
blurRadius: isDragging ? 12 : 4,
offset: Offset(0, isDragging ? 4 : 2),
),
],
),
child: Center(
child: Text(
item['label'] as String,
style: TextStyle(
fontSize: widget.ageTier == AgeTier.preschool ? 20 : 14,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
);
}
@override
void dispose() {
_snapController.dispose();
super.dispose();
}
}
Fill-in-the-blank has a hard age-tier split. For preschool and early elementary (ages 4-6), we show a word bank — a set of tappable word tiles that the child drags or taps into the blank. No keyboard. Hana was adamant about this: “A four-year-old should not have to find the letter K on a QWERTY keyboard to answer a question about kittens.” For older elementary and preteens (ages 7-12), we show a text input with a custom keyboard that includes autocomplete suggestions and spell-check tolerance. We allow one character off for kids under 10, because punishing a seven-year-old for spelling “giraffe” as “girafe” when they got the science answer right is missing the point.
Audio prompts use platform text-to-speech for pre-readers. Every question prompt can optionally have a audioPromptUrl for pre-recorded audio (better quality, consistent voice) or fall back to TTS. We integrated the flutter_tts package on the Flutter side and expo-speech on the React Native side. The tricky part was timing — the question should not appear answerable until the audio prompt finishes playing, especially for preschoolers who might otherwise just tap randomly:
// lib/services/audio_prompt_service.dart
class AudioPromptService {
final FlutterTts _tts = FlutterTts();
final AudioPlayer _audioPlayer = AudioPlayer();
bool _isPlaying = false;
Future<void> initialize() async {
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(0.45); // Slower for kids
await _tts.setPitch(1.1); // Slightly higher pitch
}
Future<void> playPrompt({
required String text,
String? audioUrl,
required VoidCallback onComplete,
}) async {
if (_isPlaying) return;
_isPlaying = true;
try {
if (audioUrl != null) {
await _audioPlayer.play(UrlSource(audioUrl));
_audioPlayer.onPlayerComplete.first.then((_) {
_isPlaying = false;
onComplete();
});
} else {
_tts.setCompletionHandler(() {
_isPlaying = false;
onComplete();
});
await _tts.speak(text);
}
} catch (e) {
// Fallback: just enable answers immediately
_isPlaying = false;
onComplete();
}
}
Future<void> stop() async {
_isPlaying = false;
await _tts.stop();
await _audioPlayer.stop();
}
}
Adaptive Difficulty
The adaptive difficulty system was where we spent the most time arguing and the least time coding. The algorithm itself is simple. The debate about how it should feel took weeks.
The core idea: the app adjusts difficulty between API calls to the Kids Learn .NET backend, so the child never waits for a server round-trip to get an appropriately challenging question. The server provides lessons with a mix of difficulty levels, and the client-side engine selects which questions to present based on the child’s recent performance.
Here is the client-side adaptive engine:
// lib/engine/adaptive_difficulty_engine.dart
class AdaptiveDifficultyEngine {
static const int _consecutiveCorrectThreshold = 3;
static const int _consecutiveWrongThreshold = 2;
static const int _maxDifficulty = 5;
static const int _minDifficulty = 1;
int _currentDifficulty;
int _consecutiveCorrect = 0;
int _consecutiveWrong = 0;
final List<DifficultyChange> _history = [];
AdaptiveDifficultyEngine({required int initialDifficulty})
: _currentDifficulty = initialDifficulty.clamp(
_minDifficulty,
_maxDifficulty,
);
int get currentDifficulty => _currentDifficulty;
List<DifficultyChange> get history => List.unmodifiable(_history);
DifficultyAdjustment recordAnswer({
required bool isCorrect,
required Duration responseTime,
required int questionDifficulty,
}) {
if (isCorrect) {
_consecutiveCorrect++;
_consecutiveWrong = 0;
} else {
_consecutiveWrong++;
_consecutiveCorrect = 0;
}
DifficultyAdjustment adjustment = DifficultyAdjustment.none;
// Increase difficulty after consecutive correct answers
if (_consecutiveCorrect >= _consecutiveCorrectThreshold) {
if (_currentDifficulty < _maxDifficulty) {
_currentDifficulty++;
adjustment = DifficultyAdjustment.increased;
_history.add(DifficultyChange(
from: _currentDifficulty - 1,
to: _currentDifficulty,
reason: 'Consecutive correct: $_consecutiveCorrect',
timestamp: DateTime.now(),
));
}
_consecutiveCorrect = 0;
}
// Decrease difficulty after consecutive wrong answers
if (_consecutiveWrong >= _consecutiveWrongThreshold) {
if (_currentDifficulty > _minDifficulty) {
_currentDifficulty--;
adjustment = DifficultyAdjustment.decreased;
_history.add(DifficultyChange(
from: _currentDifficulty + 1,
to: _currentDifficulty,
reason: 'Consecutive wrong: $_consecutiveWrong',
timestamp: DateTime.now(),
));
}
_consecutiveWrong = 0;
}
// Bonus: if response time is very fast AND correct, consider
// the question too easy even without hitting the streak threshold
if (isCorrect &&
responseTime.inSeconds < 3 &&
questionDifficulty <= _currentDifficulty - 1) {
_consecutiveCorrect++;
}
return adjustment;
}
Question? selectNextQuestion(List<Question> availableQuestions) {
// Prefer questions at current difficulty level
final atLevel = availableQuestions
.where((q) => _getDifficulty(q) == _currentDifficulty)
.toList();
if (atLevel.isNotEmpty) {
atLevel.shuffle();
return atLevel.first;
}
// Fall back to closest difficulty
availableQuestions.sort((a, b) {
final aDiff = (_getDifficulty(a) - _currentDifficulty).abs();
final bDiff = (_getDifficulty(b) - _currentDifficulty).abs();
return aDiff.compareTo(bDiff);
});
return availableQuestions.firstOrNull;
}
int _getDifficulty(Question question) {
return question.typeSpecificData?['difficulty'] as int? ??
_currentDifficulty;
}
/// Serialize for syncing with backend
Map<String, dynamic> toSyncPayload() {
return {
'currentDifficulty': _currentDifficulty,
'history': _history.map((h) => h.toJson()).toList(),
'timestamp': DateTime.now().toIso8601String(),
};
}
}
enum DifficultyAdjustment { none, increased, decreased }
class DifficultyChange {
final int from;
final int to;
final String reason;
final DateTime timestamp;
const DifficultyChange({
required this.from,
required this.to,
required this.reason,
required this.timestamp,
});
Map<String, dynamic> toJson() => {
'from': from,
'to': to,
'reason': reason,
'timestamp': timestamp.toIso8601String(),
};
}
The React Native equivalent uses the same logic but integrates with a Zustand store for reactivity:
// src/engine/adaptiveDifficultyEngine.ts
interface DifficultyChange {
from: number;
to: number;
reason: string;
timestamp: Date;
}
type DifficultyAdjustment = 'none' | 'increased' | 'decreased';
class AdaptiveDifficultyEngine {
private static readonly CONSECUTIVE_CORRECT_THRESHOLD = 3;
private static readonly CONSECUTIVE_WRONG_THRESHOLD = 2;
private static readonly MAX_DIFFICULTY = 5;
private static readonly MIN_DIFFICULTY = 1;
private currentDifficulty: number;
private consecutiveCorrect = 0;
private consecutiveWrong = 0;
private history: DifficultyChange[] = [];
constructor(initialDifficulty: number) {
this.currentDifficulty = Math.max(
AdaptiveDifficultyEngine.MIN_DIFFICULTY,
Math.min(AdaptiveDifficultyEngine.MAX_DIFFICULTY, initialDifficulty)
);
}
getDifficulty(): number {
return this.currentDifficulty;
}
recordAnswer(params: {
isCorrect: boolean;
responseTimeMs: number;
questionDifficulty: number;
}): DifficultyAdjustment {
const { isCorrect, responseTimeMs, questionDifficulty } = params;
if (isCorrect) {
this.consecutiveCorrect++;
this.consecutiveWrong = 0;
} else {
this.consecutiveWrong++;
this.consecutiveCorrect = 0;
}
let adjustment: DifficultyAdjustment = 'none';
if (this.consecutiveCorrect >= AdaptiveDifficultyEngine.CONSECUTIVE_CORRECT_THRESHOLD) {
if (this.currentDifficulty < AdaptiveDifficultyEngine.MAX_DIFFICULTY) {
const previous = this.currentDifficulty;
this.currentDifficulty++;
adjustment = 'increased';
this.history.push({
from: previous,
to: this.currentDifficulty,
reason: `Consecutive correct: ${this.consecutiveCorrect}`,
timestamp: new Date(),
});
}
this.consecutiveCorrect = 0;
}
if (this.consecutiveWrong >= AdaptiveDifficultyEngine.CONSECUTIVE_WRONG_THRESHOLD) {
if (this.currentDifficulty > AdaptiveDifficultyEngine.MIN_DIFFICULTY) {
const previous = this.currentDifficulty;
this.currentDifficulty--;
adjustment = 'decreased';
this.history.push({
from: previous,
to: this.currentDifficulty,
reason: `Consecutive wrong: ${this.consecutiveWrong}`,
timestamp: new Date(),
});
}
this.consecutiveWrong = 0;
}
// Fast correct answer at lower difficulty signals the content is too easy
if (isCorrect && responseTimeMs < 3000 && questionDifficulty <= this.currentDifficulty - 1) {
this.consecutiveCorrect++;
}
return adjustment;
}
selectNextQuestion(available: Question[]): Question | null {
const atLevel = available.filter(
(q) => (q.typeSpecificData?.difficulty ?? this.currentDifficulty) === this.currentDifficulty
);
if (atLevel.length > 0) {
return atLevel[Math.floor(Math.random() * atLevel.length)];
}
const sorted = [...available].sort((a, b) => {
const aDiff = Math.abs((a.typeSpecificData?.difficulty as number ?? this.currentDifficulty) - this.currentDifficulty);
const bDiff = Math.abs((b.typeSpecificData?.difficulty as number ?? this.currentDifficulty) - this.currentDifficulty);
return aDiff - bDiff;
});
return sorted[0] ?? null;
}
toSyncPayload(): Record<string, unknown> {
return {
currentDifficulty: this.currentDifficulty,
history: this.history,
timestamp: new Date().toISOString(),
};
}
}
The big debate was about transition smoothness. When the difficulty increases, the child should not feel like they suddenly hit a wall. Hana designed a “bridge question” system: when difficulty goes up, the first question at the new level is the easiest possible question at that level. And when difficulty goes down, the child still gets a “warm” question — not the hardest at the lower level, but something that lets them rebuild confidence. The server provides these bridge questions as metadata, and the client engine uses them as the first pick at a new difficulty level.
The other critical detail: we sync difficulty data with the server when online, but the client is always authoritative during a session. If the child is offline for a week, the local adaptive engine keeps working. When they reconnect, we send the history to the Kids Learn backend, which incorporates it into the child’s long-term learning model. The server might respond with an adjusted starting difficulty for the next session, but it never overrides the client mid-session.
Quiz System
Quizzes in KidSpark are distinct from regular lessons. A lesson is open-ended — the child works through material at their own pace. A quiz is a bounded assessment: a fixed number of questions, optional time limits, scoring, and a completion summary. The distinction matters because the UX is completely different. In a lesson, we encourage exploration. In a quiz, we measure mastery.
Timer Management
Time limits are age-dependent and optional. Preschoolers never see a timer — Hana’s research from our usability testing showed that countdown timers caused anxiety in children under six. Elementary kids see a friendly visual countdown, a shrinking colored bar, that turns from green to yellow to red. Preteens see a numerical countdown with minutes and seconds.
The timer implementation handles a subtle edge case: what happens when a child backgrounds the app mid-quiz? On iOS, the app might be suspended. On Android, it might be killed entirely. We save timer state to local storage on every tick and restore it when the app resumes. If more than five minutes have passed while the app was backgrounded, we pause the timer and show a “Welcome back!” prompt instead of penalizing the child for time they spent away:
// lib/quiz/quiz_timer_manager.dart
class QuizTimerManager {
Timer? _timer;
int _remainingSeconds;
final int _totalSeconds;
final AgeTier _ageTier;
final VoidCallback onTimeUp;
final ValueChanged<int> onTick;
final LocalStorage _localStorage;
DateTime? _lastTickTimestamp;
static const int _maxBackgroundSeconds = 300; // 5 minutes
QuizTimerManager({
required int totalSeconds,
required AgeTier ageTier,
required this.onTimeUp,
required this.onTick,
required LocalStorage localStorage,
}) : _totalSeconds = totalSeconds,
_remainingSeconds = totalSeconds,
_ageTier = ageTier,
_localStorage = localStorage;
double get progress => _remainingSeconds / _totalSeconds;
int get remainingSeconds => _remainingSeconds;
bool get isActive => _timer != null && _timer!.isActive;
TimerUrgency get urgency {
final ratio = _remainingSeconds / _totalSeconds;
if (ratio > 0.5) return TimerUrgency.relaxed;
if (ratio > 0.2) return TimerUrgency.warning;
return TimerUrgency.urgent;
}
void start() {
_lastTickTimestamp = DateTime.now();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
_remainingSeconds--;
_lastTickTimestamp = DateTime.now();
_saveState();
onTick(_remainingSeconds);
if (_remainingSeconds <= 0) {
stop();
onTimeUp();
}
});
}
void pause() {
_timer?.cancel();
_saveState();
}
void resume() {
// Check how long we were paused
if (_lastTickTimestamp != null) {
final elapsed = DateTime.now().difference(_lastTickTimestamp!).inSeconds;
if (elapsed > _maxBackgroundSeconds) {
// Don't penalize for long background periods
// Timer resumes from where it was
} else {
// Short background — subtract elapsed time
_remainingSeconds = (_remainingSeconds - elapsed).clamp(0, _totalSeconds);
}
}
if (_remainingSeconds > 0) {
start();
} else {
onTimeUp();
}
}
void stop() {
_timer?.cancel();
_timer = null;
}
Future<void> _saveState() async {
await _localStorage.write(
key: 'quiz_timer_state',
value: {
'remaining': _remainingSeconds,
'total': _totalSeconds,
'lastTick': DateTime.now().toIso8601String(),
},
);
}
Future<void> restoreState() async {
final state = await _localStorage.read(key: 'quiz_timer_state');
if (state != null) {
_remainingSeconds = state['remaining'] as int;
final lastTick = DateTime.parse(state['lastTick'] as String);
_lastTickTimestamp = lastTick;
}
}
void dispose() {
stop();
}
}
enum TimerUrgency { relaxed, warning, urgent }
Answer Validation and Feedback
Answer validation happens in two stages. First, immediate client-side validation for instant feedback. The child taps an answer and within 150 milliseconds sees a green glow for correct or a gentle orange shake for incorrect. No red “X” marks — Hana banned them after a testing session where a six-year-old associated the red X with “being bad” and refused to continue. Orange with a gentle shake animation and an encouraging message like “Almost! Try again” or “Good try! The answer is…” tested much better.
Second, server-side truth when online. The client validates against the answer data it already has, but it also queues a validation event to the server. This matters for two reasons: analytics (the server tracks answer patterns for all children to improve content), and anti-gaming (if someone modifies the local database, the server eventually catches it).
The streak tracking within a quiz was Toan’s idea. He noticed during testing that kids who got three or four right in a row showed visible excitement — sitting up straighter, talking to the screen, bouncing in their seat. So we built a streak counter that rewards consecutive correct answers with bonus stars and escalating visual effects. The streak resets on a wrong answer, but the stars earned during the streak are kept:
// lib/quiz/quiz_bloc.dart
class QuizBloc extends Bloc<QuizEvent, QuizState> {
final LessonRepository lessonRepository;
final ProgressRepository progressRepository;
final AdaptiveDifficultyEngine difficultyEngine;
final AudioPromptService audioService;
QuizBloc({
required this.lessonRepository,
required this.progressRepository,
required this.difficultyEngine,
required this.audioService,
}) : super(QuizInitial()) {
on<QuizStarted>(_onStarted);
on<AnswerSubmitted>(_onAnswerSubmitted);
on<HintRequested>(_onHintRequested);
on<QuizCompleted>(_onCompleted);
on<QuizPaused>(_onPaused);
on<QuizResumed>(_onResumed);
}
Future<void> _onStarted(QuizStarted event, Emitter<QuizState> emit) async {
final lesson = await lessonRepository.getLesson(event.lessonId);
if (lesson == null) {
emit(QuizError('Lesson not found'));
return;
}
final firstQuestion = difficultyEngine.selectNextQuestion(lesson.questions);
if (firstQuestion == null) {
emit(QuizError('No questions available'));
return;
}
emit(QuizInProgress(
lesson: lesson,
currentQuestion: firstQuestion,
questionIndex: 0,
totalQuestions: lesson.questions.length,
score: 0,
maxScore: lesson.totalPoints,
streak: 0,
longestStreak: 0,
hintsUsed: 0,
starsEarned: 0,
answeredQuestionIds: {},
startedAt: DateTime.now(),
));
}
Future<void> _onAnswerSubmitted(
AnswerSubmitted event,
Emitter<QuizState> emit,
) async {
final currentState = state;
if (currentState is! QuizInProgress) return;
final isCorrect = event.answer.isCorrect;
final responseTime = DateTime.now().difference(
currentState.questionStartedAt ?? currentState.startedAt,
);
// Record answer for adaptive engine
final adjustment = difficultyEngine.recordAnswer(
isCorrect: isCorrect,
responseTime: responseTime,
questionDifficulty: currentState.currentQuestion
.typeSpecificData?['difficulty'] as int? ?? 3,
);
// Calculate streak and stars
final newStreak = isCorrect ? currentState.streak + 1 : 0;
final longestStreak = newStreak > currentState.longestStreak
? newStreak
: currentState.longestStreak;
// Bonus stars for streaks: 1 star per correct, +1 bonus at 3-streak,
// +2 bonus at 5-streak, +3 bonus at 7-streak
int starsForAnswer = 0;
if (isCorrect) {
starsForAnswer = 1;
if (newStreak == 3) starsForAnswer += 1;
if (newStreak == 5) starsForAnswer += 2;
if (newStreak == 7) starsForAnswer += 3;
if (newStreak >= 10) starsForAnswer += 5;
}
final newScore = currentState.score +
(isCorrect ? currentState.currentQuestion.points : 0);
// Emit feedback state briefly
emit(QuizAnswerFeedback(
previousState: currentState,
answer: event.answer,
isCorrect: isCorrect,
streakCount: newStreak,
starsAwarded: starsForAnswer,
difficultyAdjustment: adjustment,
explanation: event.answer.explanation,
));
// Wait for animation
await Future.delayed(const Duration(milliseconds: 1500));
// Check if quiz is complete
final answeredIds = {
...currentState.answeredQuestionIds,
currentState.currentQuestion.id,
};
final remaining = currentState.lesson.questions
.where((q) => !answeredIds.contains(q.id))
.toList();
if (remaining.isEmpty) {
add(QuizCompleted());
return;
}
// Select next question via adaptive engine
final nextQuestion = difficultyEngine.selectNextQuestion(remaining);
if (nextQuestion == null) {
add(QuizCompleted());
return;
}
emit(QuizInProgress(
lesson: currentState.lesson,
currentQuestion: nextQuestion,
questionIndex: currentState.questionIndex + 1,
totalQuestions: currentState.totalQuestions,
score: newScore,
maxScore: currentState.maxScore,
streak: newStreak,
longestStreak: longestStreak,
hintsUsed: currentState.hintsUsed,
starsEarned: currentState.starsEarned + starsForAnswer,
answeredQuestionIds: answeredIds,
startedAt: currentState.startedAt,
));
}
Future<void> _onHintRequested(
HintRequested event,
Emitter<QuizState> emit,
) async {
final currentState = state;
if (currentState is! QuizInProgress) return;
final question = currentState.currentQuestion;
final hintsUsedForQuestion = currentState.hintsUsedForCurrentQuestion;
String? hintText;
int starCost = 0;
if (hintsUsedForQuestion == 0 && question.hint != null) {
// First hint is free
hintText = question.hint;
starCost = 0;
} else if (hintsUsedForQuestion == 1 && question.secondHint != null) {
// Second hint costs 2 stars
if (currentState.starsEarned >= 2) {
hintText = question.secondHint;
starCost = 2;
} else {
hintText = 'Not enough stars for a second hint!';
starCost = 0;
}
}
if (hintText != null) {
emit(currentState.copyWith(
hintsUsed: currentState.hintsUsed + 1,
starsEarned: currentState.starsEarned - starCost,
currentHint: hintText,
hintsUsedForCurrentQuestion: hintsUsedForQuestion + 1,
));
}
}
Future<void> _onCompleted(
QuizCompleted event,
Emitter<QuizState> emit,
) async {
final currentState = state;
QuizInProgress progressState;
if (currentState is QuizAnswerFeedback) {
progressState = currentState.previousState;
} else if (currentState is QuizInProgress) {
progressState = currentState;
} else {
return;
}
final duration = DateTime.now().difference(progressState.startedAt);
// Save progress locally
await progressRepository.saveProgress(
lessonId: progressState.lesson.id,
score: progressState.score,
maxScore: progressState.maxScore,
starsEarned: progressState.starsEarned,
duration: duration,
hintsUsed: progressState.hintsUsed,
longestStreak: progressState.longestStreak,
difficultyData: difficultyEngine.toSyncPayload(),
);
emit(QuizComplete(
lessonTitle: progressState.lesson.title,
score: progressState.score,
maxScore: progressState.maxScore,
starsEarned: progressState.starsEarned,
longestStreak: progressState.longestStreak,
duration: duration,
hintsUsed: progressState.hintsUsed,
isNewHighScore: false, // Determined after DB lookup
));
}
Future<void> _onPaused(QuizPaused event, Emitter<QuizState> emit) async {
// Save state for restoration
}
Future<void> _onResumed(QuizResumed event, Emitter<QuizState> emit) async {
// Restore state
}
}
And the React Native equivalent using Zustand:
// src/stores/quizStore.ts
import { create } from 'zustand';
import { AdaptiveDifficultyEngine } from '../engine/adaptiveDifficultyEngine';
import { progressDb } from '../database/progressDb';
interface QuizState {
status: 'idle' | 'active' | 'feedback' | 'complete' | 'error';
lesson: Lesson | null;
currentQuestion: Question | null;
questionIndex: number;
totalQuestions: number;
score: number;
maxScore: number;
streak: number;
longestStreak: number;
hintsUsed: number;
starsEarned: number;
answeredQuestionIds: Set<string>;
startedAt: Date | null;
lastAnswer: Answer | null;
lastAnswerCorrect: boolean;
currentHint: string | null;
difficultyEngine: AdaptiveDifficultyEngine | null;
startQuiz: (lesson: Lesson, initialDifficulty: number) => void;
submitAnswer: (answer: Answer) => void;
requestHint: () => void;
completeQuiz: () => Promise<void>;
}
export const useQuizStore = create<QuizState>((set, get) => ({
status: 'idle',
lesson: null,
currentQuestion: null,
questionIndex: 0,
totalQuestions: 0,
score: 0,
maxScore: 0,
streak: 0,
longestStreak: 0,
hintsUsed: 0,
starsEarned: 0,
answeredQuestionIds: new Set(),
startedAt: null,
lastAnswer: null,
lastAnswerCorrect: false,
currentHint: null,
difficultyEngine: null,
startQuiz: (lesson: Lesson, initialDifficulty: number) => {
const engine = new AdaptiveDifficultyEngine(initialDifficulty);
const firstQuestion = engine.selectNextQuestion(lesson.questions);
if (!firstQuestion) {
set({ status: 'error' });
return;
}
set({
status: 'active',
lesson,
currentQuestion: firstQuestion,
questionIndex: 0,
totalQuestions: lesson.questions.length,
score: 0,
maxScore: lesson.questions.reduce((sum, q) => sum + q.points, 0),
streak: 0,
longestStreak: 0,
hintsUsed: 0,
starsEarned: 0,
answeredQuestionIds: new Set(),
startedAt: new Date(),
lastAnswer: null,
lastAnswerCorrect: false,
currentHint: null,
difficultyEngine: engine,
});
},
submitAnswer: (answer: Answer) => {
const state = get();
if (state.status !== 'active' || !state.currentQuestion || !state.difficultyEngine) return;
const isCorrect = answer.isCorrect;
const responseTimeMs = Date.now() - (state.startedAt?.getTime() ?? Date.now());
state.difficultyEngine.recordAnswer({
isCorrect,
responseTimeMs,
questionDifficulty:
(state.currentQuestion.typeSpecificData?.difficulty as number) ?? 3,
});
const newStreak = isCorrect ? state.streak + 1 : 0;
const longestStreak = Math.max(newStreak, state.longestStreak);
let starsForAnswer = 0;
if (isCorrect) {
starsForAnswer = 1;
if (newStreak === 3) starsForAnswer += 1;
if (newStreak === 5) starsForAnswer += 2;
if (newStreak === 7) starsForAnswer += 3;
if (newStreak >= 10) starsForAnswer += 5;
}
const newScore = state.score + (isCorrect ? state.currentQuestion.points : 0);
const newAnswered = new Set(state.answeredQuestionIds);
newAnswered.add(state.currentQuestion.id);
// Show feedback briefly
set({
status: 'feedback',
lastAnswer: answer,
lastAnswerCorrect: isCorrect,
score: newScore,
streak: newStreak,
longestStreak,
starsEarned: state.starsEarned + starsForAnswer,
answeredQuestionIds: newAnswered,
});
// After animation delay, advance to next question
setTimeout(() => {
const currentState = get();
if (!currentState.lesson || !currentState.difficultyEngine) return;
const remaining = currentState.lesson.questions.filter(
(q) => !currentState.answeredQuestionIds.has(q.id)
);
if (remaining.length === 0) {
currentState.completeQuiz();
return;
}
const nextQuestion = currentState.difficultyEngine.selectNextQuestion(remaining);
if (!nextQuestion) {
currentState.completeQuiz();
return;
}
set({
status: 'active',
currentQuestion: nextQuestion,
questionIndex: currentState.questionIndex + 1,
currentHint: null,
});
}, 1500);
},
requestHint: () => {
const state = get();
if (!state.currentQuestion) return;
if (state.hintsUsed === 0 && state.currentQuestion.hint) {
set({
currentHint: state.currentQuestion.hint,
hintsUsed: state.hintsUsed + 1,
});
} else if (state.hintsUsed === 1 && state.currentQuestion.secondHint) {
if (state.starsEarned >= 2) {
set({
currentHint: state.currentQuestion.secondHint,
hintsUsed: state.hintsUsed + 1,
starsEarned: state.starsEarned - 2,
});
}
}
},
completeQuiz: async () => {
const state = get();
if (!state.lesson || !state.startedAt) return;
const duration = Date.now() - state.startedAt.getTime();
await progressDb.saveProgress({
lessonId: state.lesson.id,
score: state.score,
maxScore: state.maxScore,
starsEarned: state.starsEarned,
durationMs: duration,
hintsUsed: state.hintsUsed,
longestStreak: state.longestStreak,
difficultyData: state.difficultyEngine?.toSyncPayload() ?? {},
synced: false,
});
set({ status: 'complete' });
},
}));
The hint system deserves its own explanation. We went through three designs before landing on one that balanced helpfulness with challenge. Version one gave unlimited free hints, and kids just tapped the hint button before even reading the question. Version two charged stars for every hint, and kids stopped using hints entirely because they were hoarding stars. Version three — what shipped — gives the first hint for free and charges two stars for the second hint. Toan was skeptical at first. “Kids won’t understand the concept of spending currency on hints,” he said. He was wrong. In our testing sessions, kids as young as five quickly learned that the hint button was “free the first time, then costs stars,” and they started being strategic about when to use their second hint. It was one of those moments where children surprised us by being smarter than we gave them credit for.
Progress Tracking
Progress tracking in a kids’ app is more nuanced than in an adult app. Adults look at charts and percentages. A five-year-old looks at whether the dinosaur on their progress screen grew bigger today. But beneath the playful visualization, we need serious data engineering — because this data drives the adaptive engine, informs parental reports, and needs to survive offline periods that could last days.
Offline-First Architecture
We made a decision early in the project that shaped everything else: the local database is the source of truth for progress data. Not the server. Not the API. The local database. The server is a secondary copy that gets updated when connectivity allows.
This is the opposite of how most apps work, and it was the right call for a kids’ app. Children use tablets on airplanes, in cars, at grandparents’ houses with spotty Wi-Fi, and in schools that block most internet traffic. If progress tracking requires connectivity, you lose half your usage scenarios.
Here is the local database setup in Flutter using Isar:
// lib/database/models/progress_record.dart
import 'package:isar/isar.dart';
part 'progress_record.g.dart';
@collection
class ProgressRecord {
Id id = Isar.autoIncrement;
@Index()
late String childId;
@Index()
late String lessonId;
@Index(composite: [CompositeIndex('lessonId')])
late String uniqueKey; // childId_lessonId_timestamp
late int score;
late int maxScore;
late int starsEarned;
late int durationSeconds;
late int hintsUsed;
late int longestStreak;
late DateTime completedAt;
late bool synced;
String? syncError;
DateTime? syncedAt;
// Serialized adaptive difficulty data
String? difficultyDataJson;
double get percentage => maxScore > 0 ? score / maxScore : 0;
bool get isPassing => percentage >= 0.7;
}
// lib/database/models/achievement_record.dart
@collection
class AchievementRecord {
Id id = Isar.autoIncrement;
@Index()
late String childId;
@Index()
late String achievementId;
late String title;
late String description;
late String iconName;
late DateTime unlockedAt;
late bool synced;
late bool seen; // Whether the child has viewed the unlock animation
}
// lib/database/models/streak_record.dart
@collection
class StreakRecord {
Id id = Isar.autoIncrement;
@Index()
late String childId;
late int currentStreak;
late int longestStreak;
late DateTime lastActivityDate;
late bool usedRepairThisMonth;
late DateTime? lastRepairDate;
}
And the React Native equivalent using WatermelonDB, which gives us a similar offline-first SQLite-backed database with reactive queries:
// src/database/models/ProgressRecord.ts
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly, text } from '@nozbe/watermelondb/decorators';
export class ProgressRecord extends Model {
static table = 'progress_records';
@text('child_id') childId!: string;
@text('lesson_id') lessonId!: string;
@field('score') score!: number;
@field('max_score') maxScore!: number;
@field('stars_earned') starsEarned!: number;
@field('duration_seconds') durationSeconds!: number;
@field('hints_used') hintsUsed!: number;
@field('longest_streak') longestStreak!: number;
@field('synced') synced!: boolean;
@text('sync_error') syncError!: string | null;
@date('completed_at') completedAt!: Date;
@date('synced_at') syncedAt!: Date | null;
@text('difficulty_data_json') difficultyDataJson!: string | null;
get percentage(): number {
return this.maxScore > 0 ? this.score / this.maxScore : 0;
}
get isPassing(): boolean {
return this.percentage >= 0.7;
}
}
// src/database/models/AchievementRecord.ts
export class AchievementRecord extends Model {
static table = 'achievement_records';
@text('child_id') childId!: string;
@text('achievement_id') achievementId!: string;
@text('title') title!: string;
@text('description') description!: string;
@text('icon_name') iconName!: string;
@date('unlocked_at') unlockedAt!: Date;
@field('synced') synced!: boolean;
@field('seen') seen!: boolean;
}
// src/database/schema.ts
import { appSchema, tableSchema } from '@nozbe/watermelondb';
export const schema = appSchema({
version: 3,
tables: [
tableSchema({
name: 'progress_records',
columns: [
{ name: 'child_id', type: 'string', isIndexed: true },
{ name: 'lesson_id', type: 'string', isIndexed: true },
{ name: 'score', type: 'number' },
{ name: 'max_score', type: 'number' },
{ name: 'stars_earned', type: 'number' },
{ name: 'duration_seconds', type: 'number' },
{ name: 'hints_used', type: 'number' },
{ name: 'longest_streak', type: 'number' },
{ name: 'synced', type: 'boolean' },
{ name: 'sync_error', type: 'string', isOptional: true },
{ name: 'completed_at', type: 'number' },
{ name: 'synced_at', type: 'number', isOptional: true },
{ name: 'difficulty_data_json', type: 'string', isOptional: true },
],
}),
tableSchema({
name: 'achievement_records',
columns: [
{ name: 'child_id', type: 'string', isIndexed: true },
{ name: 'achievement_id', type: 'string', isIndexed: true },
{ name: 'title', type: 'string' },
{ name: 'description', type: 'string' },
{ name: 'icon_name', type: 'string' },
{ name: 'unlocked_at', type: 'number' },
{ name: 'synced', type: 'boolean' },
{ name: 'seen', type: 'boolean' },
],
}),
],
});
Sync Queue Pattern
The sync queue is a simple but critical piece of infrastructure. Every time the app writes progress data locally, it also enqueues a sync operation. When connectivity is available, the sync service processes the queue in order:
// lib/services/sync_service.dart
class SyncService {
final Isar _db;
final KidsLearnApiClient _apiClient;
final ConnectivityService _connectivity;
Timer? _syncTimer;
static const int _maxRetries = 3;
static const Duration _retryBaseDelay = Duration(seconds: 2);
static const Duration _syncInterval = Duration(minutes: 5);
SyncService({
required Isar db,
required KidsLearnApiClient apiClient,
required ConnectivityService connectivity,
}) : _db = db,
_apiClient = apiClient,
_connectivity = connectivity;
void startPeriodicSync() {
_syncTimer = Timer.periodic(_syncInterval, (_) => syncAll());
// Also sync when connectivity changes to online
_connectivity.onConnectivityChanged.listen((isOnline) {
if (isOnline) {
syncAll();
}
});
}
Future<SyncResult> syncAll() async {
if (!await _connectivity.isOnline) {
return SyncResult(synced: 0, failed: 0, pending: 0);
}
int synced = 0;
int failed = 0;
// Sync progress records
final unsyncedProgress = await _db.progressRecords
.filter()
.syncedEqualTo(false)
.findAll();
for (final record in unsyncedProgress) {
final success = await _syncProgressRecord(record);
if (success) {
synced++;
} else {
failed++;
}
}
// Sync achievement records
final unsyncedAchievements = await _db.achievementRecords
.filter()
.syncedEqualTo(false)
.findAll();
for (final record in unsyncedAchievements) {
final success = await _syncAchievementRecord(record);
if (success) {
synced++;
} else {
failed++;
}
}
final pendingCount = await _db.progressRecords
.filter()
.syncedEqualTo(false)
.count();
return SyncResult(
synced: synced,
failed: failed,
pending: pendingCount,
);
}
Future<bool> _syncProgressRecord(ProgressRecord record) async {
for (int attempt = 0; attempt < _maxRetries; attempt++) {
try {
await _apiClient.submitProgress(
childId: record.childId,
lessonId: record.lessonId,
score: record.score,
maxScore: record.maxScore,
starsEarned: record.starsEarned,
durationSeconds: record.durationSeconds,
completedAt: record.completedAt,
difficultyData: record.difficultyDataJson != null
? jsonDecode(record.difficultyDataJson!)
: null,
);
// Mark as synced
await _db.writeTxn(() async {
record.synced = true;
record.syncedAt = DateTime.now();
record.syncError = null;
await _db.progressRecords.put(record);
});
return true;
} on ApiException catch (e) {
if (e.statusCode == 409) {
// Conflict — server already has this record
// Mark as synced since the data exists on the server
await _db.writeTxn(() async {
record.synced = true;
record.syncedAt = DateTime.now();
await _db.progressRecords.put(record);
});
return true;
}
if (attempt < _maxRetries - 1) {
// Exponential backoff
await Future.delayed(
_retryBaseDelay * (1 << attempt),
);
} else {
// Final attempt failed — record the error
await _db.writeTxn(() async {
record.syncError = e.message;
await _db.progressRecords.put(record);
});
}
} catch (e) {
if (attempt == _maxRetries - 1) {
await _db.writeTxn(() async {
record.syncError = e.toString();
await _db.progressRecords.put(record);
});
}
await Future.delayed(_retryBaseDelay * (1 << attempt));
}
}
return false;
}
Future<bool> _syncAchievementRecord(AchievementRecord record) async {
try {
await _apiClient.submitAchievement(
childId: record.childId,
achievementId: record.achievementId,
unlockedAt: record.unlockedAt,
);
await _db.writeTxn(() async {
record.synced = true;
await _db.achievementRecords.put(record);
});
return true;
} catch (e) {
return false;
}
}
void dispose() {
_syncTimer?.cancel();
}
}
class SyncResult {
final int synced;
final int failed;
final int pending;
const SyncResult({
required this.synced,
required this.failed,
required this.pending,
});
}
The conflict resolution strategy is deliberately simple. For progress records, we use “last write wins” — if the server already has a record for the same child, lesson, and timestamp, we keep whichever is newer. For achievements, we use “merge” — if the server says the child has a badge and the local database says the child has the badge, we keep both records and take the earlier unlock time. We considered more sophisticated CRDT-based approaches, but the reality is that conflicts are rare in a single-user-per-device kids’ app, and the simple strategy covers 99.9% of cases.
Progress Visualization
The data matters, but the visualization is what children interact with. Hana designed three visualizations: mastery rings per subject, a learning streaks calendar, and subject-level progress bars.
Mastery rings are circular progress indicators that fill as the child completes lessons in a subject. We used Flutter’s CustomPainter to draw them:
// lib/widgets/progress/mastery_ring.dart
class MasteryRingPainter extends CustomPainter {
final double progress; // 0.0 to 1.0
final Color ringColor;
final Color backgroundColor;
final double strokeWidth;
MasteryRingPainter({
required this.progress,
required this.ringColor,
required this.backgroundColor,
this.strokeWidth = 8.0,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
// Background ring
final bgPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
// Progress ring
final progressPaint = Paint()
..color = ringColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2, // Start from top
sweepAngle,
false,
progressPaint,
);
}
@override
bool shouldRepaint(MasteryRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.ringColor != ringColor;
}
}
class MasteryRingWidget extends StatelessWidget {
final Subject subject;
final double progress;
final int level;
const MasteryRingWidget({
super.key,
required this.subject,
required this.progress,
required this.level,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(
width: 80,
height: 80,
child: CustomPaint(
painter: MasteryRingPainter(
progress: progress,
ringColor: _colorForSubject(subject),
backgroundColor: Colors.grey.shade200,
),
child: Center(
child: Text(
'Lv$level',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 4),
Text(
subject.name.capitalize(),
style: const TextStyle(fontSize: 12),
),
],
);
}
Color _colorForSubject(Subject subject) {
return switch (subject) {
Subject.math => const Color(0xFF4CAF50),
Subject.reading => const Color(0xFF2196F3),
Subject.science => const Color(0xFFFF9800),
Subject.language => const Color(0xFF9C27B0),
Subject.creativity => const Color(0xFFE91E63),
};
}
}
The learning streaks calendar shows a heat map of the last 30 days, with each day colored by activity level — gray for no activity, light green for one lesson, medium green for two to three, and dark green for four or more. It is deliberately similar to GitHub’s contribution graph, because parents recognize the pattern and understand it immediately.
Gamification Engine
Gamification in a kids’ app walks a knife-edge. Done right, it motivates children to learn, builds habits, and creates genuine excitement. Done wrong, it manipulates, creates anxiety, and teaches children that the reward matters more than the learning. We spent more time on gamification ethics than on gamification engineering.
Badge System
The badge system is the most visible gamification element. Children earn badges for achievements like completing their first lesson, maintaining a five-day streak, mastering a subject level, or trying every question type. We defined achievements as data, not code, so we can add new badges without app updates:
// lib/gamification/achievement_definitions.dart
class AchievementDefinition {
final String id;
final String title;
final String description;
final String iconAsset;
final AchievementCategory category;
final AchievementCondition condition;
final bool isSecret; // Hidden until unlocked
const AchievementDefinition({
required this.id,
required this.title,
required this.description,
required this.iconAsset,
required this.category,
required this.condition,
this.isSecret = false,
});
}
enum AchievementCategory {
milestone, // "First Lesson", "100th Question"
streak, // "5-Day Streak", "30-Day Streak"
mastery, // "Math Master Level 3", "Science Explorer"
exploration,// "Tried All Subjects", "Used Every Question Type"
social, // "Shared Progress with Parent"
}
sealed class AchievementCondition {
bool evaluate(AchievementContext context);
}
class LessonsCompletedCondition extends AchievementCondition {
final int requiredCount;
final Subject? subject;
LessonsCompletedCondition({required this.requiredCount, this.subject});
@override
bool evaluate(AchievementContext context) {
if (subject != null) {
return context.lessonsCompletedBySubject[subject] != null &&
context.lessonsCompletedBySubject[subject]! >= requiredCount;
}
return context.totalLessonsCompleted >= requiredCount;
}
}
class StreakCondition extends AchievementCondition {
final int requiredDays;
StreakCondition({required this.requiredDays});
@override
bool evaluate(AchievementContext context) {
return context.currentStreak >= requiredDays;
}
}
class ScoreCondition extends AchievementCondition {
final double requiredPercentage;
final int requiredCount;
ScoreCondition({required this.requiredPercentage, required this.requiredCount});
@override
bool evaluate(AchievementContext context) {
return context.perfectScoreCount >= requiredCount;
}
}
class AchievementContext {
final int totalLessonsCompleted;
final Map<Subject, int> lessonsCompletedBySubject;
final int currentStreak;
final int longestStreak;
final int totalStarsEarned;
final int perfectScoreCount;
final Set<QuestionType> questionTypesUsed;
final Set<Subject> subjectsExplored;
const AchievementContext({
required this.totalLessonsCompleted,
required this.lessonsCompletedBySubject,
required this.currentStreak,
required this.longestStreak,
required this.totalStarsEarned,
required this.perfectScoreCount,
required this.questionTypesUsed,
required this.subjectsExplored,
});
}
// Predefined achievements
const List<AchievementDefinition> achievements = [
AchievementDefinition(
id: 'first_lesson',
title: 'First Steps',
description: 'Complete your very first lesson',
iconAsset: 'assets/badges/first_steps.svg',
category: AchievementCategory.milestone,
condition: LessonsCompletedCondition(requiredCount: 1),
),
AchievementDefinition(
id: 'streak_5',
title: 'On Fire',
description: 'Learn 5 days in a row',
iconAsset: 'assets/badges/on_fire.svg',
category: AchievementCategory.streak,
condition: StreakCondition(requiredDays: 5),
),
AchievementDefinition(
id: 'streak_30',
title: 'Unstoppable',
description: 'Learn 30 days in a row',
iconAsset: 'assets/badges/unstoppable.svg',
category: AchievementCategory.streak,
condition: StreakCondition(requiredDays: 30),
),
AchievementDefinition(
id: 'math_master_3',
title: 'Math Master Level 3',
description: 'Complete 15 math lessons',
iconAsset: 'assets/badges/math_master.svg',
category: AchievementCategory.mastery,
condition: LessonsCompletedCondition(requiredCount: 15, subject: Subject.math),
),
AchievementDefinition(
id: 'perfect_10',
title: 'Perfect Ten',
description: 'Get 100% on 10 quizzes',
iconAsset: 'assets/badges/perfect_ten.svg',
category: AchievementCategory.mastery,
condition: ScoreCondition(requiredPercentage: 1.0, requiredCount: 10),
isSecret: true,
),
];
Achievement conditions are checked locally after every lesson completion. This is critical — the child sees the badge unlock instantly, not after a network round trip. The unlock event is then queued for syncing to the Kids Learn backend:
// lib/gamification/achievement_checker.dart
class AchievementChecker {
final Isar _db;
final List<AchievementDefinition> _definitions;
AchievementChecker({
required Isar db,
List<AchievementDefinition>? definitions,
}) : _db = db,
_definitions = definitions ?? achievements;
Future<List<AchievementDefinition>> checkForNewAchievements({
required String childId,
}) async {
// Build context from local database
final context = await _buildContext(childId);
// Get already-unlocked achievement IDs
final unlocked = await _db.achievementRecords
.filter()
.childIdEqualTo(childId)
.findAll();
final unlockedIds = unlocked.map((a) => a.achievementId).toSet();
// Check each definition
final newlyUnlocked = <AchievementDefinition>[];
for (final definition in _definitions) {
if (unlockedIds.contains(definition.id)) continue;
if (definition.condition.evaluate(context)) {
// Unlock it
await _db.writeTxn(() async {
final record = AchievementRecord()
..childId = childId
..achievementId = definition.id
..title = definition.title
..description = definition.description
..iconName = definition.iconAsset
..unlockedAt = DateTime.now()
..synced = false
..seen = false;
await _db.achievementRecords.put(record);
});
newlyUnlocked.add(definition);
}
}
return newlyUnlocked;
}
Future<AchievementContext> _buildContext(String childId) async {
final allProgress = await _db.progressRecords
.filter()
.childIdEqualTo(childId)
.findAll();
final bySubject = <Subject, int>{};
int perfectCount = 0;
for (final record in allProgress) {
// Count by subject (would need lesson lookup in real implementation)
if (record.percentage >= 1.0) perfectCount++;
}
final streak = await _db.streakRecords
.filter()
.childIdEqualTo(childId)
.findFirst();
return AchievementContext(
totalLessonsCompleted: allProgress.length,
lessonsCompletedBySubject: bySubject,
currentStreak: streak?.currentStreak ?? 0,
longestStreak: streak?.longestStreak ?? 0,
totalStarsEarned: allProgress.fold(0, (sum, r) => sum + r.starsEarned),
perfectScoreCount: perfectCount,
questionTypesUsed: {}, // Built from answer history
subjectsExplored: bySubject.keys.toSet(),
);
}
}
Streak Management
The daily streak is the second most powerful motivator after badges. But streaks can also be the most toxic gamification element if handled poorly. Duolingo gets criticized regularly for streak anxiety — users feel genuine distress about breaking their streak, which is not the emotional response we want from children.
Our streak design has three key differences from the typical approach. First, we have a one-day grace period. If a child misses one day, their streak does not break immediately. It enters a “grace” state, and if they complete a lesson the next day, the streak continues as if nothing happened. Second, we have a monthly repair. If a child loses their streak, they can repair it once per month by completing two lessons in a single day. This removes the “all is lost” feeling. Third, the streak counter is presented as a positive (“You’ve learned 12 days this month!”) rather than as a threat (“Don’t lose your streak!”).
// lib/gamification/streak_manager.dart
class StreakManager {
final Isar _db;
StreakManager({required Isar db}) : _db = db;
Future<StreakStatus> getStreakStatus(String childId) async {
final record = await _db.streakRecords
.filter()
.childIdEqualTo(childId)
.findFirst();
if (record == null) {
return StreakStatus(
currentStreak: 0,
longestStreak: 0,
state: StreakState.none,
canRepair: true,
);
}
final today = _dateOnly(DateTime.now());
final lastActivity = _dateOnly(record.lastActivityDate);
final daysSinceActivity = today.difference(lastActivity).inDays;
StreakState state;
int effectiveStreak = record.currentStreak;
if (daysSinceActivity == 0) {
// Active today
state = StreakState.active;
} else if (daysSinceActivity == 1) {
// Missed today but still within grace period
state = StreakState.grace;
} else if (daysSinceActivity == 2) {
// Can still repair if they haven't used repair this month
state = StreakState.broken;
effectiveStreak = 0;
} else {
// Streak is lost
state = StreakState.lost;
effectiveStreak = 0;
}
final canRepair = !record.usedRepairThisMonth ||
(record.lastRepairDate != null &&
record.lastRepairDate!.month != DateTime.now().month);
return StreakStatus(
currentStreak: effectiveStreak,
longestStreak: record.longestStreak,
state: state,
canRepair: canRepair && state == StreakState.broken,
);
}
Future<StreakStatus> recordActivity(String childId) async {
var record = await _db.streakRecords
.filter()
.childIdEqualTo(childId)
.findFirst();
final today = _dateOnly(DateTime.now());
if (record == null) {
// First ever activity
record = StreakRecord()
..childId = childId
..currentStreak = 1
..longestStreak = 1
..lastActivityDate = today
..usedRepairThisMonth = false;
await _db.writeTxn(() async {
await _db.streakRecords.put(record!);
});
return StreakStatus(
currentStreak: 1,
longestStreak: 1,
state: StreakState.active,
canRepair: true,
);
}
final lastActivity = _dateOnly(record.lastActivityDate);
final daysSinceActivity = today.difference(lastActivity).inDays;
if (daysSinceActivity == 0) {
// Already active today, no change
return getStreakStatus(childId);
}
if (daysSinceActivity <= 1) {
// Continuing streak (or within grace period)
record.currentStreak++;
} else {
// Streak broken, starting fresh
record.currentStreak = 1;
}
record.lastActivityDate = today;
if (record.currentStreak > record.longestStreak) {
record.longestStreak = record.currentStreak;
}
// Reset monthly repair flag if new month
if (record.lastRepairDate != null &&
record.lastRepairDate!.month != today.month) {
record.usedRepairThisMonth = false;
}
await _db.writeTxn(() async {
await _db.streakRecords.put(record!);
});
return getStreakStatus(childId);
}
Future<bool> repairStreak(String childId) async {
final status = await getStreakStatus(childId);
if (!status.canRepair || status.state != StreakState.broken) {
return false;
}
final record = await _db.streakRecords
.filter()
.childIdEqualTo(childId)
.findFirst();
if (record == null) return false;
// Restore the streak
record.currentStreak = record.longestStreak; // Restore to previous
record.lastActivityDate = DateTime.now();
record.usedRepairThisMonth = true;
record.lastRepairDate = DateTime.now();
await _db.writeTxn(() async {
await _db.streakRecords.put(record);
});
return true;
}
DateTime _dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);
}
enum StreakState { none, active, grace, broken, lost }
class StreakStatus {
final int currentStreak;
final int longestStreak;
final StreakState state;
final bool canRepair;
const StreakStatus({
required this.currentStreak,
required this.longestStreak,
required this.state,
required this.canRepair,
});
}
Celebration Animations
When a child completes a lesson or unlocks a badge, the celebration has to be worth it. This is where Rive and Lottie animations come in. We use Lottie for simple effects like confetti bursts and star collections, and Rive for interactive character animations where the character’s reaction adapts to the child’s score.
The celebration system is age-tiered. Preschoolers get big, colorful confetti with a dancing character, sound effects, and haptic feedback. The celebration lasts four to five seconds and fills the entire screen. Elementary kids get confetti with a smaller character animation and a score display, lasting about three seconds. Preteens get a subtle confetti burst, a score card, and a brief haptic buzz — closer to an adult app’s success state.
// lib/widgets/celebration/celebration_overlay.dart
class CelebrationOverlay extends StatefulWidget {
final AgeTier ageTier;
final int score;
final int maxScore;
final int starsEarned;
final int longestStreak;
final VoidCallback onComplete;
const CelebrationOverlay({
super.key,
required this.ageTier,
required this.score,
required this.maxScore,
required this.starsEarned,
required this.longestStreak,
required this.onComplete,
});
@override
State<CelebrationOverlay> createState() => _CelebrationOverlayState();
}
class _CelebrationOverlayState extends State<CelebrationOverlay>
with TickerProviderStateMixin {
late final AnimationController _confettiController;
late final AnimationController _scoreController;
late final AnimationController _starsController;
@override
void initState() {
super.initState();
final celebrationDuration = switch (widget.ageTier) {
AgeTier.preschool => const Duration(milliseconds: 5000),
AgeTier.elementary => const Duration(milliseconds: 3500),
AgeTier.preteen => const Duration(milliseconds: 2000),
};
_confettiController = AnimationController(
vsync: this,
duration: celebrationDuration,
);
_scoreController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_starsController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_startCelebration();
}
Future<void> _startCelebration() async {
// Haptic feedback
HapticFeedback.heavyImpact();
// Start confetti
_confettiController.forward();
// Stagger the score reveal
await Future.delayed(const Duration(milliseconds: 500));
_scoreController.forward();
// Then reveal stars with a count-up animation
await Future.delayed(const Duration(milliseconds: 400));
_starsController.forward();
// Additional haptic pulses for preschool
if (widget.ageTier == AgeTier.preschool) {
for (int i = 0; i < 3; i++) {
await Future.delayed(const Duration(milliseconds: 300));
HapticFeedback.lightImpact();
}
}
// Wait for celebration to finish, then callback
await Future.delayed(
switch (widget.ageTier) {
AgeTier.preschool => const Duration(milliseconds: 5500),
AgeTier.elementary => const Duration(milliseconds: 4000),
AgeTier.preteen => const Duration(milliseconds: 2500),
},
);
widget.onComplete();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Background overlay
AnimatedBuilder(
animation: _confettiController,
builder: (context, child) {
return Container(
color: Colors.black.withOpacity(
0.3 * _confettiController.value.clamp(0.0, 1.0),
),
);
},
),
// Confetti layer (Lottie animation)
if (widget.ageTier != AgeTier.preteen)
Positioned.fill(
child: Lottie.asset(
'assets/animations/confetti_burst.json',
controller: _confettiController,
fit: BoxFit.cover,
),
),
// Score card
Center(
child: ScaleTransition(
scale: CurvedAnimation(
parent: _scoreController,
curve: Curves.elasticOut,
),
child: _buildScoreCard(),
),
),
// Star counter
Positioned(
bottom: 100,
left: 0,
right: 0,
child: FadeTransition(
opacity: _starsController,
child: _buildStarCounter(),
),
),
// Character animation for younger kids
if (widget.ageTier == AgeTier.preschool)
Positioned(
bottom: 200,
right: 20,
child: SizedBox(
width: 120,
height: 120,
child: RiveAnimation.asset(
'assets/animations/spark_character.riv',
stateMachines: const ['celebration'],
),
),
),
],
);
}
Widget _buildScoreCard() {
final percentage = widget.maxScore > 0
? (widget.score / widget.maxScore * 100).round()
: 0;
final fontSize = switch (widget.ageTier) {
AgeTier.preschool => 48.0,
AgeTier.elementary => 36.0,
AgeTier.preteen => 28.0,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_celebrationMessage(percentage),
style: TextStyle(
fontSize: fontSize * 0.5,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 8),
Text(
'$percentage%',
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: _scoreColor(percentage),
),
),
if (widget.longestStreak > 2) ...[
const SizedBox(height: 8),
Text(
'${widget.longestStreak} in a row!',
style: TextStyle(
fontSize: fontSize * 0.4,
color: Colors.orange.shade700,
),
),
],
],
),
);
}
Widget _buildStarCounter() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(widget.starsEarned.clamp(0, 5), (index) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: 200 + (index * 100)),
builder: (context, value, child) {
return Transform.scale(
scale: value,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Icon(
Icons.star_rounded,
size: 40,
color: Colors.amber,
),
),
);
},
);
}),
);
}
String _celebrationMessage(int percentage) {
if (percentage >= 100) return 'Perfect!';
if (percentage >= 80) return 'Amazing!';
if (percentage >= 60) return 'Great job!';
if (percentage >= 40) return 'Good effort!';
return 'Keep going!';
}
Color _scoreColor(int percentage) {
if (percentage >= 80) return Colors.green.shade700;
if (percentage >= 60) return Colors.blue.shade700;
if (percentage >= 40) return Colors.orange.shade700;
return Colors.grey.shade700;
}
@override
void dispose() {
_confettiController.dispose();
_scoreController.dispose();
_starsController.dispose();
super.dispose();
}
}
What We Deliberately Excluded
This section is as important as everything above. Gamification in kids’ apps has a dark side, and we drew firm lines around what KidSpark would never do.
No loot boxes or random rewards. Every reward in KidSpark is deterministic. If you complete the lesson, you get the stars. If you maintain the streak, you get the badge. There is no “mystery box” that might contain a rare badge. Toan initially wanted a “daily surprise chest” feature — common in mobile games — and Hana shut it down immediately. “Variable-ratio reinforcement schedules are literally the psychology behind slot machines,” she said. “We are not putting slot machine mechanics in a children’s app.” She was right.
No social leaderboards for children under ten. Older kids in the preteen tier can optionally see how their class is doing (with parental consent), but younger children never see competitive rankings. The research is clear: social comparison in young children leads to discouragement in lower-performing kids and anxiety about maintaining position in higher-performing kids. Neither is the relationship we want children to have with learning.
No countdown pressure tactics. “Only 2 hours left to earn double stars!” — you see this in every mobile game. We do not do it. Learning is not a flash sale. The content is always there, the rewards are always available, and there is no artificial urgency.
No shame messaging. The app never says “You missed yesterday!” or “You’re falling behind!” or “Your friends are ahead of you!” When a child returns after a break, the app says “Welcome back!” and shows them where they left off. Hana formalized this as a design principle: “Celebrate achievement, never punish absence.” We printed it on a card and taped it to the monitor next to the design system.
No dark patterns in the parent-to-child loop. Some kids’ apps send notifications to parents saying “Your child hasn’t practiced today!” to guilt parents into making their child use the app. We send weekly progress summaries to parents, but never shame-based notifications. The summary focuses on what the child accomplished, not what they missed.
Parental Controls
Parental controls in KidSpark serve two purposes: giving parents agency over their child’s experience, and meeting the compliance requirements we will cover in Part 6. The implementation follows a middleware pattern — parental controls wrap child-facing features and can block, modify, or log interactions without the feature code knowing about it.
The parent area is PIN-protected with an optional biometric unlock. The PIN entry is deliberately designed to be difficult for children — it is not a simple four-digit keypad, but a “grown-up” interface with small buttons and a settings icon that does not look playful:
// lib/parental/parental_gate.dart
class ParentalGate {
static const String _pinKey = 'parental_pin';
final SecureStorage _secureStorage;
ParentalGate({required SecureStorage secureStorage})
: _secureStorage = secureStorage;
Future<bool> isPinSet() async {
final pin = await _secureStorage.read(key: _pinKey);
return pin != null && pin.isNotEmpty;
}
Future<void> setPin(String pin) async {
// Hash the PIN before storing
final hashed = sha256.convert(utf8.encode(pin)).toString();
await _secureStorage.write(key: _pinKey, value: hashed);
}
Future<bool> validatePin(String pin) async {
final stored = await _secureStorage.read(key: _pinKey);
if (stored == null) return false;
final hashed = sha256.convert(utf8.encode(pin)).toString();
return hashed == stored;
}
Future<bool> authenticateWithBiometrics() async {
try {
final auth = LocalAuthentication();
final canAuth = await auth.canCheckBiometrics;
if (!canAuth) return false;
return await auth.authenticate(
localizedReason: 'Verify your identity to access parent settings',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,
),
);
} catch (e) {
return false;
}
}
}
// lib/parental/screen_time_manager.dart
class ScreenTimeManager {
final SharedPreferences _prefs;
Timer? _checkTimer;
final ValueChanged<ScreenTimeStatus> onStatusChanged;
ScreenTimeManager({
required SharedPreferences prefs,
required this.onStatusChanged,
}) : _prefs = prefs;
int get dailyLimitMinutes => _prefs.getInt('daily_limit_minutes') ?? 60;
int get weeklyLimitMinutes => _prefs.getInt('weekly_limit_minutes') ?? 300;
TimeOfDay get quietHoursStart {
final hour = _prefs.getInt('quiet_hours_start_hour') ?? 20;
final minute = _prefs.getInt('quiet_hours_start_minute') ?? 0;
return TimeOfDay(hour: hour, minute: minute);
}
TimeOfDay get quietHoursEnd {
final hour = _prefs.getInt('quiet_hours_end_hour') ?? 7;
final minute = _prefs.getInt('quiet_hours_end_minute') ?? 0;
return TimeOfDay(hour: hour, minute: minute);
}
Future<void> startTracking() async {
_checkTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _checkScreenTime(),
);
_checkScreenTime();
}
Future<void> _checkScreenTime() async {
final todayMinutes = await _getTodayUsageMinutes();
final weekMinutes = await _getWeekUsageMinutes();
final inQuietHours = _isInQuietHours();
if (inQuietHours) {
onStatusChanged(ScreenTimeStatus.quietHours);
} else if (todayMinutes >= dailyLimitMinutes) {
onStatusChanged(ScreenTimeStatus.dailyLimitReached);
} else if (weekMinutes >= weeklyLimitMinutes) {
onStatusChanged(ScreenTimeStatus.weeklyLimitReached);
} else if (todayMinutes >= dailyLimitMinutes - 5) {
onStatusChanged(ScreenTimeStatus.warningFiveMinutes);
} else {
onStatusChanged(ScreenTimeStatus.active);
}
}
bool _isInQuietHours() {
final now = TimeOfDay.now();
final start = quietHoursStart;
final end = quietHoursEnd;
final nowMinutes = now.hour * 60 + now.minute;
final startMinutes = start.hour * 60 + start.minute;
final endMinutes = end.hour * 60 + end.minute;
if (startMinutes > endMinutes) {
// Quiet hours cross midnight (e.g., 20:00 to 07:00)
return nowMinutes >= startMinutes || nowMinutes < endMinutes;
} else {
return nowMinutes >= startMinutes && nowMinutes < endMinutes;
}
}
Future<int> _getTodayUsageMinutes() async {
final key = 'usage_${_dateKey(DateTime.now())}';
return _prefs.getInt(key) ?? 0;
}
Future<int> _getWeekUsageMinutes() async {
int total = 0;
final now = DateTime.now();
for (int i = 0; i < 7; i++) {
final day = now.subtract(Duration(days: i));
final key = 'usage_${_dateKey(day)}';
total += _prefs.getInt(key) ?? 0;
}
return total;
}
Future<void> recordMinute() async {
final key = 'usage_${_dateKey(DateTime.now())}';
final current = _prefs.getInt(key) ?? 0;
await _prefs.setInt(key, current + 1);
}
String _dateKey(DateTime dt) => '${dt.year}-${dt.month}-${dt.day}';
void dispose() {
_checkTimer?.cancel();
}
}
enum ScreenTimeStatus {
active,
warningFiveMinutes,
dailyLimitReached,
weeklyLimitReached,
quietHours,
}
The screen time implementation coordinates with the operating system’s built-in screen time features on iOS (Screen Time API) and Android (Digital Wellbeing). We do not try to replace the OS-level controls — we complement them. The app’s internal timer handles our own limits, and we provide information to the OS framework so parents who use Apple’s Screen Time or Google Family Link see accurate data. During quiet hours, the app suppresses all notifications and shows a gentle “Time to rest” screen if the child opens the app.
Usage reports are generated locally and sent as weekly email summaries. The report includes lessons completed, time spent, subjects explored, achievements earned, and trends versus the previous week. We generate these reports as clean HTML emails using a template engine. Critically, the reports focus on positive framing: “Maya completed 12 lessons this week, 3 more than last week!” rather than “Maya only completed 12 out of a possible 30 lessons.”
Content filtering lets parents restrict by subject (“No science lessons until homework is done”), difficulty level (“Only levels 1-3 for now”), and age-appropriateness (which defaults to the child’s registered age but can be adjusted). The filter is implemented as a middleware that intercepts lesson queries before they reach the UI:
// src/parental/contentFilter.ts
interface ContentFilterRules {
allowedSubjects: Subject[];
maxDifficultyLevel: number;
ageTierOverride?: 'preschool' | 'elementary' | 'preteen';
blockedLessonIds: string[];
}
class ContentFilter {
private rules: ContentFilterRules;
constructor(rules: ContentFilterRules) {
this.rules = rules;
}
filterLessons(lessons: Lesson[]): Lesson[] {
return lessons.filter((lesson) => {
// Check subject
if (!this.rules.allowedSubjects.includes(lesson.subject)) {
return false;
}
// Check difficulty
if (lesson.difficultyLevel > this.rules.maxDifficultyLevel) {
return false;
}
// Check age tier override
if (this.rules.ageTierOverride && lesson.ageTier !== this.rules.ageTierOverride) {
return false;
}
// Check blocklist
if (this.rules.blockedLessonIds.includes(lesson.id)) {
return false;
}
return true;
});
}
canAccessLesson(lesson: Lesson): { allowed: boolean; reason?: string } {
if (!this.rules.allowedSubjects.includes(lesson.subject)) {
return { allowed: false, reason: 'This subject is currently paused by your parent.' };
}
if (lesson.difficultyLevel > this.rules.maxDifficultyLevel) {
return { allowed: false, reason: 'This lesson is a bit too advanced right now.' };
}
if (this.rules.blockedLessonIds.includes(lesson.id)) {
return { allowed: false, reason: 'This lesson is not available right now.' };
}
return { allowed: true };
}
}
Notice the child-friendly language in the rejection messages. We never say “Your parent blocked this.” We say “This is not available right now” or “This subject is currently paused.” The child should not feel like they are being punished when a content filter is active.
Offline Mode
Offline mode is not a feature — it is a survival requirement. When we analyzed our target users’ connectivity patterns during user research, we found that 40% of usage sessions happened with degraded or absent internet. School tablets on filtered networks. Tablets in car backseats on road trips. Hand-me-down iPads at grandparents’ houses in rural areas. If KidSpark does not work offline, it does not work for nearly half our users.
Content Pack System
Rather than caching individual lessons on demand, we built a content pack system. A content pack is a bundle of lessons, assets (images, audio files), and metadata for a specific subject and grade level. Parents or the app itself can pre-download packs when connected, and the child can then work through them entirely offline:
// lib/offline/content_pack_manager.dart
class ContentPackManager {
final KidsLearnApiClient _api;
final Isar _db;
final AssetCacheService _assetCache;
final ValueChanged<DownloadProgress>? onProgress;
ContentPackManager({
required KidsLearnApiClient api,
required Isar db,
required AssetCacheService assetCache,
this.onProgress,
}) : _api = api,
_db = db,
_assetCache = assetCache;
Future<List<ContentPackInfo>> getAvailablePacks() async {
try {
return await _api.getContentPacks();
} catch (e) {
// Return locally known packs if offline
return _getLocalPackInfo();
}
}
Future<void> downloadPack(String packId) async {
onProgress?.call(DownloadProgress(
packId: packId,
phase: DownloadPhase.fetchingMetadata,
progress: 0,
));
// Fetch the pack content from the Kids Learn API
final pack = await _api.getContentPack(packId);
onProgress?.call(DownloadProgress(
packId: packId,
phase: DownloadPhase.storingLessons,
progress: 0.1,
));
// Store lessons in local database
await _db.writeTxn(() async {
for (final lesson in pack.lessons) {
final localLesson = LocalLesson()
..lessonId = lesson.id
..packId = packId
..dataJson = jsonEncode(lesson.toJson())
..downloadedAt = DateTime.now();
await _db.localLessons.put(localLesson);
}
});
// Download all assets (images, audio)
final totalAssets = pack.assets.length;
for (int i = 0; i < totalAssets; i++) {
await _assetCache.downloadAsset(pack.assets[i]);
onProgress?.call(DownloadProgress(
packId: packId,
phase: DownloadPhase.downloadingAssets,
progress: 0.1 + (0.85 * (i + 1) / totalAssets),
));
}
// Mark pack as downloaded
await _db.writeTxn(() async {
final packRecord = DownloadedPack()
..packId = packId
..title = pack.title
..subject = pack.subject
..lessonCount = pack.lessons.length
..sizeBytes = await _calculatePackSize(packId)
..downloadedAt = DateTime.now();
await _db.downloadedPacks.put(packRecord);
});
onProgress?.call(DownloadProgress(
packId: packId,
phase: DownloadPhase.complete,
progress: 1.0,
));
}
Future<double> getStorageUsedMb() async {
final packs = await _db.downloadedPacks.where().findAll();
final totalBytes = packs.fold<int>(
0,
(sum, pack) => sum + pack.sizeBytes,
);
return totalBytes / (1024 * 1024);
}
Future<double> getAvailableStorageMb() async {
final dir = await getApplicationDocumentsDirectory();
// Platform-specific storage check
final stat = await dir.stat();
// Simplified — real implementation uses platform channels
return 500.0; // Placeholder, actual implementation checks device storage
}
Future<void> cleanupOldPacks({int keepCount = 5}) async {
final packs = await _db.downloadedPacks
.where()
.sortByDownloadedAt()
.findAll();
if (packs.length <= keepCount) return;
final toDelete = packs.sublist(0, packs.length - keepCount);
for (final pack in toDelete) {
await deletePack(pack.packId);
}
}
Future<void> deletePack(String packId) async {
// Delete lessons
await _db.writeTxn(() async {
await _db.localLessons
.filter()
.packIdEqualTo(packId)
.deleteAll();
await _db.downloadedPacks
.filter()
.packIdEqualTo(packId)
.deleteAll();
});
// Delete cached assets for this pack
await _assetCache.deleteAssetsForPack(packId);
}
Future<int> _calculatePackSize(String packId) async {
// Sum of all asset sizes for this pack
return await _assetCache.getPackSizeBytes(packId);
}
Future<List<ContentPackInfo>> _getLocalPackInfo() async {
final packs = await _db.downloadedPacks.where().findAll();
return packs.map((p) => ContentPackInfo(
id: p.packId,
title: p.title,
subject: p.subject,
lessonCount: p.lessonCount,
isDownloaded: true,
)).toList();
}
}
class DownloadProgress {
final String packId;
final DownloadPhase phase;
final double progress; // 0.0 to 1.0
const DownloadProgress({
required this.packId,
required this.phase,
required this.progress,
});
}
enum DownloadPhase {
fetchingMetadata,
storingLessons,
downloadingAssets,
complete,
error,
}
Storage management is surprisingly important. A child’s tablet might have 16 GB of total storage with 2 GB free. Each content pack runs 50-200 MB depending on audio and image assets. The storage screen shows parents how much space KidSpark is using, which packs are downloaded, and which ones can be safely removed. We auto-cleanup the oldest packs when storage drops below a threshold, but only after notifying the parent.
What Does Not Work Offline
Being honest about offline limitations is as important as building offline features. Here is what requires connectivity:
AI-generated adaptive content does not work offline. The Kids Learn backend uses AI to generate personalized questions based on a child’s learning history. This requires a server call. Offline, the child gets pre-authored content from downloaded packs, which is still adaptive (the client-side difficulty engine works offline), but not AI-personalized.
New content discovery is unavailable offline. The child cannot browse and start new content packs without connectivity. They can only access previously downloaded content. This is why we prompt parents to download packs when connected — a toast message saying “Download some lessons for the road?” appears when the app detects a strong Wi-Fi connection.
Leaderboards and social features require connectivity. The preteen tier’s optional classroom leaderboard only updates when online. We show a “Last updated 2 hours ago” timestamp so kids know the data might be stale.
Parent usage reports are delayed. Progress is tracked locally but the weekly summary email is generated server-side. If the child is offline all week, the report arrives the next time the app syncs. We explain this in the parent settings to avoid confusion.
The general principle: everything the child interacts with works offline, everything the parent monitors syncs when it can. A child should never be blocked from learning because of a network issue.
The Bottom Line
I spent seven weeks building what Toan originally estimated at three weeks. Every single week was justified.
The lesson engine looks simple on paper — show questions, record answers — but represents months of engineering when you account for six question types, three age tiers, adaptive difficulty, offline support, and celebration animations. The quiz system looks like “just” a state machine, but the nuances of timer management, hint economics, and streak tracking add up to thousands of lines of carefully reasoned code. Progress tracking looks like “just” a database, but the offline-first architecture with sync queues, conflict resolution, and storage management is a system unto itself. Gamification looks like “just” badges and confetti, but the ethical boundaries around what we excluded are as important as what we included.
The key insight I would give to any team building a kids’ learning app: every feature must pass three tests simultaneously. Does it work offline? Is it age-appropriate across your entire range? Does it feel delightful to a child? If any of those answers is “no,” the feature is not done.
Linh said something at the end of the sprint that stuck with me. “The hardest part wasn’t any single feature. It was making all the features work together seamlessly — adaptive difficulty feeding into progress tracking, progress tracking feeding into gamification, gamification feeding back into motivation to do more lessons. The whole system has to feel like one thing, not six things bolted together.”
She was right. Ship the core loop first — one lesson type, one quiz mode, basic progress. Get that flowing perfectly. Then layer on complexity. The MVP is not “all features at half quality.” The MVP is “half the features at full quality.” Every feature you ship should be complete, polished, and delightful. Then add the next one.
In Part 6, we will tackle the compliance minefield: COPPA, GDPR-K, and the app store rules that specifically target children’s apps. If you think the engineering in this post was complex, wait until you see what happens when lawyers get involved.
This is Part 5 of a 10-part series: Building KidSpark — From Idea to App Store.
Series outline:
- Why Mobile, Why Now — Market opportunity, team intro, and unique challenges of kids apps (Part 1)
- Product Design & Features — Feature prioritization, user journeys, and MVP scope (Part 2)
- UX for Children — Age-appropriate design, accessibility, and testing with kids (Part 3)
- Tech Stack Selection — Flutter vs React Native vs Native, architecture decisions (Part 4)
- Core Features — Lessons, quizzes, gamification, offline mode, parental controls (this post)
- Child Safety & Compliance — COPPA, GDPR-K, and app store rules for kids (Part 6)
- Testing Strategy — Unit, widget, integration, accessibility, and device testing (Part 7)
- CI/CD & App Store — Build pipelines, code signing, submission, and ASO (Part 8)
- Production — Analytics, crash reporting, monitoring, and iteration (Part 9)
- Monetization & Growth — Ethical monetization, growth strategies, and lessons learned (Part 10)