feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user