This commit is contained in:
Rene Kievits
2025-10-27 18:52:16 +01:00
commit ba82e662f6
140 changed files with 6443 additions and 0 deletions

39
lib/main.dart Normal file
View File

@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
import 'src/services/deck_repository.dart';
import 'src/screens/start_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS)) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
runApp(
Provider<DeckRepository>(
create: (_) => DeckRepository(),
child: const WkApp(),
),
);
}
class WkApp extends StatelessWidget {
const WkApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'WaniKani SRS',
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(useMaterial3: true),
home: const StartScreen(),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class WkClient {
final String apiKey;
final Map<String, String> headers;
final String base = 'https://api.wanikani.com/v2';
WkClient(this.apiKey) : headers = {'Authorization': 'Bearer $apiKey', 'Wanikani-Revision': '20170710', 'Accept': 'application/json'};
Future<List<Map<String, dynamic>>> fetchAllAssignments({List<String>? subjectTypes}) async {
final out = <Map<String, dynamic>>[];
String url = '$base/assignments?page=1';
if (subjectTypes != null && subjectTypes.isNotEmpty) {
final types = subjectTypes.join(',');
url = '$base/assignments?subject_types=$types&page=1';
}
while (url.isNotEmpty) {
final resp = await http.get(Uri.parse(url), headers: headers);
if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}');
final j = json.decode(resp.body) as Map<String, dynamic>;
out.addAll((j['data'] as List).cast<Map<String, dynamic>>());
final pages = j['pages'] as Map<String, dynamic>?;
if (pages != null && pages['next_url'] != null) {
url = pages['next_url'] as String;
} else {
break;
}
}
return out;
}
Future<List<Map<String, dynamic>>> fetchSubjectsByIds(List<int> ids) async {
final out = <Map<String, dynamic>>[];
const batch = 100;
for (var i = 0; i < ids.length; i += batch) {
final chunk = ids.sublist(i, i + batch > ids.length ? ids.length : i + batch);
String url = '$base/subjects?ids=${chunk.join(',')}&page=1';
while (true) {
final resp = await http.get(Uri.parse(url), headers: headers);
if (resp.statusCode != 200) throw Exception('API ${resp.statusCode}');
final j = json.decode(resp.body) as Map<String, dynamic>;
out.addAll((j['data'] as List).cast<Map<String, dynamic>>());
final pages = j['pages'] as Map<String, dynamic>?;
if (pages != null && pages['next_url'] != null) {
url = pages['next_url'] as String;
} else {
break;
}
}
}
return out;
}
}

22
lib/src/app.dart Normal file
View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/home_screen.dart';
import 'services/deck_repository.dart';
class WkApp extends StatelessWidget {
const WkApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider<DeckRepository>(create: (_) => DeckRepository()),
],
child: MaterialApp(
title: 'WaniKani SRS',
theme: ThemeData(useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple)),
home: const HomeScreen(),
),
);
}
}

View File

@@ -0,0 +1,62 @@
class KanjiItem {
final int id;
final String characters;
final List<String> meanings;
final List<String> onyomi;
final List<String> kunyomi;
KanjiItem({
required this.id,
required this.characters,
required this.meanings,
required this.onyomi,
required this.kunyomi,
});
factory KanjiItem.fromSubject(Map<String, dynamic> subj) {
final int id = subj['id'] as int;
final data = subj['data'] as Map<String, dynamic>;
final String characters = (data['characters'] ?? '') as String;
final List<String> meanings = <String>[];
final List<String> onyomi = <String>[];
final List<String> kunyomi = <String>[];
if (data['meanings'] != null) {
for (final m in data['meanings'] as List) {
meanings.add((m['meaning'] as String).toLowerCase());
}
}
if (data['readings'] != null) {
for (final r in data['readings'] as List) {
final typ = r['type'] as String? ?? '';
final reading = r['reading'] as String? ?? '';
if (typ == 'onyomi') {
onyomi.add(_katakanaToHiragana(reading));
} else if (typ == 'kunyomi') {
kunyomi.add(reading);
}
}
}
return KanjiItem(
id: id,
characters: characters,
meanings: meanings,
onyomi: onyomi,
kunyomi: kunyomi,
);
}
}
String _katakanaToHiragana(String input) {
final buf = StringBuffer();
for (final r in input.runes) {
if (r >= 0x30A1 && r <= 0x30FA) {
buf.writeCharCode(r - 0x60);
} else {
buf.writeCharCode(r);
}
}
return buf.toString();
}

