feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
class WelcomePage extends StatefulWidget {
|
||||
const WelcomePage({super.key, required this.state, required this.onContinue});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<WelcomePage> createState() => _WelcomePageState();
|
||||
}
|
||||
|
||||
class _WelcomePageState extends State<WelcomePage> {
|
||||
late LearningGoal selectedGoal;
|
||||
late int selectedMinutes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedGoal = widget.state.goal;
|
||||
selectedMinutes = widget.state.dailyMinutes;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
spacing: 20,
|
||||
children: [
|
||||
const Eyebrow('欢迎'),
|
||||
Text(
|
||||
'每天 20 分钟,\n说出能用的英语。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
|
||||
_ChoiceGroup<LearningGoal>(
|
||||
title: '你的主要目标',
|
||||
value: selectedGoal,
|
||||
options: const {
|
||||
LearningGoal.dailyLife: '日常生活',
|
||||
LearningGoal.travel: '旅行',
|
||||
LearningGoal.workStarter: '工作起步',
|
||||
},
|
||||
onChanged: (value) => setState(() => selectedGoal = value),
|
||||
),
|
||||
_ChoiceGroup<int>(
|
||||
title: '每天学习多久?',
|
||||
value: selectedMinutes,
|
||||
options: const {10: '10 分钟', 20: '20 分钟', 30: '30 分钟'},
|
||||
onChanged: (value) => setState(() => selectedMinutes = value),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '继续',
|
||||
onPressed: () {
|
||||
widget.state
|
||||
..setGoal(selectedGoal)
|
||||
..setDailyMinutes(selectedMinutes);
|
||||
widget.onContinue();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlacementPage extends StatefulWidget {
|
||||
const PlacementPage({super.key, required this.state, required this.onStart});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
State<PlacementPage> createState() => _PlacementPageState();
|
||||
}
|
||||
|
||||
class _PlacementPageState extends State<PlacementPage> {
|
||||
late PlacementLevel selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selected = widget.state.placement;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const labels = {
|
||||
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
|
||||
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
|
||||
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
|
||||
};
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
spacing: 14,
|
||||
children: [
|
||||
const Eyebrow('第一步 · 约 3 分钟'),
|
||||
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('选择最接近的状态,之后随时能调整。'),
|
||||
for (final option in PlacementLevel.values)
|
||||
_PlacementChoice(
|
||||
option: option,
|
||||
labels: labels[option]!,
|
||||
isSelected: selected == option,
|
||||
onTap: () => setState(() => selected = option),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '开始 3 分钟定位',
|
||||
onPressed: () {
|
||||
widget.state.setPlacement(selected);
|
||||
widget.onStart();
|
||||
},
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: widget.onStart,
|
||||
child: const Text('直接从第一课开始'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlacementChoice extends StatelessWidget {
|
||||
const _PlacementChoice({
|
||||
required this.option,
|
||||
required this.labels,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PlacementLevel option;
|
||||
final (String, String) labels;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
tint: isSelected ? AppColors.softGreen : null,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
color: isSelected ? AppColors.green : AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
labels.$1,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(labels.$2, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChoiceGroup<T> extends StatelessWidget {
|
||||
const _ChoiceGroup({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final T value;
|
||||
final Map<T, String> options;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionCard(
|
||||
child: SpacedColumn(
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: options.entries
|
||||
.map(
|
||||
(entry) => ChoiceChip(
|
||||
label: Text(entry.value),
|
||||
selected: value == entry.key,
|
||||
selectedColor: AppColors.softGreen,
|
||||
onSelected: (_) => onChanged(entry.key),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user