adaptive_gamification 0.0.1
adaptive_gamification: ^0.0.1 copied to clipboard
Adaptive gamification engine powered by reinforcement learning for dynamic difficulty adjustment.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:adaptive_gamification/adaptive_gamification.dart';
void main() {
runApp(const AdaptiveGamificationExampleApp());
}
class AdaptiveGamificationExampleApp extends StatelessWidget {
const AdaptiveGamificationExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Adaptive Gamification Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
home: const DemoHomePage(),
);
}
}
class DemoHomePage extends StatefulWidget {
const DemoHomePage({super.key});
@override
State<DemoHomePage> createState() => _DemoHomePageState();
}
class _DemoHomePageState extends State<DemoHomePage> {
final AdaptiveEngine _engine = AdaptiveEngine();
bool _loading = true;
String _error = '';
// ---- Demo user/session state (frontend state) ----
int _difficultyIndex = 2; // 0..4 (veryEasy..veryHard) - you control this mapping in the app
int _streak = 0;
int _total = 0;
int _correct = 0;
double _responseTime = 4.0; // seconds (user adjusts)
AdaptiveDecision? _lastDecision;
@override
void initState() {
super.initState();
_initEngine();
}
Future<void> _initEngine() async {
try {
// IMPORTANT: This asset lives in the EXAMPLE app, not in the library package.
await _engine.initFromAsset(policyAssetPath: 'assets/adaptive_policy.json');
setState(() => _loading = false);
} catch (e) {
setState(() {
_loading = false;
_error = e.toString();
});
}
}
double get _accuracy {
// Laplace smoothing prevents extreme 0/1 early in the session.
// Example: first wrong -> (0+1)/(1+2)=0.33 instead of 0.0
return ((_correct + 1) / (_total + 2)).clamp(0.0, 1.0);
}
String _difficultyLabelFromIndex(int i) {
const labels = ['veryEasy', 'easy', 'medium', 'hard', 'veryHard'];
if (i < 0) return labels.first;
if (i >= labels.length) return labels.last;
return labels[i];
}
void _applyDecision(String nextDifficulty) {
// nextDifficulty from policy is string (veryEasy..veryHard)
// Apply a *bounded* transition to avoid unrealistic jumps in the demo UI.
const order = ['veryEasy', 'easy', 'medium', 'hard', 'veryHard'];
final target = order.indexOf(nextDifficulty);
if (target == -1) return;
final current = _difficultyIndex;
if (target == current) return;
// Move at most one step toward the target.
if (target > current) {
_difficultyIndex = (current + 1).clamp(0, order.length - 1);
} else {
_difficultyIndex = (current - 1).clamp(0, order.length - 1);
}
}
Future<void> _answer(bool isCorrect) async {
setState(() {
_total += 1;
if (isCorrect) {
_correct += 1;
_streak += 1;
} else {
_streak = 0;
}
});
final userState = UserState(
currentDifficultyIndex: _difficultyIndex,
accuracy: _accuracy,
responseTime: _responseTime,
correctStreak: _streak,
);
final decision = _engine.decide(userState);
setState(() {
_lastDecision = decision;
_applyDecision(decision.nextDifficulty);
});
}
void _reset() {
setState(() {
_difficultyIndex = 2;
_streak = 0;
_total = 0;
_correct = 0;
_responseTime = 4.0;
_lastDecision = null;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (_error.isNotEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Adaptive Gamification Example')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Failed to initialize engine:\n\n$_error',
style: const TextStyle(fontSize: 14),
),
),
);
}
final currentDifficulty = _difficultyLabelFromIndex(_difficultyIndex);
return Scaffold(
appBar: AppBar(
title: const Text('Adaptive Gamification Example'),
actions: [
IconButton(
onPressed: _reset,
icon: const Icon(Icons.refresh),
tooltip: 'Reset',
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_Card(
title: 'Current Session State',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowLine(label: 'Difficulty', value: currentDifficulty),
_RowLine(label: 'Accuracy', value: (_accuracy * 100).toStringAsFixed(1) + '%'),
_RowLine(label: 'Streak', value: '$_streak'),
_RowLine(label: 'Answered', value: '$_total (correct: $_correct)'),
],
),
),
const SizedBox(height: 12),
_Card(
title: 'Response Time (seconds)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_responseTime.toStringAsFixed(2)),
Slider(
value: _responseTime,
min: 1.0,
max: 20.0,
divisions: 38,
label: _responseTime.toStringAsFixed(2),
onChanged: (v) => setState(() => _responseTime = v),
),
],
),
),
const SizedBox(height: 12),
_Card(
title: 'Next Decision',
child: _lastDecision == null
? const Text('Answer a question to see a decision.')
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_RowLine(label: 'Next Difficulty', value: _lastDecision!.nextDifficulty),
_RowLine(label: 'Reason', value: _lastDecision!.reason),
],
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => _answer(true),
icon: const Icon(Icons.check_circle_outline),
label: const Text('Correct'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _answer(false),
icon: const Icon(Icons.cancel_outlined),
label: const Text('Wrong'),
),
),
],
),
const SizedBox(height: 12),
const Text(
'Tip: change response time and alternate correct/wrong answers to observe adaptation. '
'The demo limits difficulty changes to one level per answer to avoid abrupt jumps.',
),
],
),
);
}
}
class _Card extends StatelessWidget {
final String title;
final Widget child;
const _Card({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Card(
elevation: 0.5,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
child,
],
),
),
);
}
}
class _RowLine extends StatelessWidget {
final String label;
final String value;
const _RowLine({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
SizedBox(width: 120, child: Text(label)),
Expanded(child: Text(value, style: const TextStyle(fontWeight: FontWeight.w600))),
],
),
);
}
}