View File

@@ -0,0 +1,310 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/kanji_item.dart';
import '../services/deck_repository.dart';
import '../services/distractor_generator.dart';
import '../widgets/kanji_card.dart';
import '../widgets/options_grid.dart';
import 'settings_screen.dart';
enum QuizMode { kanjiToEnglish, englishToKanji, reading }
class _ReadingInfo {
final List<String> correctReadings;
final String hint;
_ReadingInfo(this.correctReadings, this.hint);
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
List<KanjiItem> _deck = [];
bool _loading = false;
String _status = 'Loading deck...';
final DistractorGenerator _dg = DistractorGenerator();
final Random _random = Random();
QuizMode _mode = QuizMode.kanjiToEnglish;
KanjiItem? _current;
List<String> _options = [];
List<String> _correctAnswers = [];
String _readingHint = '';
int _score = 0;
int _asked = 0;
@override
void initState() {
super.initState();
_loadDeck();
}
Future<void> _loadDeck() async {
setState(() {
_loading = true;
_status = 'Loading deck...';
});
try {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
final apiKey = repo.apiKey;
if (apiKey == null || apiKey.isEmpty) {
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
return;
}
setState(() {
_status = 'Fetching deck...';
});
final items = await repo.fetchAndCacheFromWk(apiKey);
setState(() {
_deck = items;
_status = 'Loaded ${items.length} kanji';
_loading = false;
});
_nextQuestion();
} catch (e) {
setState(() {
_status = 'Error: $e';
_loading = false;
});
}
}
String _toTitleCase(String s) {
if (s.isEmpty) return s;
return s
.split(' ')
.map((w) => w.isEmpty ? w : w[0].toUpperCase() + w.substring(1))
.join(' ');
}
_ReadingInfo _pickReading(KanjiItem item) {
final choices = <String>[];
if (item.onyomi.isNotEmpty) choices.add('onyomi');
if (item.kunyomi.isNotEmpty) choices.add('kunyomi');
if (choices.isEmpty) return _ReadingInfo([], '');
final pickedType = choices[_random.nextInt(choices.length)];
final readingsList = pickedType == 'onyomi' ? item.onyomi : item.kunyomi;
final hint = 'Select the ${pickedType == 'onyomi' ? "on\'yomi" : "kunyomi"}';
return _ReadingInfo(readingsList, hint);
}
void _nextQuestion() {
if (_deck.isEmpty) return;
_current = (_deck..shuffle()).first;
_correctAnswers = [];
_options = [];
_readingHint = '';
switch (_mode) {
case QuizMode.kanjiToEnglish:
_correctAnswers = [_current!.meanings.first];
_options = [_correctAnswers.first, ..._dg.generateMeanings(_current!, _deck, 3)]
.map(_toTitleCase)
.toList()
..shuffle();
break;
case QuizMode.englishToKanji:
_correctAnswers = [_current!.characters];
_options = [_correctAnswers.first, ..._dg.generateKanji(_current!, _deck, 3)]
..shuffle();
break;
case QuizMode.reading:
final info = _pickReading(_current!);
_correctAnswers = info.correctReadings;
_readingHint = info.hint;
final readingsSource = _readingHint.contains("on'yomi")
? _deck.expand((k) => k.onyomi)
: _deck.expand((k) => k.kunyomi);
final distractors =
readingsSource.where((r) => !_correctAnswers.contains(r)).toSet().toList()
..shuffle();
_options = ([_correctAnswers[_random.nextInt(_correctAnswers.length)], ...distractors.take(3)])
..shuffle();
break;
}
setState(() {});
}
void _answer(String option) {
final isCorrect = _correctAnswers
.map((a) => a.toLowerCase().trim())
.contains(option.toLowerCase().trim());
setState(() {
_asked += 1;
if (isCorrect) _score += 1;
});
final correctDisplay = (_mode == QuizMode.kanjiToEnglish)
? _toTitleCase(_correctAnswers.first)
: (_mode == QuizMode.reading ? _correctAnswers.join(', ') : _correctAnswers.first);
final snack = SnackBar(
content: Text(
isCorrect ? 'Correct!' : 'Wrong — correct: $correctDisplay',
style: TextStyle(
color: isCorrect ? Colors.greenAccent : Colors.redAccent,
fontWeight: FontWeight.bold,
),
),
backgroundColor: const Color(0xFF222222),
duration: const Duration(milliseconds: 900),
);
ScaffoldMessenger.of(context).showSnackBar(snack);
Future.delayed(const Duration(milliseconds: 900), _nextQuestion);
}
@override
Widget build(BuildContext context) {
String prompt = '';
String subtitle = '';
switch (_mode) {
case QuizMode.kanjiToEnglish:
prompt = _current?.characters ?? '';
break;
case QuizMode.englishToKanji:
prompt = _current != null ? _toTitleCase(_current!.meanings.first) : '';
break;
case QuizMode.reading:
prompt = _current?.characters ?? '';
subtitle = _readingHint;
break;
}
return Scaffold(
backgroundColor: const Color(0xFF121212),
appBar: AppBar(
title: const Text('WaniKani Kanji SRS'),
backgroundColor: const Color(0xFF1F1F1F),
foregroundColor: Colors.white,
elevation: 2,
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
)
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
children: [
Expanded(
child: Text(
_status,
style: const TextStyle(color: Colors.white),
),
),
if (_loading)
const CircularProgressIndicator(color: Colors.blueAccent),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.center,
children: [
_buildChoiceChip('Kanji→English', QuizMode.kanjiToEnglish),
_buildChoiceChip('English→Kanji', QuizMode.englishToKanji),
_buildChoiceChip('Reading', QuizMode.reading),
],
),
const SizedBox(height: 18),
Expanded(
flex: 3,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 0,
maxWidth: 500,
minHeight: 150,
),
child: KanjiCard(
characters: prompt,
subtitle: subtitle,
backgroundColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
),
),
),
const SizedBox(height: 12),
SafeArea(
top: false,
child: Column(
children: [
OptionsGrid(
options: _options,
onSelected: _answer,
buttonColor: const Color(0xFF1E1E1E),
textColor: Colors.white,
),
const SizedBox(height: 8),
Text(
'Score: $_score / $_asked',
style: const TextStyle(color: Colors.white),
),
],
),
),
],
),
),
);
}
ChoiceChip _buildChoiceChip(String label, QuizMode mode) {
final selected = _mode == mode;
return ChoiceChip(
label: Text(
label,
style: TextStyle(color: selected ? Colors.white : Colors.grey[400]),
),
selected: selected,
onSelected: (v) {
setState(() => _mode = mode);
_nextQuestion();
},
selectedColor: Colors.blueAccent,
backgroundColor: const Color(0xFF1E1E1E),
);
}
}

