feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import '../core/app_theme.dart';
class AppPage extends StatelessWidget {
const AppPage({
super.key,
required this.child,
this.appBar,
this.bottomNavigationBar,
});
final Widget child;
final PreferredSizeWidget? appBar;
final Widget? bottomNavigationBar;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
body: SafeArea(
top: appBar == null,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: child,
),
),
);
}
}
class SectionCard extends StatelessWidget {
const SectionCard({
super.key,
required this.child,
this.tint,
this.onTap,
this.padding = const EdgeInsets.all(14),
});
final Widget child;
final Color? tint;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Material(
color: tint ?? AppColors.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: AppColors.line),
borderRadius: BorderRadius.circular(16),
),
child: content,
),
),
);
}
}
class PrimaryButton extends StatelessWidget {
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 50,
child: FilledButton.icon(
onPressed: onPressed,
icon: icon == null ? const SizedBox.shrink() : Icon(icon),
label: Text(label),
style: FilledButton.styleFrom(
backgroundColor: AppColors.green,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
);
}
}
class SecondaryButton extends StatelessWidget {
const SecondaryButton({
super.key,
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.ink,
side: const BorderSide(color: AppColors.line),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: Text(label),
),
);
}
}
class Eyebrow extends StatelessWidget {
const Eyebrow(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 13,
),
);
}
class SpacedColumn extends StatelessWidget {
const SpacedColumn({super.key, required this.children, this.spacing = 12});
final List<Widget> children;
final double spacing;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
if (index < children.length - 1) SizedBox(height: spacing),
],
],
);
}
@@ -0,0 +1,342 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../core/app_state.dart';
import '../core/app_theme.dart';
import '../core/ai_service.dart';
import '../core/models.dart';
import '../core/seed_courses.dart';
import '../core/voice_service.dart';
import 'app_widgets.dart';
List<VocabularyItem> get courseLexiconEntries {
final entries = <VocabularyItem>[
...a0SeedLessons.expand((lesson) => lesson.vocabulary),
...a0SegmentVocabulary.values.expand((items) => items),
];
final seen = <String>{};
return entries.where((item) => seen.add(item.word.toLowerCase())).toList()
..sort((a, b) => b.word.length.compareTo(a.word.length));
}
/// Finds a course item by exact query, then by the longest known phrase in it.
VocabularyItem? findCourseLexicon(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
return pattern.hasMatch(normalized);
}, orElse: () => null);
}
/// Inline course text that lets a learner tap a known word or phrase without
/// leaving the current task. Longest phrases are matched before their words.
class LexiconText extends StatefulWidget {
const LexiconText(
this.text, {
super.key,
required this.state,
this.style,
this.textAlign,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
@override
State<LexiconText> createState() => _LexiconTextState();
}
class _LexiconTextState extends State<LexiconText> {
final List<TapGestureRecognizer> _recognizers = [];
String selectedText = '';
@override
void dispose() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
final entries = courseLexiconEntries;
if (entries.isEmpty) return Text(widget.text, style: widget.style);
final expression = RegExp(
r'(?<![a-zA-Z0-9])(?:' +
entries
.map(
(item) => RegExp.escape(item.word).replaceAll('', "[']"),
)
.join('|') +
r')(?![a-zA-Z0-9])',
caseSensitive: false,
);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in expression.allMatches(widget.text)) {
if (match.start > cursor) {
spans.add(TextSpan(text: widget.text.substring(cursor, match.start)));
}
final matched = widget.text.substring(match.start, match.end);
final entry = findCourseLexicon(matched);
if (entry == null) {
spans.add(TextSpan(text: matched));
} else {
final recognizer = TapGestureRecognizer()
..onTap = () => showLexiconLookup(
context,
state: widget.state,
initialText: entry.word,
);
_recognizers.add(recognizer);
spans.add(
TextSpan(
text: matched,
recognizer: recognizer,
style: const TextStyle(
color: AppColors.green,
decoration: TextDecoration.underline,
decorationColor: AppColors.green,
),
),
);
}
cursor = match.end;
}
if (cursor < widget.text.length) {
spans.add(TextSpan(text: widget.text.substring(cursor)));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
onSelectionChanged: (selection, _) {
final text = selection.isValid && !selection.isCollapsed
? widget.text.substring(selection.start, selection.end).trim()
: '';
if (text != selectedText && mounted) {
setState(() => selectedText = text);
}
},
),
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
),
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
],
);
}
}
Future<void> showLexiconLookup(
BuildContext context, {
required AppState state,
String initialText = '',
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
_LexiconLookupSheet(state: state, initialText: initialText),
);
class _LexiconLookupSheet extends StatefulWidget {
const _LexiconLookupSheet({required this.state, required this.initialText});
final AppState state;
final String initialText;
@override
State<_LexiconLookupSheet> createState() => _LexiconLookupSheetState();
}
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
late final TextEditingController controller;
VocabularyItem? entry;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
: null;
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
?.definition;
temporaryError = null;
});
Future<void> _requestTemporaryDefinition() async {
final text = controller.text.trim();
if (text.isEmpty || widget.state.aiProvider.name == 'mock') {
setState(() => temporaryError = '未配置 AI 服务时,只能查询本地已审核课程词典。');
return;
}
setState(() {
requestingTemporaryDefinition = true;
temporaryError = null;
});
final definition = await AiService.instance.temporaryDefinition(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingTemporaryDefinition = false;
temporaryDefinition = definition;
temporaryError = definition == null ? '暂时无法查询;结果不会被编造。' : null;
});
if (definition != null) {
widget.state.cacheTemporaryDefinition(
query: text,
definition: definition,
);
}
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SpacedColumn(
children: [
const Eyebrow('课程词典 · 短语优先'),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入或粘贴英文词、短语、句子',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _lookup,
),
),
),
if (entry == null) ...[
const SectionCard(
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
),
OutlinedButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text('待审核临时释义\n$temporaryDefinition'),
Text(
'仅保存在本机,不会加入复习或影响掌握度。',
style: Theme.of(context).textTheme.bodySmall,
),
TextButton.icon(
onPressed: () {
widget.state.removeTemporaryDefinition(controller.text);
setState(() => temporaryDefinition = null);
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('删除此临时释义'),
),
],
),
),
] else ...[
Text(
entry!.word,
style: Theme.of(context).textTheme.headlineMedium,
),
if (entry!.ipa != null)
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
SectionCard(
tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(entry!.word),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(entry!.word, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
],
),
PrimaryButton(
label: '加入复习',
onPressed: () {
widget.state.addSavedWord(entry!);
Navigator.pop(context);
},
),
],
],
),
),
);
}