View File

@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/deck_repository.dart';
import 'home_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final TextEditingController _apiKeyController = TextEditingController();
@override
void dispose() {
_apiKeyController.dispose();
super.dispose();
}
Future<void> _saveApiKey() async {
final apiKey = _apiKeyController.text.trim();
if (apiKey.isEmpty) return;
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.setApiKey(apiKey);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('API key saved!')),
);
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
}
}
@override
void initState() {
super.initState();
final repo = Provider.of<DeckRepository>(context, listen: false);
repo.loadApiKey().then((key) {
if (key != null) {
_apiKeyController.text = key;
}
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF121212),
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: const Color(0xFF1F1F1F),
foregroundColor: Colors.white,
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _apiKeyController,
obscureText: true,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
labelText: 'WaniKani API Key',
labelStyle: const TextStyle(color: Colors.grey),
filled: true,
fillColor: const Color(0xFF1E1E1E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: const BorderSide(color: Colors.grey),
),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _saveApiKey,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
),
child: const Text('Save & Start Quiz'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/deck_repository.dart';
import 'settings_screen.dart';
import 'home_screen.dart';
class StartScreen extends StatefulWidget {
const StartScreen({super.key});
@override
State<StartScreen> createState() => _StartScreenState();
}
class _StartScreenState extends State<StartScreen> {
bool _loading = true;
bool _hasApiKey = false;
@override
void initState() {
super.initState();
_checkApiKey();
}
Future<void> _checkApiKey() async {
final repo = Provider.of<DeckRepository>(context, listen: false);
await repo.loadApiKey();
setState(() {
_hasApiKey = repo.apiKey != null && repo.apiKey!.isNotEmpty;
_loading = false;
});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
backgroundColor: Color(0xFF121212),
body: Center(
child: CircularProgressIndicator(color: Colors.blueAccent),
),
);
}
return Scaffold(
backgroundColor: const Color(0xFF121212),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Welcome to WaniKani Kanji SRS!',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontSize: 28, color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
_hasApiKey
? 'Your API key is set. You can start the quiz!'
: 'Before you start, please set up your WaniKani API key in the settings.',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Colors.grey[300]),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {
if (_hasApiKey) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const HomeScreen()),
);
} else {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: Text(
_hasApiKey ? 'Start Quiz' : 'Go to Settings',
style: const TextStyle(fontSize: 18),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,140 @@
import 'dart:async';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../models/kanji_item.dart';
import '../api/wk_client.dart';
class DeckRepository {
Database? _db;
String? _apiKey;
Future<void> setApiKey(String apiKey) async {
_apiKey = apiKey;
await saveApiKey(apiKey);
}
String? get apiKey => _apiKey;
Future<Database> _openDb() async {
if (_db != null) return _db!;
final dir = await getApplicationDocumentsDirectory();
final path = join(dir.path, 'wanikani_srs.db');
_db = await openDatabase(
path,
version: 2,
onCreate: (db, version) async {
await db.execute(
'''CREATE TABLE kanji (id INTEGER PRIMARY KEY, characters TEXT, meanings TEXT, onyomi TEXT, kunyomi TEXT)''');
await db.execute(
'''CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)''');
},
onUpgrade: (db, oldVersion, newVersion) async {
await db.execute(
'''CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)''');
},
);
return _db!;
}
Future<void> saveApiKey(String apiKey) async {
final db = await _openDb();
await db.insert(
'settings',
{'key': 'apiKey', 'value': apiKey},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<String?> loadApiKey() async {
final db = await _openDb();
final rows =
await db.query('settings', where: 'key = ?', whereArgs: ['apiKey']);
if (rows.isNotEmpty) {
_apiKey = rows.first['value'] as String;
return _apiKey;
}
return null;
}
Future<void> saveKanji(List<KanjiItem> items) async {
final db = await _openDb();
final batch = db.batch();
for (final it in items) {
batch.insert(
'kanji',
{
'id': it.id,
'characters': it.characters,
'meanings': it.meanings.join('|'),
'onyomi': it.onyomi.join('|'),
'kunyomi': it.kunyomi.join('|'),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}
Future<List<KanjiItem>> loadKanji() async {
final db = await _openDb();
final rows = await db.query('kanji');
return rows
.map((r) => KanjiItem(
id: r['id'] as int,
characters: r['characters'] as String,
meanings: (r['meanings'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
onyomi: (r['onyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
kunyomi: (r['kunyomi'] as String)
.split('|')
.where((s) => s.isNotEmpty)
.toList(),
))
.toList();
}
Future<List<KanjiItem>> fetchAndCacheFromWk([String? apiKey]) async {
final key = apiKey ?? _apiKey;
if (key == null) throw Exception('API key not set');
final client = WkClient(key);
final assignments =
await client.fetchAllAssignments(subjectTypes: ['kanji']);
final unlocked = <int>{};
for (final a in assignments) {
final data = a['data'] as Map<String, dynamic>;
final sidRaw = data['subject_id'];
if (sidRaw == null) continue;
final sid = sidRaw is int ? sidRaw : int.tryParse(sidRaw.toString());
if (sid == null) continue;
final started = data['started_at'];
final srs = data['srs_stage'];
final isUnlocked = (started != null) || (srs != null && (srs as int) > 0);
if (isUnlocked) unlocked.add(sid);
}
if (unlocked.isEmpty) return [];
final subjects = await client.fetchSubjectsByIds(unlocked.toList());
final items = subjects
.where((s) =>
s['object'] == 'kanji' ||
(s['data'] != null &&
(s['data'] as Map)['object_type'] == 'kanji'))
.map((s) => KanjiItem.fromSubject(s))
.where((k) => k.characters.isNotEmpty && k.meanings.isNotEmpty)
.toList();
await saveKanji(items);
return items;
}
}

View File

@@ -0,0 +1,69 @@
import '../models/kanji_item.dart';
import 'dart:math';
class DistractorGenerator {
final Random _rnd = Random();
List<String> generateMeanings(KanjiItem correct, List<KanjiItem> pool, int needed) {
final correctMeaning = correct.meanings.first;
final tokens = correctMeaning.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
final candidates = <String>[];
for (final k in pool) {
if (k.id == correct.id) continue;
for (final m in k.meanings) {
final mTokens = m.split(RegExp(r'\s+')).map((s) => s.trim()).where((s) => s.isNotEmpty).toSet();
if (mTokens.intersection(tokens).isNotEmpty) {
candidates.add(m);
}
}
}
if (candidates.length < needed) {
for (final k in pool) {
if (k.id == correct.id) continue;
for (final m in k.meanings) {
if (!candidates.contains(m)) candidates.add(m);
}
}
}
candidates.shuffle(_rnd);
final out = <String>[];
for (final c in candidates) {
if (out.length >= needed) break;
if (c.toLowerCase() == correctMeaning.toLowerCase()) continue;
out.add(_toTitleCase(c));
}
while (out.length < needed) out.add('(no more)');
return out;
}
List<String> generateKanji(KanjiItem correct, List<KanjiItem> pool, int needed) {
final others = pool.map((k) => k.characters).where((c) => c != correct.characters).toList();
others.shuffle(_rnd);
final out = <String>[];
for (final o in others) {
if (out.length >= needed) break;
out.add(o);
}
while (out.length < needed) out.add('');
return out;
}
List<String> generateReadings(String correct, List<KanjiItem> pool, int needed) {
final poolReadings = <String>[];
for (final k in pool) {
poolReadings.addAll(k.onyomi);
poolReadings.addAll(k.kunyomi);
}
poolReadings.removeWhere((r) => r == correct || r.isEmpty);
poolReadings.shuffle(_rnd);
final out = <String>[];
for (final r in poolReadings) {
if (out.length >= needed) break;
out.add(r);
}
while (out.length < needed) out.add('');
return out;
}
}
String _toTitleCase(String s) => s.split(' ').map((w) => w.isEmpty ? w : (w[0].toUpperCase() + w.substring(1))).join(' ');

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
class KanjiCard extends StatelessWidget {
final String characters;
final String subtitle;
final Color? backgroundColor;
final Color? textColor;
const KanjiCard({
super.key,
required this.characters,
this.subtitle = '',
this.backgroundColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = backgroundColor ?? theme.cardTheme.color ?? theme.colorScheme.surface;
final fgColor = textColor ?? theme.textTheme.bodyMedium?.color ?? theme.colorScheme.onSurface;
return Card(
elevation: theme.cardTheme.elevation ?? 12,
color: bgColor,
shape: theme.cardTheme.shape ?? RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: SizedBox(
width: 360,
height: 240,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
characters,
style: theme.textTheme.headlineMedium?.copyWith(
fontSize: 56,
color: fgColor,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: fgColor.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class OptionsGrid extends StatelessWidget {
final List<String> options;
final void Function(String) onSelected;
final Color? buttonColor;
final Color? textColor;
const OptionsGrid({
super.key,
required this.options,
required this.onSelected,
this.buttonColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
if (options.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
final bg = buttonColor ?? theme.colorScheme.primary;
final fg = textColor ?? theme.colorScheme.onPrimary;
return Wrap(
spacing: 10,
runSpacing: 10,
alignment: WrapAlignment.center,
children: options.map((o) {
return SizedBox(
width: 160,
child: ElevatedButton(
onPressed: () => onSelected(o),
style: ElevatedButton.styleFrom(
backgroundColor: bg,
foregroundColor: fg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
),
child: Text(
o,
style: TextStyle(fontSize: 20, color: fg),
textAlign: TextAlign.center,
),
),
);
}).toList(),
);
}
}