Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f934aa45 | ||
|
|
11929d6a03 | ||
|
|
2a2668829d | ||
|
|
9215931b13 | ||
|
|
82b1ecdf84 | ||
|
|
35b493e652 | ||
|
|
670260f8e2 | ||
|
|
4f22e974af | ||
|
|
6fcffc968f | ||
|
|
4341726162 |
@@ -94,3 +94,16 @@ app.*.map.json
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# ==============================================================================
|
||||
# 7. Python 服务端构建与运行产物
|
||||
# ==============================================================================
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
venv/
|
||||
.venv/
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<application
|
||||
android:label="kouyu_english"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="芽说英语"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
@@ -33,12 +34,13 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
<!-- Required to query activities that can process text, speech recognition, and TTS engines on Android 11+:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"provider": "compatible",
|
||||
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
|
||||
"model": "gemini-3.7-flash-high",
|
||||
"reasoningEffort": "low",
|
||||
"apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf",
|
||||
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import os
|
||||
import math
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
def create_super_sampled_icon(size=1024, supersample=4):
|
||||
ss_size = size * supersample
|
||||
img = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 1. Background Rounded Squircle with rich forest-emerald gradient
|
||||
# Gradient from top-left (#1F8A5B) to bottom-right (#0D452B)
|
||||
radius = int(ss_size * 0.22)
|
||||
|
||||
# Create mask for squircle
|
||||
mask = Image.new("L", (ss_size, ss_size), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
margin = int(ss_size * 0.04)
|
||||
mask_draw.rounded_rectangle(
|
||||
[margin, margin, ss_size - margin, ss_size - margin],
|
||||
radius=radius,
|
||||
fill=255
|
||||
)
|
||||
|
||||
# Base gradient image
|
||||
gradient = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
c_tl = (38, 155, 102) # #269B66
|
||||
c_br = (14, 66, 42) # #0E422A
|
||||
|
||||
for y in range(ss_size):
|
||||
for x in range(0, ss_size, 4):
|
||||
t = (x + y) / (ss_size * 2)
|
||||
r = int(c_tl[0] * (1 - t) + c_br[0] * t)
|
||||
g = int(c_tl[1] * (1 - t) + c_br[1] * t)
|
||||
b = int(c_tl[2] * (1 - t) + c_br[2] * t)
|
||||
# fill horizontal run
|
||||
for dx in range(4):
|
||||
if x + dx < ss_size:
|
||||
gradient.putpixel((x + dx, y), (r, g, b, 255))
|
||||
|
||||
# Composite gradient with squircle mask
|
||||
bg = Image.composite(gradient, Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0)), mask)
|
||||
|
||||
# Inner soft glow ring
|
||||
inner_glow = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
glow_draw = ImageDraw.Draw(inner_glow)
|
||||
glow_draw.rounded_rectangle(
|
||||
[margin + 6 * supersample, margin + 6 * supersample, ss_size - margin - 6 * supersample, ss_size - margin - 6 * supersample],
|
||||
radius=radius - 6 * supersample,
|
||||
outline=(255, 255, 255, 45),
|
||||
width=int(3 * supersample)
|
||||
)
|
||||
bg = Image.alpha_composite(bg, inner_glow)
|
||||
|
||||
# 2. Main Speech Bubble + Sprout Motif
|
||||
# Center coordinates
|
||||
cx, cy = ss_size // 2, ss_size // 2
|
||||
|
||||
# A subtle companion speech bubble in the background (Mia AI partner)
|
||||
comp_bubble = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
comp_draw = ImageDraw.Draw(comp_bubble)
|
||||
|
||||
comp_rect = [
|
||||
int(cx + 40 * supersample),
|
||||
int(cy - 280 * supersample),
|
||||
int(cx + 340 * supersample),
|
||||
int(cy - 20 * supersample)
|
||||
]
|
||||
comp_draw.rounded_rectangle(
|
||||
comp_rect,
|
||||
radius=int(60 * supersample),
|
||||
fill=(226, 243, 232, 180) # AppColors.softGreen with opacity
|
||||
)
|
||||
# Companion bubble tail
|
||||
comp_tail = [
|
||||
(int(cx + 300 * supersample), int(cy - 40 * supersample)),
|
||||
(int(cx + 360 * supersample), int(cy + 40 * supersample)),
|
||||
(int(cx + 250 * supersample), int(cy - 20 * supersample)),
|
||||
]
|
||||
comp_draw.polygon(comp_tail, fill=(226, 243, 232, 180))
|
||||
|
||||
# Sound wave dots in companion bubble
|
||||
dot_color = (23, 107, 70, 220)
|
||||
for i in range(3):
|
||||
dx_dot = int(cx + (140 + i * 55) * supersample)
|
||||
dy_dot = int(cy - 150 * supersample)
|
||||
r_dot = int((10 + (1 if i==1 else 0)*4) * supersample)
|
||||
comp_draw.ellipse([dx_dot - r_dot, dy_dot - r_dot, dx_dot + r_dot, dy_dot + r_dot], fill=dot_color)
|
||||
|
||||
# Composite companion bubble
|
||||
bg = Image.alpha_composite(bg, comp_bubble)
|
||||
|
||||
# 3. Primary Speech Bubble (Clean Crisp White with subtle drop shadow)
|
||||
primary_shadow = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
sh_draw = ImageDraw.Draw(primary_shadow)
|
||||
|
||||
b_left = int(cx - 320 * supersample)
|
||||
b_top = int(cy - 180 * supersample)
|
||||
b_right = int(cx + 180 * supersample)
|
||||
b_bottom = int(cy + 260 * supersample)
|
||||
b_radius = int(100 * supersample)
|
||||
|
||||
sh_draw.rounded_rectangle(
|
||||
[b_left, b_top + 16 * supersample, b_right, b_bottom + 16 * supersample],
|
||||
radius=b_radius,
|
||||
fill=(0, 0, 0, 70)
|
||||
)
|
||||
# Primary tail shadow
|
||||
p_tail_sh = [
|
||||
(int(b_left + 80 * supersample), int(b_bottom + 10 * supersample)),
|
||||
(int(b_left - 30 * supersample), int(b_bottom + 120 * supersample)),
|
||||
(int(b_left + 190 * supersample), int(b_bottom + 10 * supersample)),
|
||||
]
|
||||
sh_draw.polygon(p_tail_sh, fill=(0, 0, 0, 70))
|
||||
primary_shadow = primary_shadow.filter(ImageFilter.GaussianBlur(radius=int(16 * supersample)))
|
||||
bg = Image.alpha_composite(bg, primary_shadow)
|
||||
|
||||
# Draw actual Primary Bubble
|
||||
primary_bubble = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
pb_draw = ImageDraw.Draw(primary_bubble)
|
||||
pb_draw.rounded_rectangle(
|
||||
[b_left, b_top, b_right, b_bottom],
|
||||
radius=b_radius,
|
||||
fill=(255, 255, 255, 255)
|
||||
)
|
||||
p_tail = [
|
||||
(int(b_left + 80 * supersample), int(b_bottom - 10 * supersample)),
|
||||
(int(b_left - 30 * supersample), int(b_bottom + 95 * supersample)),
|
||||
(int(b_left + 190 * supersample), int(b_bottom - 10 * supersample)),
|
||||
]
|
||||
pb_draw.polygon(p_tail, fill=(255, 255, 255, 255))
|
||||
bg = Image.alpha_composite(bg, primary_bubble)
|
||||
|
||||
# 4. Sprout & Dynamic Voice Wave Graphic inside Primary Bubble
|
||||
# Center of primary bubble
|
||||
pcx = (b_left + b_right) // 2
|
||||
pcy = (b_top + b_bottom) // 2 - int(10 * supersample)
|
||||
|
||||
icon_art = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
|
||||
art_draw = ImageDraw.Draw(icon_art)
|
||||
|
||||
# Sprout Stem & Leaves (Vibrant Emerald & Spring Green)
|
||||
# Main Leaf (Right): curving upwards with life
|
||||
leaf_r_points = []
|
||||
steps = 40
|
||||
# Center anchor of stem: (pcx - 30, pcy + 110)
|
||||
stem_x, stem_y = pcx - int(20 * supersample), pcy + int(110 * supersample)
|
||||
|
||||
# Left leaf
|
||||
left_leaf = [
|
||||
(stem_x, stem_y - int(30 * supersample)),
|
||||
(stem_x - int(120 * supersample), stem_y - int(60 * supersample)),
|
||||
(stem_x - int(150 * supersample), stem_y - int(150 * supersample)),
|
||||
(stem_x - int(60 * supersample), stem_y - int(140 * supersample)),
|
||||
(stem_x, stem_y - int(70 * supersample))
|
||||
]
|
||||
# Draw smooth left leaf
|
||||
art_draw.polygon(left_leaf, fill=(43, 174, 107, 255))
|
||||
|
||||
# Right bigger primary leaf
|
||||
right_leaf = [
|
||||
(stem_x + int(10 * supersample), stem_y - int(40 * supersample)),
|
||||
(stem_x + int(70 * supersample), stem_y - int(60 * supersample)),
|
||||
(stem_x + int(160 * supersample), stem_y - int(170 * supersample)),
|
||||
(stem_x + int(130 * supersample), stem_y - int(220 * supersample)),
|
||||
(stem_x + int(40 * supersample), stem_y - int(190 * supersample)),
|
||||
(stem_x - int(10 * supersample), stem_y - int(100 * supersample))
|
||||
]
|
||||
art_draw.polygon(right_leaf, fill=(23, 107, 70, 255))
|
||||
|
||||
# Sprout Dewdrop / Energy Spark (Warm Sun Gold #F59E0B)
|
||||
spark_x = stem_x + int(145 * supersample)
|
||||
spark_y = stem_y - int(235 * supersample)
|
||||
spark_r = int(22 * supersample)
|
||||
art_draw.ellipse(
|
||||
[spark_x - spark_r, spark_y - spark_r, spark_x + spark_r, spark_y + spark_r],
|
||||
fill=(245, 158, 11, 255)
|
||||
)
|
||||
|
||||
# 3 Arched Voice Waves radiating from the sprout (representing speaking & pronunciation)
|
||||
wave_color = (23, 107, 70, 220)
|
||||
|
||||
# Wave 1 (inner)
|
||||
w1_box = [
|
||||
int(pcx - 180 * supersample),
|
||||
int(pcy - 160 * supersample),
|
||||
int(pcx - 20 * supersample),
|
||||
int(pcy + 0 * supersample)
|
||||
]
|
||||
art_draw.arc(w1_box, start=140, end=270, fill=wave_color, width=int(14 * supersample))
|
||||
|
||||
# Wave 2 (middle)
|
||||
w2_box = [
|
||||
int(pcx - 240 * supersample),
|
||||
int(pcy - 210 * supersample),
|
||||
int(pcx - 10 * supersample),
|
||||
int(pcy + 30 * supersample)
|
||||
]
|
||||
art_draw.arc(w2_box, start=145, end=265, fill=(43, 174, 107, 200), width=int(14 * supersample))
|
||||
|
||||
# Wave 3 (outer)
|
||||
w3_box = [
|
||||
int(pcx - 295 * supersample),
|
||||
int(pcy - 260 * supersample),
|
||||
int(pcx - 0 * supersample),
|
||||
int(pcy + 60 * supersample)
|
||||
]
|
||||
art_draw.arc(w3_box, start=150, end=260, fill=(245, 158, 11, 230), width=int(13 * supersample))
|
||||
|
||||
# Composite artwork
|
||||
bg = Image.alpha_composite(bg, icon_art)
|
||||
|
||||
# Downsample using high quality Lanczos filter for razor-sharp antialiasing
|
||||
final_icon = bg.resize((size, size), Image.Resampling.LANCZOS)
|
||||
return final_icon
|
||||
|
||||
def create_brand_banner(icon_img):
|
||||
w, h = 1200, 600
|
||||
banner = Image.new("RGBA", (w, h), (245, 247, 243, 255)) # AppColors.paper #F5F7F3
|
||||
draw = ImageDraw.Draw(banner)
|
||||
|
||||
# Background subtle decorative circles
|
||||
dec_draw = ImageDraw.Draw(banner)
|
||||
dec_draw.ellipse([800, -100, 1400, 500], fill=(226, 243, 232, 120))
|
||||
dec_draw.ellipse([-100, 300, 400, 800], fill=(255, 240, 227, 120))
|
||||
|
||||
# Paste resized icon on the left
|
||||
icon_w = 340
|
||||
icon_resized = icon_img.resize((icon_w, icon_w), Image.Resampling.LANCZOS)
|
||||
banner.paste(icon_resized, (100, (h - icon_w) // 2), icon_resized)
|
||||
|
||||
# Typography on the right
|
||||
# Try finding available fonts or use default with clear layout
|
||||
font_path_candidates = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
]
|
||||
|
||||
title_font = None
|
||||
en_font = None
|
||||
sub_font = None
|
||||
badge_font = None
|
||||
|
||||
for p in font_path_candidates:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
title_font = ImageFont.truetype(p, 58, index=0)
|
||||
en_font = ImageFont.truetype(p, 30, index=0)
|
||||
sub_font = ImageFont.truetype(p, 24, index=0)
|
||||
badge_font = ImageFont.truetype(p, 18, index=0)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not title_font:
|
||||
title_font = ImageFont.load_default()
|
||||
en_font = ImageFont.load_default()
|
||||
sub_font = ImageFont.load_default()
|
||||
badge_font = ImageFont.load_default()
|
||||
|
||||
tx = 490
|
||||
ty = 135
|
||||
|
||||
# Tag / Pill: "AI 伴学 · 零基础开口"
|
||||
pill_w = 210
|
||||
pill_h = 36
|
||||
draw.rounded_rectangle([tx, ty, tx + pill_w, ty + pill_h], radius=18, fill=(226, 243, 232, 255))
|
||||
draw.text((tx + 18, ty + 7), "🌱 AI 伴学 · 轻松开口", fill=(23, 107, 70, 255), font=badge_font)
|
||||
|
||||
# Main Brand Name
|
||||
draw.text((tx, ty + 50), "芽说英语", fill=(25, 33, 27, 255), font=title_font)
|
||||
|
||||
# English Name
|
||||
draw.text((tx + 270, ty + 72), "SpeakSprout", fill=(23, 107, 70, 255), font=en_font)
|
||||
|
||||
# Slogan / Value Proposition
|
||||
draw.text((tx, ty + 145), "每一次开口,都是成长的萌芽", fill=(100, 114, 104, 255), font=sub_font)
|
||||
draw.text((tx, ty + 190), "• 真实场景 1v1 AI 语伴 Mia", fill=(25, 33, 27, 230), font=sub_font)
|
||||
draw.text((tx, ty + 235), "• 听 / 说 / 读 / 写 四维科学进阶", fill=(25, 33, 27, 230), font=sub_font)
|
||||
draw.text((tx, ty + 280), "• 本地离线高精语音识别 · 极速跟读", fill=(25, 33, 27, 230), font=sub_font)
|
||||
|
||||
return banner
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_dir = "assets/branding"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
print("🎨 正在生成超采样高清 Logo (1024x1024)...")
|
||||
icon1024 = create_super_sampled_icon(size=1024, supersample=4)
|
||||
master_path = os.path.join(out_dir, "logo_master_1024.png")
|
||||
icon1024.save(master_path, "PNG")
|
||||
print(f"✅ 保存主图标: {master_path}")
|
||||
|
||||
# 512x512
|
||||
icon512 = icon1024.resize((512, 512), Image.Resampling.LANCZOS)
|
||||
p512 = os.path.join(out_dir, "logo_512.png")
|
||||
icon512.save(p512, "PNG")
|
||||
|
||||
# Brand Banner
|
||||
print("🖼️ 正在生成品牌展示图 (1200x600)...")
|
||||
banner = create_brand_banner(icon1024)
|
||||
banner_path = os.path.join(out_dir, "brand_banner.png")
|
||||
banner.save(banner_path, "PNG")
|
||||
print(f"✅ 保存品牌展示图: {banner_path}")
|
||||
|
||||
# Android launcher icons
|
||||
mipmaps = {
|
||||
"mipmap-mdpi": 48,
|
||||
"mipmap-hdpi": 72,
|
||||
"mipmap-xhdpi": 96,
|
||||
"mipmap-xxhdpi": 144,
|
||||
"mipmap-xxxhdpi": 192,
|
||||
}
|
||||
|
||||
print("📱 正在更新 Android App 各分辨率图标...")
|
||||
for folder, sz in mipmaps.items():
|
||||
dir_path = os.path.join("android/app/src/main/res", folder)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
out_f = os.path.join(dir_path, "ic_launcher.png")
|
||||
resized = icon1024.resize((sz, sz), Image.Resampling.LANCZOS)
|
||||
resized.save(out_f, "PNG")
|
||||
print(f" -> {out_f} ({sz}x{sz})")
|
||||
|
||||
print("🎉 全部 Logo 和图标生成完成!")
|
||||
@@ -0,0 +1,308 @@
|
||||
import os
|
||||
import math
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
def cubic_bezier(p0, p1, p2, p3, steps=60):
|
||||
points = []
|
||||
for i in range(steps + 1):
|
||||
t = i / float(steps)
|
||||
x = (1-t)**3 * p0[0] + 3*(1-t)**2 * t * p1[0] + 3*(1-t) * t**2 * p2[0] + t**3 * p3[0]
|
||||
y = (1-t)**3 * p0[1] + 3*(1-t)**2 * t * p1[1] + 3*(1-t) * t**2 * p2[1] + t**3 * p3[1]
|
||||
points.append((x, y))
|
||||
return points
|
||||
|
||||
def create_sprout_icon(size=1024, supersample=4, transparent_bg=False):
|
||||
ss = size * supersample
|
||||
img = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
|
||||
radius = int(ss * 0.225)
|
||||
margin = int(ss * 0.045)
|
||||
|
||||
mask = Image.new("L", (ss, ss), 0)
|
||||
mask_draw = ImageDraw.Draw(mask)
|
||||
mask_draw.rounded_rectangle(
|
||||
[margin, margin, ss - margin, ss - margin],
|
||||
radius=radius,
|
||||
fill=255
|
||||
)
|
||||
|
||||
gradient = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
# Colors: top-left fresh emerald #198754, bottom-right deep forest #0D4E30
|
||||
c_tl = (28, 148, 92)
|
||||
c_br = (12, 68, 42)
|
||||
|
||||
for y in range(ss):
|
||||
for x in range(0, ss, 4):
|
||||
t = (x * 0.7 + y * 1.0) / (ss * 1.7)
|
||||
t = max(0.0, min(1.0, t))
|
||||
r = int(c_tl[0] * (1 - t) + c_br[0] * t)
|
||||
g = int(c_tl[1] * (1 - t) + c_br[1] * t)
|
||||
b = int(c_tl[2] * (1 - t) + c_br[2] * t)
|
||||
for dx in range(4):
|
||||
if x + dx < ss:
|
||||
gradient.putpixel((x + dx, y), (r, g, b, 255))
|
||||
|
||||
bg = Image.composite(gradient, Image.new("RGBA", (ss, ss), (0, 0, 0, 0)), mask)
|
||||
|
||||
# Ambient subtle highlight on top edge
|
||||
highlight = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
hl_draw = ImageDraw.Draw(highlight)
|
||||
hl_draw.rounded_rectangle(
|
||||
[margin + 4*supersample, margin + 4*supersample, ss - margin - 4*supersample, ss - margin - 4*supersample],
|
||||
radius=radius - 4*supersample,
|
||||
outline=(255, 255, 255, 35),
|
||||
width=int(2.5 * supersample)
|
||||
)
|
||||
bg = Image.alpha_composite(bg, highlight)
|
||||
|
||||
cx, cy = ss // 2, ss // 2
|
||||
|
||||
# 2. Main Center Hero Badge: Rounded Speech Bubble with clean geometry
|
||||
bw = int(580 * supersample)
|
||||
bh = int(480 * supersample)
|
||||
bx = cx - bw // 2
|
||||
by = cy - bh // 2 - int(25 * supersample)
|
||||
br = int(120 * supersample)
|
||||
|
||||
# Bubble Drop Shadow
|
||||
bubble_shadow = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
bsh_draw = ImageDraw.Draw(bubble_shadow)
|
||||
bsh_draw.rounded_rectangle(
|
||||
[bx, by + int(18 * supersample), bx + bw, by + bh + int(18 * supersample)],
|
||||
radius=br,
|
||||
fill=(0, 0, 0, 75)
|
||||
)
|
||||
tail_pts_sh = [
|
||||
(bx + int(90 * supersample), by + bh + int(10 * supersample)),
|
||||
(bx + int(30 * supersample), by + bh + int(105 * supersample)),
|
||||
(bx + int(190 * supersample), by + bh + int(10 * supersample)),
|
||||
]
|
||||
bsh_draw.polygon(tail_pts_sh, fill=(0, 0, 0, 75))
|
||||
bubble_shadow = bubble_shadow.filter(ImageFilter.GaussianBlur(radius=int(18 * supersample)))
|
||||
bg = Image.alpha_composite(bg, bubble_shadow)
|
||||
|
||||
# Bubble Body (Pure White with subtle warm sheen)
|
||||
bubble = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
b_draw = ImageDraw.Draw(bubble)
|
||||
b_draw.rounded_rectangle(
|
||||
[bx, by, bx + bw, by + bh],
|
||||
radius=br,
|
||||
fill=(255, 255, 255, 255)
|
||||
)
|
||||
tail_pts = [
|
||||
(bx + int(90 * supersample), by + bh - int(8 * supersample)),
|
||||
(bx + int(30 * supersample), by + bh + int(85 * supersample)),
|
||||
(bx + int(190 * supersample), by + bh - int(8 * supersample)),
|
||||
]
|
||||
b_draw.polygon(tail_pts, fill=(255, 255, 255, 255))
|
||||
bg = Image.alpha_composite(bg, bubble)
|
||||
|
||||
# 3. Inside the Bubble: Sprout + Smile Soundwave Iconography
|
||||
icx = bx + bw // 2
|
||||
icy = by + bh // 2 - int(10 * supersample)
|
||||
|
||||
sprout_layer = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
|
||||
sp_draw = ImageDraw.Draw(sprout_layer)
|
||||
|
||||
# 3.1 Right Leaf (Primary, lush emerald)
|
||||
p0 = (icx - int(10 * supersample), icy + int(35 * supersample))
|
||||
p1 = (icx + int(70 * supersample), icy + int(15 * supersample))
|
||||
p2 = (icx + int(165 * supersample), icy - int(50 * supersample))
|
||||
p3 = (icx + int(135 * supersample), icy - int(130 * supersample)) # Tip
|
||||
|
||||
p4 = (icx + int(60 * supersample), icy - int(115 * supersample))
|
||||
p5 = (icx + int(0 * supersample), icy - int(45 * supersample))
|
||||
p6 = p0
|
||||
|
||||
curve_right_top = cubic_bezier(p0, p1, p2, p3, steps=40)
|
||||
curve_right_bot = cubic_bezier(p3, p4, p5, p6, steps=40)
|
||||
right_leaf_pts = curve_right_top + curve_right_bot
|
||||
sp_draw.polygon(right_leaf_pts, fill=(23, 107, 70, 255)) # #176B46
|
||||
|
||||
# 3.2 Left Leaf (Secondary, fresh bright mint green)
|
||||
lp0 = (icx - int(18 * supersample), icy + int(50 * supersample))
|
||||
lp1 = (icx - int(65 * supersample), icy + int(25 * supersample))
|
||||
lp2 = (icx - int(135 * supersample), icy - int(15 * supersample))
|
||||
lp3 = (icx - int(120 * supersample), icy - int(80 * supersample)) # Tip
|
||||
|
||||
lp4 = (icx - int(60 * supersample), icy - int(65 * supersample))
|
||||
lp5 = (icx - int(15 * supersample), icy - int(10 * supersample))
|
||||
lp6 = lp0
|
||||
|
||||
curve_left_top = cubic_bezier(lp0, lp1, lp2, lp3, steps=40)
|
||||
curve_left_bot = cubic_bezier(lp3, lp4, lp5, lp6, steps=40)
|
||||
left_leaf_pts = curve_left_top + curve_left_bot
|
||||
sp_draw.polygon(left_leaf_pts, fill=(52, 185, 118, 255)) # Bright fresh green
|
||||
|
||||
# 3.3 Sprout Sun Dot / Golden Energy Droplet (Golden Sun #F59E0B)
|
||||
dot_x = icx + int(148 * supersample)
|
||||
dot_y = icy - int(142 * supersample)
|
||||
dot_r = int(22 * supersample)
|
||||
sp_draw.ellipse(
|
||||
[dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r],
|
||||
fill=(245, 158, 11, 255)
|
||||
)
|
||||
|
||||
# 3.4 Smile / Vocal Flow Arc
|
||||
smile_y = icy + int(135 * supersample)
|
||||
smile_w = int(140 * supersample)
|
||||
smile_pts = cubic_bezier(
|
||||
(icx - smile_w, smile_y - int(15 * supersample)),
|
||||
(icx - smile_w // 2, smile_y + int(30 * supersample)),
|
||||
(icx + smile_w // 2, smile_y + int(30 * supersample)),
|
||||
(icx + smile_w, smile_y - int(15 * supersample)),
|
||||
steps=50
|
||||
)
|
||||
for i in range(len(smile_pts) - 1):
|
||||
sp_draw.line([smile_pts[i], smile_pts[i+1]], fill=(23, 107, 70, 230), width=int(14 * supersample))
|
||||
|
||||
s_cap_r = int(7 * supersample)
|
||||
sp_draw.ellipse([smile_pts[0][0]-s_cap_r, smile_pts[0][1]-s_cap_r, smile_pts[0][0]+s_cap_r, smile_pts[0][1]+s_cap_r], fill=(23, 107, 70, 230))
|
||||
sp_draw.ellipse([smile_pts[-1][0]-s_cap_r, smile_pts[-1][1]-s_cap_r, smile_pts[-1][0]+s_cap_r, smile_pts[-1][0]+s_cap_r], fill=(23, 107, 70, 230))
|
||||
|
||||
# 3.5 Concentric Dynamic Sound Waves
|
||||
w_cx = icx - int(55 * supersample)
|
||||
w_cy = icy - int(55 * supersample)
|
||||
|
||||
wave_r1 = int(115 * supersample)
|
||||
sp_draw.arc(
|
||||
[w_cx - wave_r1, w_cy - wave_r1, w_cx + wave_r1, w_cy + wave_r1],
|
||||
start=155, end=245,
|
||||
fill=(52, 185, 118, 240),
|
||||
width=int(12 * supersample)
|
||||
)
|
||||
|
||||
wave_r2 = int(165 * supersample)
|
||||
sp_draw.arc(
|
||||
[w_cx - wave_r2, w_cy - wave_r2, w_cx + wave_r2, w_cy + wave_r2],
|
||||
start=160, end=240,
|
||||
fill=(245, 158, 11, 230),
|
||||
width=int(12 * supersample)
|
||||
)
|
||||
|
||||
bg = Image.alpha_composite(bg, sprout_layer)
|
||||
|
||||
# Downsample
|
||||
final_icon = bg.resize((size, size), Image.Resampling.LANCZOS)
|
||||
return final_icon
|
||||
|
||||
def create_brand_banner(icon_img):
|
||||
w, h = 1200, 600
|
||||
banner = Image.new("RGBA", (w, h), (245, 247, 243, 255)) # AppColors.paper #F5F7F3
|
||||
draw = ImageDraw.Draw(banner)
|
||||
|
||||
# Decorative background shapes
|
||||
draw.ellipse([760, -120, 1360, 480], fill=(226, 243, 232, 140))
|
||||
draw.ellipse([-80, 320, 420, 820], fill=(255, 240, 227, 130))
|
||||
|
||||
# Left Icon
|
||||
icon_w = 340
|
||||
icon_resized = icon_img.resize((icon_w, icon_w), Image.Resampling.LANCZOS)
|
||||
banner.paste(icon_resized, (90, (h - icon_w) // 2), icon_resized)
|
||||
|
||||
font_path_candidates = [
|
||||
"/System/Library/Fonts/PingFang.ttc",
|
||||
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
]
|
||||
|
||||
title_font = None
|
||||
en_font = None
|
||||
sub_font = None
|
||||
desc_font = None
|
||||
badge_font = None
|
||||
|
||||
for p in font_path_candidates:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
title_font = ImageFont.truetype(p, 58, index=0)
|
||||
en_font = ImageFont.truetype(p, 32, index=0)
|
||||
sub_font = ImageFont.truetype(p, 23, index=0)
|
||||
desc_font = ImageFont.truetype(p, 21, index=0)
|
||||
badge_font = ImageFont.truetype(p, 17, index=0)
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not title_font:
|
||||
title_font = ImageFont.load_default()
|
||||
en_font = ImageFont.load_default()
|
||||
sub_font = ImageFont.load_default()
|
||||
desc_font = ImageFont.load_default()
|
||||
badge_font = ImageFont.load_default()
|
||||
|
||||
tx = 475
|
||||
ty = 105
|
||||
|
||||
# Badge Pill
|
||||
pill_w = 240
|
||||
pill_h = 36
|
||||
draw.rounded_rectangle([tx, ty, tx + pill_w, ty + pill_h], radius=18, fill=(226, 243, 232, 255))
|
||||
# Small green dot in pill
|
||||
draw.ellipse([tx + 14, ty + 12, tx + 26, ty + 24], fill=(23, 107, 70, 255))
|
||||
draw.text((tx + 34, ty + 7), "AI 口语私教 · 零基础开口", fill=(23, 107, 70, 255), font=badge_font)
|
||||
|
||||
# Main Brand Name
|
||||
draw.text((tx, ty + 52), "芽说英语", fill=(25, 33, 27, 255), font=title_font)
|
||||
draw.text((tx + 270, ty + 72), "SpeakSprout", fill=(23, 107, 70, 255), font=en_font)
|
||||
|
||||
# Slogan
|
||||
draw.text((tx, ty + 145), "“ 每一次开口,都是成长的萌芽 ”", fill=(100, 114, 104, 255), font=sub_font)
|
||||
|
||||
# Clean bullet features (using custom drawn colored dots instead of emoji glyphs)
|
||||
features = [
|
||||
("1v1 智能语伴 Mia · 沉浸式情境真实对话", (23, 107, 70, 255)),
|
||||
("听 · 说 · 读 · 写 四维微习惯进阶体系", (52, 185, 118, 255)),
|
||||
("离线高精度语音识别 · 毫秒级跟读评分", (245, 158, 11, 255)),
|
||||
("间隔艾宾浩斯复习 · 真正打破哑巴英语", (23, 107, 70, 255))
|
||||
]
|
||||
for i, (feat, dot_c) in enumerate(features):
|
||||
fy = ty + 200 + i * 44
|
||||
# Draw small clean bullet dot
|
||||
draw.ellipse([tx, fy + 7, tx + 10, fy + 17], fill=dot_c)
|
||||
draw.text((tx + 22, fy), feat, fill=(35, 45, 38, 245), font=desc_font)
|
||||
|
||||
return banner
|
||||
|
||||
if __name__ == "__main__":
|
||||
out_dir = "assets/branding"
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
print("🎨 正在生成全新高清 Logo v2 (1024x1024)...")
|
||||
icon1024 = create_sprout_icon(size=1024, supersample=4)
|
||||
master_path = os.path.join(out_dir, "logo_master_1024.png")
|
||||
icon1024.save(master_path, "PNG")
|
||||
print(f"✅ 保存主图标: {master_path}")
|
||||
|
||||
# 512x512
|
||||
icon512 = icon1024.resize((512, 512), Image.Resampling.LANCZOS)
|
||||
p512 = os.path.join(out_dir, "logo_512.png")
|
||||
icon512.save(p512, "PNG")
|
||||
|
||||
# Brand Banner
|
||||
print("🖼️ 正在生成品牌展示图 (1200x600)...")
|
||||
banner = create_brand_banner(icon1024)
|
||||
banner_path = os.path.join(out_dir, "brand_banner.png")
|
||||
banner.save(banner_path, "PNG")
|
||||
print(f"✅ 保存品牌展示图: {banner_path}")
|
||||
|
||||
# Android launcher icons
|
||||
mipmaps = {
|
||||
"mipmap-mdpi": 48,
|
||||
"mipmap-hdpi": 72,
|
||||
"mipmap-xhdpi": 96,
|
||||
"mipmap-xxhdpi": 144,
|
||||
"mipmap-xxxhdpi": 192,
|
||||
}
|
||||
|
||||
print("📱 正在更新 Android App 各分辨率图标...")
|
||||
for folder, sz in mipmaps.items():
|
||||
dir_path = os.path.join("android/app/src/main/res", folder)
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
out_f = os.path.join(dir_path, "ic_launcher.png")
|
||||
resized = icon1024.resize((sz, sz), Image.Resampling.LANCZOS)
|
||||
resized.save(out_f, "PNG")
|
||||
print(f" -> {out_f} ({sz}x{sz})")
|
||||
|
||||
print("🎉 全部 Logo 和图标生成完成!")
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# 进入项目根目录
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "📱 检查连接的 Android 设备..."
|
||||
DEVICE_COUNT=$(adb devices | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ')
|
||||
|
||||
if [ "$DEVICE_COUNT" -eq 0 ]; then
|
||||
echo "❌ 未检测到连接的 Android 设备,请确保手机已开启 USB 调试并通过数据线连接!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📦 正在编译 Debug APK (包含最新的 assets 配置)..."
|
||||
flutter build apk --debug
|
||||
|
||||
APK_PATH="build/app/outputs/flutter-apk/app-debug.apk"
|
||||
|
||||
if [ ! -f "$APK_PATH" ]; then
|
||||
echo "❌ 找不到编译输出的 APK: $APK_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 正在安装 APK 到手机..."
|
||||
if ! adb install -r "$APK_PATH"; then
|
||||
echo "⚠️ 覆盖安装失败(签名不一致或版本冲突),正在卸载旧版本并重新安装..."
|
||||
adb uninstall com.shen.kouyu_english || true
|
||||
adb install -r "$APK_PATH"
|
||||
fi
|
||||
|
||||
echo "▶️ 正在手机上启动 开口英语 App..."
|
||||
adb shell monkey -p com.shen.kouyu_english -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 || true
|
||||
|
||||
echo "✅ 安装并启动成功!"
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 14 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Kouyu English</string>
|
||||
<string>芽说英语</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'models.dart';
|
||||
|
||||
class AiConfigFile {
|
||||
const AiConfigFile({
|
||||
required this.provider,
|
||||
required this.endpoint,
|
||||
required this.model,
|
||||
this.reasoningEffort = 'low',
|
||||
this.apiKey,
|
||||
this.description,
|
||||
});
|
||||
|
||||
final AiProviderType provider;
|
||||
final String endpoint;
|
||||
final String model;
|
||||
final String reasoningEffort;
|
||||
final String? apiKey;
|
||||
final String? description;
|
||||
|
||||
static const String defaultAssetPath = 'assets/config/ai_config.json';
|
||||
|
||||
factory AiConfigFile.fromJson(Map<String, dynamic> json) {
|
||||
final providerStr = json['provider'] as String? ?? 'compatible';
|
||||
final provider = AiProviderType.values.firstWhere(
|
||||
(p) => p.name.toLowerCase() == providerStr.toLowerCase(),
|
||||
orElse: () => AiProviderType.compatible,
|
||||
);
|
||||
final effort = (json['reasoningEffort'] as String? ??
|
||||
json['reasoning_effort'] as String? ??
|
||||
'low')
|
||||
.trim();
|
||||
return AiConfigFile(
|
||||
provider: provider,
|
||||
endpoint: (json['endpoint'] as String? ?? '').trim(),
|
||||
model: (json['model'] as String? ?? '').trim(),
|
||||
reasoningEffort: effort.isEmpty ? 'low' : effort,
|
||||
apiKey: json['apiKey'] as String?,
|
||||
description: json['description'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
factory AiConfigFile.parse(String rawJson) {
|
||||
final data = jsonDecode(rawJson) as Map<String, dynamic>;
|
||||
return AiConfigFile.fromJson(data);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'provider': provider.name,
|
||||
'endpoint': endpoint,
|
||||
'model': model,
|
||||
'reasoningEffort': reasoningEffort,
|
||||
if (apiKey != null) 'apiKey': apiKey,
|
||||
if (description != null) 'description': description,
|
||||
};
|
||||
|
||||
static Future<AiConfigFile?> loadFromAsset([
|
||||
String path = defaultAssetPath,
|
||||
]) async {
|
||||
try {
|
||||
final content = await rootBundle.loadString(path);
|
||||
return AiConfigFile.parse(content);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
AiConfigFile copyWith({
|
||||
AiProviderType? provider,
|
||||
String? endpoint,
|
||||
String? model,
|
||||
String? reasoningEffort,
|
||||
String? apiKey,
|
||||
String? description,
|
||||
}) => AiConfigFile(
|
||||
provider: provider ?? this.provider,
|
||||
endpoint: endpoint ?? this.endpoint,
|
||||
model: model ?? this.model,
|
||||
reasoningEffort: reasoningEffort ?? this.reasoningEffort,
|
||||
apiKey: apiKey ?? this.apiKey,
|
||||
description: description ?? this.description,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'sherpa_stt_service.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
@@ -5,7 +7,6 @@ import 'package:http/http.dart' as http;
|
||||
|
||||
import 'models.dart';
|
||||
import 'generated_content.dart';
|
||||
import 'a0_core.dart';
|
||||
|
||||
class AiConnectionResult {
|
||||
const AiConnectionResult({required this.ok, required this.message});
|
||||
@@ -14,12 +15,18 @@ class AiConnectionResult {
|
||||
}
|
||||
|
||||
/// Stores the secret separately from normal app settings. Compatible endpoints
|
||||
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
|
||||
/// support both OpenAI chat-completions shape (/v1/chat/completions) and
|
||||
/// responses shape (/v1/responses), including a user-run CLIProxyAPI.
|
||||
class AiService {
|
||||
AiService._();
|
||||
static final instance = AiService._();
|
||||
static const _keyName = 'ai_api_key';
|
||||
final _secureStorage = const FlutterSecureStorage();
|
||||
String? _fallbackApiKey;
|
||||
|
||||
void setFallbackApiKey(String? key) {
|
||||
_fallbackApiKey = key?.trim();
|
||||
}
|
||||
|
||||
Future<void> saveApiKey(String value) async {
|
||||
if (value.trim().isEmpty) {
|
||||
@@ -29,8 +36,231 @@ class AiService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> resolveApiKey([String? explicit]) async {
|
||||
if (explicit != null && explicit.trim().isNotEmpty) {
|
||||
return explicit.trim();
|
||||
}
|
||||
try {
|
||||
final stored = await _secureStorage.read(key: _keyName);
|
||||
if (stored != null && stored.trim().isNotEmpty) {
|
||||
return stored.trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
if (_fallbackApiKey != null && _fallbackApiKey!.trim().isNotEmpty) {
|
||||
return _fallbackApiKey!.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> hasApiKey() async =>
|
||||
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
|
||||
(await resolveApiKey())?.isNotEmpty ?? false;
|
||||
|
||||
Future<String?> getApiKey() async => await resolveApiKey();
|
||||
|
||||
/// Resolves the target endpoint URI.
|
||||
/// If explicitly set to /responses or /chat/completions, it respects that path.
|
||||
/// If ending in /v1 or base URL, it defaults to /v1/chat/completions.
|
||||
static Uri? resolveEndpointUri({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
}) {
|
||||
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
|
||||
if (base.isEmpty) return null;
|
||||
if (provider == AiProviderType.gemini) {
|
||||
if (base.contains(':generateContent')) {
|
||||
return Uri.tryParse(base);
|
||||
}
|
||||
return Uri.tryParse('$base/models/$model:generateContent');
|
||||
}
|
||||
if (base.endsWith('/chat/completions')) {
|
||||
return Uri.tryParse(base);
|
||||
}
|
||||
if (base.endsWith('/responses')) {
|
||||
return Uri.tryParse(base);
|
||||
}
|
||||
if (base.endsWith('/v1')) {
|
||||
return Uri.tryParse('$base/chat/completions');
|
||||
}
|
||||
return Uri.tryParse('$base/v1/chat/completions');
|
||||
}
|
||||
|
||||
static Uri? resolveChatCompletionsUri({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
}) {
|
||||
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
|
||||
if (base.isEmpty) return null;
|
||||
if (provider == AiProviderType.gemini) {
|
||||
if (base.contains(':generateContent')) {
|
||||
return Uri.tryParse(base);
|
||||
}
|
||||
return Uri.tryParse('$base/models/$model:generateContent');
|
||||
}
|
||||
if (base.endsWith('/responses')) {
|
||||
final root = base.substring(0, base.length - '/responses'.length);
|
||||
return Uri.tryParse('$root/chat/completions');
|
||||
}
|
||||
if (base.endsWith('/chat/completions')) {
|
||||
return Uri.tryParse(base);
|
||||
}
|
||||
if (base.endsWith('/v1')) {
|
||||
return Uri.tryParse('$base/chat/completions');
|
||||
}
|
||||
return Uri.tryParse('$base/v1/chat/completions');
|
||||
}
|
||||
|
||||
/// Transcribes spoken audio file to English text using the configured AI multimodal model.
|
||||
Future<String?> transcribeAudio({
|
||||
required String filePath,
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
}) async {
|
||||
if (filePath.toLowerCase().endsWith('.wav')) {
|
||||
final localText = await SherpaSttService.instance.transcribeWav(filePath);
|
||||
if (localText != null && localText.trim().isNotEmpty) {
|
||||
return localText.trim();
|
||||
}
|
||||
}
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) return null;
|
||||
final bytes = await file.readAsBytes();
|
||||
if (bytes.isEmpty) return null;
|
||||
|
||||
final key = await resolveApiKey();
|
||||
final uri = resolveChatCompletionsUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (key == null || key.isEmpty || uri == null) return null;
|
||||
|
||||
final ext = filePath.split('.').last.toLowerCase();
|
||||
final format = (ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac') ? ext : 'm4a';
|
||||
final base64Data = base64Encode(bytes);
|
||||
|
||||
try {
|
||||
if (provider == AiProviderType.gemini) {
|
||||
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {'x-goog-api-key': key, 'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'contents': [
|
||||
{
|
||||
'parts': [
|
||||
{
|
||||
'text': 'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
|
||||
},
|
||||
{
|
||||
'inline_data': {
|
||||
'mime_type': mimeType,
|
||||
'data': base64Data,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'generationConfig': {
|
||||
'thinkingConfig': {
|
||||
'thinkingBudget': 1024,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).timeout(const Duration(seconds: 25));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
return _extractResponseContent(provider, response.body);
|
||||
} else {
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $key',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'model': model,
|
||||
'messages': [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
|
||||
},
|
||||
{
|
||||
'type': 'input_audio',
|
||||
'input_audio': {
|
||||
'data': base64Data,
|
||||
'format': format,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
'reasoning_effort': 'low',
|
||||
'temperature': 0.1,
|
||||
}),
|
||||
).timeout(const Duration(seconds: 25));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
final raw = _extractResponseContent(provider, response.body);
|
||||
if (raw == null) return null;
|
||||
var text = raw.trim();
|
||||
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
|
||||
text = text.substring(1, text.length - 1).trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildOpenAiPayload({
|
||||
required Uri uri,
|
||||
required String model,
|
||||
required List<Map<String, String>> messages,
|
||||
double? temperature,
|
||||
int? maxTokens,
|
||||
String reasoningEffort = 'low',
|
||||
}) {
|
||||
final isResponses = uri.path.endsWith('/responses');
|
||||
if (isResponses) {
|
||||
return {
|
||||
'model': model,
|
||||
'input': messages,
|
||||
'reasoning': {'effort': reasoningEffort},
|
||||
'reasoning_effort': reasoningEffort,
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (maxTokens != null) 'max_output_tokens': maxTokens,
|
||||
};
|
||||
}
|
||||
return {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
'reasoning_effort': reasoningEffort,
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (maxTokens != null) 'max_tokens': maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _buildGeminiGenerationConfig({
|
||||
double? temperature,
|
||||
int? maxOutputTokens,
|
||||
String? responseMimeType,
|
||||
int thinkingBudget = 1024,
|
||||
}) {
|
||||
return {
|
||||
if (temperature != null) 'temperature': temperature,
|
||||
if (maxOutputTokens != null) 'maxOutputTokens': maxOutputTokens,
|
||||
if (responseMimeType != null) 'responseMimeType': responseMimeType,
|
||||
'thinkingConfig': {
|
||||
'thinkingBudget': thinkingBudget,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns a display-only Chinese gloss for an unknown word or phrase.
|
||||
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
|
||||
@@ -45,18 +275,17 @@ class AiService {
|
||||
text.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final key = await resolveApiKey();
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
model.trim().isEmpty ||
|
||||
uri == null ||
|
||||
uri.scheme != 'https') {
|
||||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
return null;
|
||||
}
|
||||
const instruction =
|
||||
@@ -81,31 +310,34 @@ class AiService {
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'maxOutputTokens': 100,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
'role': 'user',
|
||||
'content': '$instruction\nText: $text',
|
||||
},
|
||||
],
|
||||
'max_tokens': 100,
|
||||
},
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
final raw = _extractResponseContent(provider, response.body);
|
||||
final data = raw == null ? null : jsonDecode(raw);
|
||||
final definition = data is Map ? data['definition'] : null;
|
||||
return definition is String &&
|
||||
definition.trim().isNotEmpty &&
|
||||
definition.length <= 160
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (content == null || content.length > 300) return null;
|
||||
final parsed = jsonDecode(content);
|
||||
if (parsed is! Map<String, dynamic>) return null;
|
||||
final definition = parsed['definition'];
|
||||
return definition is String && definition.trim().isNotEmpty
|
||||
? definition.trim()
|
||||
: null;
|
||||
} catch (_) {
|
||||
@@ -117,15 +349,16 @@ class AiService {
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
String? explicitApiKey,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) {
|
||||
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
|
||||
}
|
||||
const probe =
|
||||
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
final key = await resolveApiKey(explicitApiKey);
|
||||
if (key == null || key.isEmpty) {
|
||||
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
|
||||
return const AiConnectionResult(ok: false, message: '请先填写或保存 API Key。');
|
||||
}
|
||||
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
|
||||
return const AiConnectionResult(
|
||||
@@ -133,14 +366,16 @@ class AiService {
|
||||
message: '请填写 Base URL 和模型名称。',
|
||||
);
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
return const AiConnectionResult(
|
||||
ok: false,
|
||||
message: '请使用有效的 HTTP 或 HTTPS 地址。',
|
||||
);
|
||||
}
|
||||
try {
|
||||
final response = await http
|
||||
@@ -162,45 +397,63 @@ class AiService {
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'maxOutputTokens': 80,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': probe},
|
||||
],
|
||||
'max_tokens': 80,
|
||||
},
|
||||
temperature: 0,
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (_decodeDialogueResponse(content) != null) {
|
||||
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
|
||||
return const AiConnectionResult(
|
||||
ok: true,
|
||||
message: '连接成功,AI 对话服务可用!',
|
||||
);
|
||||
}
|
||||
return const AiConnectionResult(
|
||||
ok: false,
|
||||
message: '服务可连接,但未返回应用需要的结构化对话格式。',
|
||||
ok: true,
|
||||
message: '连接成功,接口响应正常。',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
return AiConnectionResult(
|
||||
ok: false,
|
||||
message: '鉴权失败 (HTTP ${response.statusCode}),请检查 API Key 是否正确。',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 404) {
|
||||
return const AiConnectionResult(
|
||||
ok: false,
|
||||
message: '鉴权失败,请检查 API Key。',
|
||||
message: '服务返回 404,请检查 Base URL(如是否缺少 /v1)或模型名称。',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 429) {
|
||||
return const AiConnectionResult(
|
||||
ok: false,
|
||||
message: '请求受限 (HTTP 429),API 额度不足或达到并发限制。',
|
||||
);
|
||||
}
|
||||
return AiConnectionResult(
|
||||
ok: false,
|
||||
message: '服务返回 ${response.statusCode},请检查地址和模型。',
|
||||
message: '服务返回 HTTP ${response.statusCode},请检查地址和模型配置。',
|
||||
);
|
||||
} catch (_) {
|
||||
return const AiConnectionResult(
|
||||
} catch (e) {
|
||||
return AiConnectionResult(
|
||||
ok: false,
|
||||
message: '无法连接服务。请检查网络、地址或局域网连通性。',
|
||||
message: '无法连接服务 ($e)。请检查网络、地址或代理连通性。',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,24 +468,23 @@ class AiService {
|
||||
if (provider == AiProviderType.mock) {
|
||||
return null;
|
||||
}
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') {
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||||
return null;
|
||||
}
|
||||
const system =
|
||||
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
|
||||
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), translation (string, simplified Chinese translation of reply), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
@@ -263,21 +515,22 @@ class AiService {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'generationConfig': {
|
||||
'temperature': 0.3,
|
||||
'maxOutputTokens': 60,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0.3,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'system', 'content': '$system$requiredTask'},
|
||||
...history,
|
||||
],
|
||||
'temperature': 0.3,
|
||||
'max_tokens': 60,
|
||||
},
|
||||
temperature: 0.3,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
@@ -303,26 +556,21 @@ class AiService {
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') return null;
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||||
final instruction =
|
||||
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
|
||||
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
|
||||
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
|
||||
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
|
||||
Base task: $basePrompt''';
|
||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
@@ -343,50 +591,50 @@ Base task: $basePrompt''';
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'temperature': 0.3,
|
||||
'maxOutputTokens': 180,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
'temperature': 0.3,
|
||||
'max_tokens': 180,
|
||||
},
|
||||
temperature: 0.2,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final raw = _extractResponseContent(provider, response.body);
|
||||
final decoded = raw == null
|
||||
? null
|
||||
: decodeGeneratedReviewVariant(
|
||||
raw,
|
||||
expectedTargetItemId: targetItemId,
|
||||
);
|
||||
if (decoded != null || repairAttempt) return decoded;
|
||||
return generateReviewVariant(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
basePrompt: basePrompt,
|
||||
repairAttempt: true,
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (content == null) return null;
|
||||
final variant = decodeGeneratedReviewVariant(
|
||||
content,
|
||||
expectedTargetItemId: targetItemId,
|
||||
);
|
||||
if (variant == null && !repairAttempt) {
|
||||
return generateReviewVariant(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
basePrompt: basePrompt,
|
||||
repairAttempt: true,
|
||||
);
|
||||
}
|
||||
return variant;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests a short, teaching-oriented writing suggestion. The caller must
|
||||
/// still run its local task validator; this response has no authority to
|
||||
/// mark an answer correct or change mastery.
|
||||
/// Evaluates an open-ended writing response against a bounded schema.
|
||||
Future<WritingAiFeedback?> writingFeedback({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
@@ -394,31 +642,28 @@ Base task: $basePrompt''';
|
||||
required String lessonId,
|
||||
required String taskPrompt,
|
||||
required String answer,
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') return null;
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||||
final instruction =
|
||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
|
||||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||||
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
|
||||
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
|
||||
Task: $taskPrompt
|
||||
Learner answer: $answer''';
|
||||
Learner wrote: $answer''';
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
@@ -439,47 +684,39 @@ Learner answer: $answer''';
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'temperature': 0.2,
|
||||
'maxOutputTokens': 240,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 300,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
'temperature': 0.2,
|
||||
'max_tokens': 240,
|
||||
},
|
||||
temperature: 0,
|
||||
maxTokens: 300,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final decoded = decodeWritingAiFeedback(
|
||||
_extractResponseContent(provider, response.body) ?? '',
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (content == null) return null;
|
||||
return decodeWritingAiFeedback(
|
||||
content,
|
||||
expectedLessonId: lessonId,
|
||||
);
|
||||
if (decoded != null || repairAttempt) return decoded;
|
||||
return writingFeedback(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
lessonId: lessonId,
|
||||
taskPrompt: taskPrompt,
|
||||
answer: answer,
|
||||
repairAttempt: true,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates one bounded A0 reinforcement lesson for a stable core target.
|
||||
/// It remains unpublished until local schema validation accepts it.
|
||||
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
||||
Future<GeneratedLesson?> generateAdaptiveLesson({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
@@ -488,21 +725,20 @@ Learner answer: $answer''';
|
||||
required String targetLabel,
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock ||
|
||||
!a0CoreItems.containsKey(targetItemId)) {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') return null;
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||||
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
||||
final instruction =
|
||||
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
|
||||
@@ -526,62 +762,70 @@ Learner answer: $answer''';
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'temperature': 0.2,
|
||||
'maxOutputTokens': 1200,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0.1,
|
||||
maxOutputTokens: 850,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
'temperature': 0.2,
|
||||
'max_tokens': 1200,
|
||||
},
|
||||
temperature: 0.1,
|
||||
maxTokens: 850,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||||
final decoded = decodeGeneratedLesson(
|
||||
_extractResponseContent(provider, response.body) ?? '',
|
||||
.timeout(const Duration(seconds: 45));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return null;
|
||||
}
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (content == null) return null;
|
||||
final lesson = decodeGeneratedLesson(
|
||||
content,
|
||||
expectedTargetItemId: targetItemId,
|
||||
);
|
||||
if (decoded != null || repairAttempt) return decoded;
|
||||
return generateAdaptiveLesson(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
targetLabel: targetLabel,
|
||||
repairAttempt: true,
|
||||
);
|
||||
if (lesson == null && !repairAttempt) {
|
||||
return generateAdaptiveLesson(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
targetLabel: targetLabel,
|
||||
repairAttempt: true,
|
||||
);
|
||||
}
|
||||
return lesson;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate audit request: it does not receive the generation prompt and
|
||||
/// can only approve/reject a previously client-validated lesson.
|
||||
/// Sends the entire generated lesson structure to an independent LLM audit.
|
||||
Future<bool> auditGeneratedLesson({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required GeneratedLesson lesson,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return false;
|
||||
final key = await _secureStorage.read(key: _keyName);
|
||||
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
|
||||
if (provider == AiProviderType.mock) return true;
|
||||
final key = await resolveApiKey();
|
||||
if (key == null ||
|
||||
key.isEmpty ||
|
||||
endpoint.trim().isEmpty ||
|
||||
model.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(
|
||||
provider == AiProviderType.gemini
|
||||
? '$base/models/$model:generateContent'
|
||||
: '$base/chat/completions',
|
||||
final uri = resolveEndpointUri(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
if (uri == null || uri.scheme != 'https') return false;
|
||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
|
||||
final lessonJson = jsonEncode({
|
||||
'lessonId': lesson.lessonId,
|
||||
'stageVersion': lesson.stageVersion,
|
||||
@@ -589,6 +833,7 @@ Learner answer: $answer''';
|
||||
'tasks': lesson.tasks
|
||||
.map(
|
||||
(task) => {
|
||||
'taskId': task.taskId,
|
||||
'skill': task.skill,
|
||||
'type': task.type,
|
||||
'prompt': task.prompt,
|
||||
@@ -625,20 +870,21 @@ Learner answer: $answer''';
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'temperature': 0,
|
||||
'maxOutputTokens': 120,
|
||||
'responseMimeType': 'application/json',
|
||||
},
|
||||
'generationConfig': _buildGeminiGenerationConfig(
|
||||
temperature: 0,
|
||||
maxOutputTokens: 200,
|
||||
responseMimeType: 'application/json',
|
||||
),
|
||||
}
|
||||
: {
|
||||
'model': model,
|
||||
'messages': [
|
||||
: _buildOpenAiPayload(
|
||||
uri: uri,
|
||||
model: model,
|
||||
messages: [
|
||||
{'role': 'user', 'content': instruction},
|
||||
],
|
||||
'temperature': 0,
|
||||
'max_tokens': 120,
|
||||
},
|
||||
temperature: 0,
|
||||
maxTokens: 200,
|
||||
),
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
@@ -671,14 +917,58 @@ Learner answer: $answer''';
|
||||
String? _extractResponseContent(AiProviderType provider, String body) {
|
||||
try {
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
String? rawContent;
|
||||
if (provider == AiProviderType.gemini) {
|
||||
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
|
||||
final candidateContent = candidate?['content'] as Map?;
|
||||
final parts = candidateContent?['parts'] as List?;
|
||||
return (parts?.firstOrNull as Map?)?['text'] as String?;
|
||||
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
|
||||
} else {
|
||||
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
||||
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
|
||||
choice?['text'] as String?;
|
||||
if (choiceContent != null && choiceContent.isNotEmpty) {
|
||||
rawContent = choiceContent;
|
||||
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
|
||||
rawContent = data['output_text'] as String;
|
||||
} else {
|
||||
final outputList = data['output'] as List?;
|
||||
if (outputList != null && outputList.isNotEmpty) {
|
||||
for (final item in outputList) {
|
||||
if (item is Map) {
|
||||
if (item['content'] is List) {
|
||||
for (final sub in item['content'] as List) {
|
||||
if (sub is Map && sub['text'] is String) {
|
||||
rawContent = sub['text'] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (item['text'] is String) {
|
||||
rawContent = item['text'] as String;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rawContent != null) break;
|
||||
}
|
||||
}
|
||||
if (rawContent == null) {
|
||||
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
|
||||
rawContent = data['response'] as String;
|
||||
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
|
||||
rawContent = data['text'] as String;
|
||||
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
|
||||
rawContent = data['content'] as String;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
||||
return (choice?['message'] as Map?)?['content'] as String?;
|
||||
if (rawContent == null) return null;
|
||||
var trimmed = rawContent.trim();
|
||||
if (trimmed.startsWith('```')) {
|
||||
trimmed = trimmed.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||||
trimmed = trimmed.replaceFirst(RegExp(r'\s*```$'), '');
|
||||
}
|
||||
return trimmed.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
@@ -687,7 +977,12 @@ Learner answer: $answer''';
|
||||
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
|
||||
try {
|
||||
final data = jsonDecode(raw) as Map<String, dynamic>;
|
||||
var sanitized = raw.trim();
|
||||
if (sanitized.startsWith('```')) {
|
||||
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||||
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
|
||||
}
|
||||
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
|
||||
final reply = data['reply'] as String?;
|
||||
final rawSlots = data['slots'];
|
||||
final rawEvidence = data['evidence'];
|
||||
@@ -716,8 +1011,10 @@ Learner answer: $answer''';
|
||||
if (item is! String || item.length > 240) return null;
|
||||
evidence.add(item);
|
||||
}
|
||||
final translation = data['translation'] as String?;
|
||||
return DialogueAiResponse(
|
||||
reply: reply.trim(),
|
||||
translation: translation?.trim(),
|
||||
slots: slots,
|
||||
evidence: evidence,
|
||||
suggestsComplete: suggestsComplete,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -9,6 +10,10 @@ import 'a0_core.dart';
|
||||
import 'generated_content.dart';
|
||||
import 'local_store.dart';
|
||||
import 'seed_courses.dart';
|
||||
import 'ai_config.dart';
|
||||
import 'ai_service.dart';
|
||||
import 'sherpa_stt_service.dart';
|
||||
import 'sync/sync_coordinator.dart';
|
||||
|
||||
class AppState extends ChangeNotifier {
|
||||
static const _storageKey = 'learning_state_v1';
|
||||
@@ -55,6 +60,12 @@ class AppState extends ChangeNotifier {
|
||||
AiProviderType aiProvider = AiProviderType.mock;
|
||||
String aiEndpoint = '';
|
||||
String aiModel = '';
|
||||
|
||||
AiConfigFile get aiConfig => AiConfigFile(
|
||||
provider: aiProvider,
|
||||
endpoint: aiEndpoint,
|
||||
model: aiModel,
|
||||
);
|
||||
String? cachedAdaptiveLessonRaw;
|
||||
DateTime? cachedAdaptiveLessonAuditedAt;
|
||||
String? cachedAdaptiveLessonAuditor;
|
||||
@@ -262,6 +273,23 @@ class AppState extends ChangeNotifier {
|
||||
|
||||
Future<void> load() async {
|
||||
try {
|
||||
unawaited(SherpaSttService.instance.initialize());
|
||||
final config = await AiConfigFile.loadFromAsset();
|
||||
if (config != null) {
|
||||
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
|
||||
AiService.instance.setFallbackApiKey(config.apiKey);
|
||||
}
|
||||
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
|
||||
aiEndpoint = config.endpoint;
|
||||
}
|
||||
if (aiModel.isEmpty && config.model.isNotEmpty) {
|
||||
aiModel = config.model;
|
||||
}
|
||||
if (aiProvider == AiProviderType.mock &&
|
||||
config.provider != AiProviderType.mock) {
|
||||
aiProvider = config.provider;
|
||||
}
|
||||
}
|
||||
String? raw;
|
||||
if (!_usesLegacyTestStore) {
|
||||
raw = await LocalSnapshotStore.instance.read();
|
||||
@@ -275,6 +303,19 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
if (raw != null) _restore(jsonDecode(raw) as Map<String, dynamic>);
|
||||
if (config != null) {
|
||||
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
|
||||
aiEndpoint = config.endpoint;
|
||||
}
|
||||
if (aiModel.isEmpty && config.model.isNotEmpty) {
|
||||
aiModel = config.model;
|
||||
}
|
||||
if (aiProvider == AiProviderType.mock &&
|
||||
config.provider != AiProviderType.mock &&
|
||||
raw == null) {
|
||||
aiProvider = config.provider;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// A corrupt local cache must never prevent access to offline lessons.
|
||||
} finally {
|
||||
@@ -283,6 +324,19 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> reloadAiConfigFromAsset() async {
|
||||
final config = await AiConfigFile.loadFromAsset();
|
||||
if (config == null) return false;
|
||||
aiProvider = config.provider;
|
||||
aiEndpoint = config.endpoint;
|
||||
aiModel = config.model;
|
||||
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
|
||||
AiService.instance.setFallbackApiKey(config.apiKey);
|
||||
}
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
void _restore(Map<String, dynamic> data) {
|
||||
onboardingComplete =
|
||||
data['onboardingComplete'] as bool? ?? onboardingComplete;
|
||||
@@ -538,6 +592,7 @@ class AppState extends ChangeNotifier {
|
||||
(turn) => DialogueTurn(
|
||||
text: turn['text'] as String,
|
||||
isLearner: turn['isLearner'] as bool? ?? false,
|
||||
translation: turn['translation'] as String?,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
@@ -710,6 +765,7 @@ class AppState extends ChangeNotifier {
|
||||
(turn) => {
|
||||
'text': turn.text,
|
||||
'isLearner': turn.isLearner,
|
||||
'translation': turn.translation,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@@ -762,6 +818,7 @@ class AppState extends ChangeNotifier {
|
||||
void finishOnboarding() {
|
||||
onboardingComplete = true;
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void setGoal(LearningGoal value) {
|
||||
@@ -925,6 +982,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void _resetLessonFlow() {
|
||||
@@ -953,6 +1011,7 @@ class AppState extends ChangeNotifier {
|
||||
completeSegment(activeLessonId, index);
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void completeReview(
|
||||
@@ -1013,6 +1072,7 @@ class AppState extends ChangeNotifier {
|
||||
rawAnswer: rawAnswer,
|
||||
);
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void reportReviewFailure(ReviewItem item) {
|
||||
|
||||
@@ -81,10 +81,12 @@ class DialogueAiResponse {
|
||||
required this.slots,
|
||||
required this.evidence,
|
||||
required this.suggestsComplete,
|
||||
this.translation,
|
||||
this.feedback,
|
||||
});
|
||||
|
||||
final String reply;
|
||||
final String? translation;
|
||||
final Map<String, String> slots;
|
||||
final List<String> evidence;
|
||||
final bool suggestsComplete;
|
||||
@@ -297,10 +299,27 @@ class MasteryItem {
|
||||
}
|
||||
|
||||
class DialogueTurn {
|
||||
const DialogueTurn({required this.text, required this.isLearner});
|
||||
const DialogueTurn({
|
||||
required this.text,
|
||||
required this.isLearner,
|
||||
this.translation,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool isLearner;
|
||||
final String? translation;
|
||||
|
||||
DialogueTurn copyWith({
|
||||
String? text,
|
||||
bool? isLearner,
|
||||
String? translation,
|
||||
}) {
|
||||
return DialogueTurn(
|
||||
text: text ?? this.text,
|
||||
isLearner: isLearner ?? this.isLearner,
|
||||
translation: translation ?? this.translation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ephemeral presentation data for a completed controlled dialogue. The
|
||||
|
||||
@@ -1071,10 +1071,12 @@ class LessonDialogue {
|
||||
required this.goal,
|
||||
required this.prompts,
|
||||
required this.hints,
|
||||
this.translations = const [],
|
||||
});
|
||||
final String goal;
|
||||
final List<String> prompts;
|
||||
final List<String> hints;
|
||||
final List<String> translations;
|
||||
}
|
||||
|
||||
const a0Dialogues = <String, LessonDialogue>{
|
||||
@@ -1092,6 +1094,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Hello!',
|
||||
'What’s your name?',
|
||||
],
|
||||
translations: [
|
||||
'嗨!我是 Mia。你叫什么名字?',
|
||||
'很高兴认识你。请说:Nice to meet you, too(我也很高兴认识你)。',
|
||||
'太棒了!再打一次招呼吧。',
|
||||
'现在请问我的名字!',
|
||||
],
|
||||
),
|
||||
'a0-02': LessonDialogue(
|
||||
goal: '介绍姓名并完整拼读名字',
|
||||
@@ -1102,6 +1110,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Now ask me my name!',
|
||||
],
|
||||
hints: ['My name is Alex.', 'A-L-E-X.', 'A-L-E-X.', 'What’s your name?'],
|
||||
translations: [
|
||||
'嗨!你叫什么名字?',
|
||||
'那个怎么拼写?',
|
||||
'谢谢。请再拼写一次。',
|
||||
'现在请问我的名字!',
|
||||
],
|
||||
),
|
||||
'a0-03': LessonDialogue(
|
||||
goal: '询问状态、回答并反问',
|
||||
@@ -1112,6 +1126,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Now say goodbye!',
|
||||
],
|
||||
hints: ['I’m good, thanks.', 'How are you?', 'I’m okay.', 'Bye!'],
|
||||
translations: [
|
||||
'嗨!你今天好吗?',
|
||||
'很好!请问我:How are you(你好吗)?',
|
||||
'我还好。请再说一次你的状态。',
|
||||
'现在请说再见!',
|
||||
],
|
||||
),
|
||||
'a0-04': LessonDialogue(
|
||||
goal: '报告一个虚拟三位号码并确认',
|
||||
@@ -1127,6 +1147,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'One-three-eight.',
|
||||
'What’s your phone number?',
|
||||
],
|
||||
translations: [
|
||||
'你的电话号码是多少?可以使用一个虚拟的三位数字。',
|
||||
'我听到了 1-3-8。对吗?',
|
||||
'请再说一遍这三个数字。',
|
||||
'现在请问我的电话号码!',
|
||||
],
|
||||
),
|
||||
'a0-05': LessonDialogue(
|
||||
goal: '询问并说出一个物品',
|
||||
@@ -1137,6 +1163,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Great! Say one more object.',
|
||||
],
|
||||
hints: ['It’s a pen.', 'It’s a pen.', 'What’s this?', 'It’s a book.'],
|
||||
translations: [
|
||||
'这是什么?这是一支笔。',
|
||||
'现在请说:It’s a pen(这是一支笔)。',
|
||||
'请问我:What’s this(这是什么)?',
|
||||
'太棒了!再说一个物品吧。',
|
||||
],
|
||||
),
|
||||
'a0-06': LessonDialogue(
|
||||
goal: '说来自哪里并反问',
|
||||
@@ -1152,6 +1184,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'I’m from Hong Kong.',
|
||||
'Bye!',
|
||||
],
|
||||
translations: [
|
||||
'你来自哪里?',
|
||||
'很好!请问我:Where are you from(你来自哪里)?',
|
||||
'请再说一次你来自哪里。',
|
||||
'请说再见!',
|
||||
],
|
||||
),
|
||||
'a0-07': LessonDialogue(
|
||||
goal: '介绍一位家人或朋友',
|
||||
@@ -1167,6 +1205,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Who is this?',
|
||||
'This is my friend.',
|
||||
],
|
||||
translations: [
|
||||
'这是谁?',
|
||||
'很好。请说:This is my mother(这是我的妈妈)。',
|
||||
'请问我:Who is this(这是谁)?',
|
||||
'请再介绍一个人。',
|
||||
],
|
||||
),
|
||||
'a0-08': LessonDialogue(
|
||||
goal: '说明星期或整点',
|
||||
@@ -1182,6 +1226,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'It’s three o’clock.',
|
||||
'What day is it?',
|
||||
],
|
||||
translations: [
|
||||
'今天星期几?',
|
||||
'现在几点了?',
|
||||
'请说一个完整的时间句子。',
|
||||
'现在请问我今天是星期几!',
|
||||
],
|
||||
),
|
||||
'a0-09': LessonDialogue(
|
||||
goal: '表达喜好、回答和反问',
|
||||
@@ -1192,6 +1242,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'Say your like one more time.',
|
||||
],
|
||||
hints: ['I like tea.', 'Yes, I do.', 'Do you like tea?', 'I like tea.'],
|
||||
translations: [
|
||||
'你喜欢什么?',
|
||||
'你喜欢茶吗?',
|
||||
'现在请问我喜欢什么。',
|
||||
'请再说一次你的喜好。',
|
||||
],
|
||||
),
|
||||
'a0-10': LessonDialogue(
|
||||
goal: '请求重复或放慢语速,并完成基础沟通',
|
||||
@@ -1207,6 +1263,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
||||
'What’s your name?',
|
||||
'I like tea.',
|
||||
],
|
||||
translations: [
|
||||
'请说:Please say that again(请再说一遍)。',
|
||||
'请说:Please speak slowly(请说慢一点)。',
|
||||
'现在请问我的名字或我来自哪里。',
|
||||
'请说一件你喜欢的事物。',
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
@@ -1215,31 +1277,37 @@ const a0SegmentDialogues = <String, LessonDialogue>{
|
||||
goal: '听辨并说出 0 到 5',
|
||||
prompts: ['Say zero, one, two.', 'Now say three, four, five.'],
|
||||
hints: ['zero, one, two', 'three, four, five'],
|
||||
translations: ['请说 zero, one, two(0,1,2)。', '现在请说 three, four, five(3,4,5)。'],
|
||||
),
|
||||
'a0-04-b': LessonDialogue(
|
||||
goal: '听辨 6 到 10 并认识 phone',
|
||||
prompts: ['Say six, seven, eight.', 'What is this? Say: It is a phone.'],
|
||||
hints: ['six, seven, eight', 'It is a phone.'],
|
||||
translations: ['请说 six, seven, eight(6,7,8)。', '这是什么?请说:It is a phone(这是一部手机)。'],
|
||||
),
|
||||
'a0-04-c': LessonDialogue(
|
||||
goal: '询问并报告三位号码',
|
||||
prompts: ['What is your phone number?', 'Say a three-digit number again.'],
|
||||
hints: ['My number is one-three-eight.', 'one-three-eight'],
|
||||
translations: ['你的电话号码是多少?', '请再说一次三位数字。'],
|
||||
),
|
||||
'a0-08-a': LessonDialogue(
|
||||
goal: '询问并说出星期一到三',
|
||||
prompts: ['What day is it?', 'Say Monday, Tuesday, or Wednesday.'],
|
||||
hints: ['What day is it?', 'It is Monday.'],
|
||||
translations: ['今天星期几?', '请说 Monday, Tuesday, 或 Wednesday(周一、周二或周三)。'],
|
||||
),
|
||||
'a0-08-b': LessonDialogue(
|
||||
goal: '说出星期四到日',
|
||||
prompts: ['What day is it?', 'Say Thursday, Friday, Saturday, or Sunday.'],
|
||||
hints: ['What day is it?', 'It is Friday.'],
|
||||
translations: ['今天星期几?', '请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。'],
|
||||
),
|
||||
'a0-08-c': LessonDialogue(
|
||||
goal: '询问并说出整点',
|
||||
prompts: ['What time is it?', 'Say one full time sentence.'],
|
||||
hints: ['What time is it?', 'It is three o’clock.'],
|
||||
translations: ['现在几点了?', '请说一个完整的时间句子。'],
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
|
||||
|
||||
class SherpaSttService {
|
||||
SherpaSttService._();
|
||||
static final instance = SherpaSttService._();
|
||||
|
||||
sherpa_onnx.OfflineRecognizer? _recognizer;
|
||||
bool _isInitialized = false;
|
||||
bool _isInitializing = false;
|
||||
|
||||
bool get isReady => _isInitialized && _recognizer != null;
|
||||
|
||||
/// Initializes SenseVoice-Small ONNX bindings and unpacks bundled model assets to local disk if needed.
|
||||
Future<bool> initialize({String? nativeLibDir}) async {
|
||||
if (_isInitialized) return true;
|
||||
if (_isInitializing) return false;
|
||||
_isInitializing = true;
|
||||
|
||||
try {
|
||||
try {
|
||||
sherpa_onnx.initBindings(nativeLibDir);
|
||||
} catch (e) {
|
||||
debugPrint('[SherpaSttService] initBindings warning: $e');
|
||||
}
|
||||
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
final modelDir = Directory('${docDir.path}/sense_voice_models');
|
||||
if (!await modelDir.exists()) {
|
||||
await modelDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final modelFiles = [
|
||||
'model.int8.onnx',
|
||||
'tokens.txt',
|
||||
];
|
||||
|
||||
for (final filename in modelFiles) {
|
||||
final targetFile = File('${modelDir.path}/$filename');
|
||||
if (!await targetFile.exists() || (await targetFile.length()) == 0) {
|
||||
final ByteData data = await rootBundle.load('assets/models/sense_voice/$filename');
|
||||
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
await targetFile.writeAsBytes(bytes, flush: true);
|
||||
}
|
||||
}
|
||||
|
||||
final modelConfig = sherpa_onnx.OfflineModelConfig(
|
||||
senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
|
||||
model: '${modelDir.path}/model.int8.onnx',
|
||||
language: 'auto',
|
||||
useInverseTextNormalization: true,
|
||||
),
|
||||
tokens: '${modelDir.path}/tokens.txt',
|
||||
numThreads: 2,
|
||||
debug: false,
|
||||
);
|
||||
|
||||
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
|
||||
model: modelConfig,
|
||||
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
|
||||
);
|
||||
|
||||
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
|
||||
_isInitialized = true;
|
||||
_isInitializing = false;
|
||||
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully.');
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
debugPrint('[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack');
|
||||
_isInitializing = false;
|
||||
_isInitialized = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcribes a local 16kHz mono WAV audio file using SenseVoice-Small.
|
||||
Future<String?> transcribeWav(String wavPath) async {
|
||||
try {
|
||||
if (!_isInitialized) {
|
||||
final ready = await initialize();
|
||||
if (!ready || _recognizer == null) return null;
|
||||
}
|
||||
|
||||
final file = File(wavPath);
|
||||
if (!await file.exists()) {
|
||||
debugPrint('[SherpaSttService] Audio file does not exist: $wavPath');
|
||||
return null;
|
||||
}
|
||||
|
||||
final wave = sherpa_onnx.readWave(wavPath);
|
||||
if (wave.samples.isEmpty) {
|
||||
debugPrint('[SherpaSttService] Read 0 wave samples from: $wavPath');
|
||||
return null;
|
||||
}
|
||||
|
||||
final stream = _recognizer!.createStream();
|
||||
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
|
||||
_recognizer!.decode(stream);
|
||||
final result = _recognizer!.getResult(stream);
|
||||
stream.free();
|
||||
|
||||
final rawText = result.text.trim();
|
||||
if (rawText.isEmpty) return null;
|
||||
|
||||
return _cleanText(rawText);
|
||||
} catch (e) {
|
||||
debugPrint('[SherpaSttService] Transcribe error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans and formats raw recognized SenseVoice text (strips emotion/event tags, normalizes casing).
|
||||
String _cleanText(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
// Strip SenseVoice special tags like <|zh|>, <|en|>, <|NEUTRAL|>, <|HAPPY|>, <|Speech|>, <|withitn|>, <|woitn|>, etc.
|
||||
var cleaned = text.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '').trim();
|
||||
if (cleaned.isEmpty) return cleaned;
|
||||
// Normalize consecutive spaces
|
||||
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
// Capitalize first character if it's a letter
|
||||
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
|
||||
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
try {
|
||||
_recognizer?.free();
|
||||
} catch (_) {}
|
||||
_recognizer = null;
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import 'sync_merger.dart';
|
||||
import 'sync_models.dart';
|
||||
import 'sync_service.dart';
|
||||
|
||||
/// 跨端学习进度同步协调器
|
||||
class SyncCoordinator extends ChangeNotifier {
|
||||
static const _prefKey = 'sync_config_v1';
|
||||
|
||||
static final SyncCoordinator instance = SyncCoordinator._();
|
||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||
|
||||
@visibleForTesting
|
||||
factory SyncCoordinator.createForTesting({SyncService? service}) {
|
||||
return SyncCoordinator._(service: service);
|
||||
}
|
||||
|
||||
final SyncService _service;
|
||||
|
||||
SyncConfig _config = const SyncConfig();
|
||||
SyncState _state = SyncState.idle;
|
||||
String? _errorMessage;
|
||||
bool _isInitialized = false;
|
||||
DateTime? _lastSyncAttempt;
|
||||
|
||||
SyncConfig get config => _config;
|
||||
SyncState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get isInitialized => _isInitialized;
|
||||
bool get isLoggedIn => _config.isLoggedIn;
|
||||
String? get username => _config.username;
|
||||
String get serverUrl => _config.serverUrl;
|
||||
DateTime? get lastSyncTime => _config.lastSyncTime;
|
||||
bool get autoSyncEnabled => _config.autoSyncEnabled;
|
||||
|
||||
/// 初始化并从本地存储加载配置
|
||||
Future<void> init() async {
|
||||
if (_isInitialized) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_prefKey);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
_config = SyncConfig.fromJson(map);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] init error: $e');
|
||||
} finally {
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveConfig() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_prefKey, jsonEncode(_config.toJson()));
|
||||
} catch (e) {
|
||||
debugPrint('[SyncCoordinator] save config error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新服务器地址
|
||||
Future<void> updateServerUrl(String newUrl) async {
|
||||
_config = _config.copyWith(serverUrl: newUrl.trim());
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 切换自动同步开关
|
||||
Future<void> setAutoSyncEnabled(bool enabled) async {
|
||||
_config = _config.copyWith(autoSyncEnabled: enabled);
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 注册新用户并自动保存登录凭证
|
||||
Future<bool> register({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final auth = await _service.register(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
userId: auth.userId,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录已有账号
|
||||
Future<bool> login({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final auth = await _service.login(
|
||||
serverUrl: serverUrl,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_config = _config.copyWith(
|
||||
serverUrl: serverUrl.trim(),
|
||||
token: auth.token,
|
||||
username: auth.username,
|
||||
userId: auth.userId,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出并清除本地登录凭证
|
||||
Future<void> logout() async {
|
||||
_config = SyncConfig(
|
||||
serverUrl: _config.serverUrl,
|
||||
autoSyncEnabled: _config.autoSyncEnabled,
|
||||
);
|
||||
_state = SyncState.idle;
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 测试与服务器的连接
|
||||
Future<bool> testConnection([String? customUrl]) async {
|
||||
return _service.testConnection(customUrl ?? _config.serverUrl);
|
||||
}
|
||||
|
||||
/// 立即触发一次全量/增量双向同步 (Pull -> Merge -> Push)
|
||||
Future<bool> syncNow(AppState appState) async {
|
||||
if (!isLoggedIn) {
|
||||
_errorMessage = '未登录同步账号';
|
||||
_state = SyncState.error;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = SyncState.syncing;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final sUrl = _config.serverUrl;
|
||||
final token = _config.token!;
|
||||
|
||||
// 1. 增量拉取云端数据
|
||||
final pullResp = await _service.pull(
|
||||
serverUrl: sUrl,
|
||||
token: token,
|
||||
since: _config.lastSyncTime,
|
||||
);
|
||||
|
||||
// 2. 本地智能合并 (CRDT/LWW)
|
||||
final changed = SyncMerger.applyPullResponse(appState, pullResp);
|
||||
if (changed) {
|
||||
appState.notifyListeners();
|
||||
}
|
||||
|
||||
// 3. 构建本地增量数据并推送至云端
|
||||
final pushReq = SyncMerger.buildPushRequest(appState);
|
||||
final sTime = await _service.push(
|
||||
serverUrl: sUrl,
|
||||
token: token,
|
||||
request: pushReq,
|
||||
);
|
||||
|
||||
// 4. 更新同步时间戳并保存
|
||||
final syncSuccessTime = DateTime.tryParse(sTime) ?? DateTime.now();
|
||||
_config = _config.copyWith(lastSyncTime: syncSuccessTime);
|
||||
_state = SyncState.success;
|
||||
_errorMessage = null;
|
||||
await _saveConfig();
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e is HttpException ? e.message : e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 满足条件时在后台静默触发同步
|
||||
void triggerBackgroundSync(AppState appState) {
|
||||
if (!autoSyncEnabled || !isLoggedIn || _state == SyncState.syncing) {
|
||||
return;
|
||||
}
|
||||
// 简单防抖:距离上次同步尝试不足 3 秒则跳过
|
||||
final now = DateTime.now();
|
||||
if (_lastSyncAttempt != null &&
|
||||
now.difference(_lastSyncAttempt!).inSeconds < 3) {
|
||||
return;
|
||||
}
|
||||
_lastSyncAttempt = now;
|
||||
|
||||
// 异步执行,不阻塞主流程
|
||||
unawaited(syncNow(appState));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import '../models.dart';
|
||||
import '../app_state.dart';
|
||||
import 'sync_models.dart';
|
||||
|
||||
/// 负责本地 AppState 与云端 DTO 之间的序列化与智能合并
|
||||
class SyncMerger {
|
||||
/// 将本地 AppState 打包为增量推送请求
|
||||
static SyncPushRequest buildPushRequest(AppState state, {String? deviceName}) {
|
||||
final nowIso = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
final progressPayload = SyncProgressPayload(
|
||||
activeLessonId: state.activeLessonId,
|
||||
completedLessonIds: state.completedLessonIds.toList(),
|
||||
completedSegmentIds: state.completedSegmentIds.toList(),
|
||||
activeStep: state.lessonStep.name,
|
||||
streakDays: state.completedLessons > 0 ? 1 : 0,
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
final masteryUpdates = state.mastery.values.map((m) {
|
||||
// 查找对应复习到期时间
|
||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||
final dueAt = review?.dueAt.toUtc().toIso8601String() ??
|
||||
DateTime.now().toUtc().toIso8601String();
|
||||
final attempts = review?.attempts ?? 0;
|
||||
final successfulReviews = review?.successfulReviews ?? 0;
|
||||
|
||||
return SyncMasteryItemPayload(
|
||||
itemId: m.id,
|
||||
checkpoint: m.checkpoint,
|
||||
status: m.status.name,
|
||||
dueAt: dueAt,
|
||||
successfulReviews: successfulReviews,
|
||||
attempts: attempts,
|
||||
payload: {
|
||||
'label': m.label,
|
||||
'evidence': m.evidence.map((e) => e.name).toList(),
|
||||
'needsReview': m.needsReview,
|
||||
if (m.firstTaughtAt != null)
|
||||
'firstTaughtAt': m.firstTaughtAt!.toUtc().toIso8601String(),
|
||||
},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final profilePayload = SyncProfilePayload(
|
||||
onboardingComplete: state.onboardingComplete,
|
||||
goal: state.goal.name,
|
||||
placement: state.placement.name,
|
||||
dailyMinutes: state.dailyMinutes,
|
||||
showChineseHints: state.showChineseHints,
|
||||
aiEndpoint: state.aiEndpoint,
|
||||
aiModel: state.aiModel,
|
||||
aiProvider: state.aiProvider.name,
|
||||
settingsPayload: {
|
||||
'keepRecordings': state.keepRecordings,
|
||||
},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
return SyncPushRequest(
|
||||
clientTime: nowIso,
|
||||
deviceName: deviceName,
|
||||
progress: progressPayload,
|
||||
masteryUpdates: masteryUpdates,
|
||||
profile: profilePayload,
|
||||
);
|
||||
}
|
||||
|
||||
/// 将云端拉取的进度合并到本地 AppState
|
||||
static bool applyPullResponse(AppState state, SyncPullResponse response) {
|
||||
var changed = false;
|
||||
|
||||
// 1. 合并课程关卡 (Union)
|
||||
if (response.progress != null) {
|
||||
final p = response.progress!;
|
||||
for (final id in p.completedLessonIds) {
|
||||
if (!state.completedLessonIds.contains(id)) {
|
||||
state.completedLessonIds.add(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (final sid in p.completedSegmentIds) {
|
||||
if (!state.completedSegmentIds.contains(sid)) {
|
||||
state.completedSegmentIds.add(sid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (state.completedLessonIds.length != state.completedLessons) {
|
||||
state.completedLessons = state.completedLessonIds.length;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 合并复习掌握项 (Max Checkpoint)
|
||||
for (final m in response.masteryUpdates) {
|
||||
final local = state.mastery[m.itemId];
|
||||
if (local == null) {
|
||||
// 本地没有,直接添加
|
||||
final status = _parseMasteryStatus(m.status);
|
||||
final evidence = (m.payload['evidence'] as List? ?? [])
|
||||
.whereType<String>()
|
||||
.map(_parseEvidenceKind)
|
||||
.toList();
|
||||
|
||||
state.mastery[m.itemId] = MasteryItem(
|
||||
id: m.itemId,
|
||||
label: m.payload['label'] as String? ?? m.itemId,
|
||||
status: status,
|
||||
evidence: evidence,
|
||||
needsReview: m.payload['needsReview'] as bool? ?? false,
|
||||
checkpoint: m.checkpoint,
|
||||
firstTaughtAt: m.payload['firstTaughtAt'] != null
|
||||
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
|
||||
: null,
|
||||
);
|
||||
changed = true;
|
||||
} else if (m.checkpoint > local.checkpoint) {
|
||||
// 云端 Checkpoint 更高,升级本地状态
|
||||
final status = _parseMasteryStatus(m.status);
|
||||
state.mastery[m.itemId] = local.copyWith(
|
||||
checkpoint: m.checkpoint,
|
||||
status: status,
|
||||
needsReview: m.payload['needsReview'] as bool? ?? local.needsReview,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 合并用户偏好设置 (按需合并)
|
||||
if (response.profile != null) {
|
||||
final prof = response.profile!;
|
||||
if (!state.onboardingComplete && prof.onboardingComplete) {
|
||||
state.onboardingComplete = true;
|
||||
changed = true;
|
||||
}
|
||||
if (prof.goal.isNotEmpty) {
|
||||
final g = _parseGoal(prof.goal);
|
||||
if (g != state.goal) {
|
||||
state.goal = g;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
static MasteryStatus _parseMasteryStatus(String str) {
|
||||
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
|
||||
MasteryStatus.newItem;
|
||||
}
|
||||
|
||||
static EvidenceKind _parseEvidenceKind(String str) {
|
||||
return EvidenceKind.values.where((e) => e.name == str).firstOrNull ??
|
||||
EvidenceKind.pending;
|
||||
}
|
||||
|
||||
static LearningGoal _parseGoal(String str) {
|
||||
return LearningGoal.values.where((e) => e.name == str).firstOrNull ??
|
||||
LearningGoal.dailyLife;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
|
||||
/// 同步状态枚举
|
||||
enum SyncState {
|
||||
idle,
|
||||
syncing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
|
||||
/// 客户端同步配置与认证状态
|
||||
class SyncConfig {
|
||||
final String serverUrl;
|
||||
final String? token;
|
||||
final String? username;
|
||||
final String? userId;
|
||||
final DateTime? lastSyncTime;
|
||||
final bool autoSyncEnabled;
|
||||
|
||||
const SyncConfig({
|
||||
this.serverUrl = 'https://syncenglish.slcydia.fun',
|
||||
this.token,
|
||||
this.username,
|
||||
this.userId,
|
||||
this.lastSyncTime,
|
||||
this.autoSyncEnabled = true,
|
||||
});
|
||||
|
||||
bool get isLoggedIn => token != null && token!.isNotEmpty;
|
||||
|
||||
SyncConfig copyWith({
|
||||
String? serverUrl,
|
||||
String? token,
|
||||
String? username,
|
||||
String? userId,
|
||||
DateTime? lastSyncTime,
|
||||
bool? autoSyncEnabled,
|
||||
}) => SyncConfig(
|
||||
serverUrl: serverUrl ?? this.serverUrl,
|
||||
token: token ?? this.token,
|
||||
username: username ?? this.username,
|
||||
userId: userId ?? this.userId,
|
||||
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
|
||||
autoSyncEnabled: autoSyncEnabled ?? this.autoSyncEnabled,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'serverUrl': serverUrl,
|
||||
'token': token,
|
||||
'username': username,
|
||||
'userId': userId,
|
||||
'lastSyncTime': lastSyncTime?.toUtc().toIso8601String(),
|
||||
'autoSyncEnabled': autoSyncEnabled,
|
||||
};
|
||||
|
||||
factory SyncConfig.fromJson(Map<String, dynamic> json) => SyncConfig(
|
||||
serverUrl: json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
token: json['token'] as String?,
|
||||
username: json['username'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
lastSyncTime: json['lastSyncTime'] != null
|
||||
? DateTime.tryParse(json['lastSyncTime'] as String)
|
||||
: null,
|
||||
autoSyncEnabled: json['autoSyncEnabled'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 认证响应
|
||||
class SyncAuthResponse {
|
||||
final String userId;
|
||||
final String username;
|
||||
final String token;
|
||||
final int expiresIn;
|
||||
|
||||
const SyncAuthResponse({
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.token,
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) => SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
expiresIn: json['expires_in'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 课程关卡进度 DTO
|
||||
class SyncProgressPayload {
|
||||
final String activeLessonId;
|
||||
final List<String> completedLessonIds;
|
||||
final List<String> completedSegmentIds;
|
||||
final String activeStep;
|
||||
final int streakDays;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncProgressPayload({
|
||||
required this.activeLessonId,
|
||||
required this.completedLessonIds,
|
||||
required this.completedSegmentIds,
|
||||
this.activeStep = 'preview',
|
||||
this.streakDays = 0,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'active_lesson_id': activeLessonId,
|
||||
'completed_lesson_ids': completedLessonIds,
|
||||
'completed_segment_ids': completedSegmentIds,
|
||||
'active_step': activeStep,
|
||||
'streak_days': streakDays,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) => SyncProgressPayload(
|
||||
activeLessonId: json['active_lesson_id'] as String? ?? 'a0-01',
|
||||
completedLessonIds: (json['completed_lesson_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
activeStep: json['active_step'] as String? ?? 'preview',
|
||||
streakDays: json['streak_days'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 艾宾浩斯与掌握度 DTO
|
||||
class SyncMasteryItemPayload {
|
||||
final String itemId;
|
||||
final int checkpoint;
|
||||
final String status;
|
||||
final String dueAt;
|
||||
final int successfulReviews;
|
||||
final int attempts;
|
||||
final Map<String, dynamic> payload;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncMasteryItemPayload({
|
||||
required this.itemId,
|
||||
required this.checkpoint,
|
||||
required this.status,
|
||||
required this.dueAt,
|
||||
this.successfulReviews = 0,
|
||||
this.attempts = 0,
|
||||
this.payload = const {},
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'item_id': itemId,
|
||||
'checkpoint': checkpoint,
|
||||
'status': status,
|
||||
'due_at': dueAt,
|
||||
'successful_reviews': successfulReviews,
|
||||
'attempts': attempts,
|
||||
'payload': payload,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) => SyncMasteryItemPayload(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
checkpoint: json['checkpoint'] as int? ?? 0,
|
||||
status: json['status'] as String? ?? 'learning',
|
||||
dueAt: json['due_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
successfulReviews: json['successful_reviews'] as int? ?? 0,
|
||||
attempts: json['attempts'] as int? ?? 0,
|
||||
payload: json['payload'] is Map<String, dynamic>
|
||||
? json['payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户画像与偏好 DTO
|
||||
class SyncProfilePayload {
|
||||
final bool onboardingComplete;
|
||||
final String goal;
|
||||
final String placement;
|
||||
final int dailyMinutes;
|
||||
final bool showChineseHints;
|
||||
final String aiEndpoint;
|
||||
final String aiModel;
|
||||
final String aiProvider;
|
||||
final Map<String, dynamic> settingsPayload;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncProfilePayload({
|
||||
this.onboardingComplete = true,
|
||||
this.goal = 'travel',
|
||||
this.placement = 'A0',
|
||||
this.dailyMinutes = 20,
|
||||
this.showChineseHints = true,
|
||||
this.aiEndpoint = '',
|
||||
this.aiModel = '',
|
||||
this.aiProvider = '',
|
||||
this.settingsPayload = const {},
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'onboarding_complete': onboardingComplete,
|
||||
'goal': goal,
|
||||
'placement': placement,
|
||||
'daily_minutes': dailyMinutes,
|
||||
'show_chinese_hints': showChineseHints,
|
||||
'ai_endpoint': aiEndpoint,
|
||||
'ai_model': aiModel,
|
||||
'ai_provider': aiProvider,
|
||||
'settings_payload': settingsPayload,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) => SyncProfilePayload(
|
||||
onboardingComplete: json['onboarding_complete'] as bool? ?? true,
|
||||
goal: json['goal'] as String? ?? 'travel',
|
||||
placement: json['placement'] as String? ?? 'A0',
|
||||
dailyMinutes: json['daily_minutes'] as int? ?? 20,
|
||||
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
|
||||
aiEndpoint: json['ai_endpoint'] as String? ?? '',
|
||||
aiModel: json['ai_model'] as String? ?? '',
|
||||
aiProvider: json['ai_provider'] as String? ?? '',
|
||||
settingsPayload: json['settings_payload'] is Map<String, dynamic>
|
||||
? json['settings_payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 增量推送请求
|
||||
class SyncPushRequest {
|
||||
final String clientTime;
|
||||
final String? deviceName;
|
||||
final SyncProgressPayload? progress;
|
||||
final List<SyncMasteryItemPayload> masteryUpdates;
|
||||
final SyncProfilePayload? profile;
|
||||
|
||||
const SyncPushRequest({
|
||||
required this.clientTime,
|
||||
this.deviceName,
|
||||
this.progress,
|
||||
this.masteryUpdates = const [],
|
||||
this.profile,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'client_time': clientTime,
|
||||
if (deviceName != null) 'device_name': deviceName,
|
||||
if (progress != null) 'progress': progress!.toJson(),
|
||||
'mastery_updates': masteryUpdates.map((m) => m.toJson()).toList(),
|
||||
if (profile != null) 'profile': profile!.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 增量拉取响应
|
||||
class SyncPullResponse {
|
||||
final String serverTime;
|
||||
final SyncProgressPayload? progress;
|
||||
final List<SyncMasteryItemPayload> masteryUpdates;
|
||||
final SyncProfilePayload? profile;
|
||||
|
||||
const SyncPullResponse({
|
||||
required this.serverTime,
|
||||
this.progress,
|
||||
this.masteryUpdates = const [],
|
||||
this.profile,
|
||||
});
|
||||
|
||||
factory SyncPullResponse.fromJson(Map<String, dynamic> json) {
|
||||
final data = json['data'] is Map<String, dynamic>
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return SyncPullResponse(
|
||||
serverTime: data['server_time'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
progress: data['progress'] != null
|
||||
? SyncProgressPayload.fromJson(data['progress'] as Map<String, dynamic>)
|
||||
: null,
|
||||
masteryUpdates: (data['mastery_updates'] as List? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => SyncMasteryItemPayload.fromJson(e))
|
||||
.toList(),
|
||||
profile: data['profile'] != null
|
||||
? SyncProfilePayload.fromJson(data['profile'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'sync_models.dart';
|
||||
|
||||
/// 负责与自建同步服务端通信的 HTTP 服务
|
||||
class SyncService {
|
||||
final http.Client _client;
|
||||
|
||||
SyncService({http.Client? client}) : _client = client ?? http.Client();
|
||||
|
||||
String _cleanUrl(String url) {
|
||||
var u = url.trim();
|
||||
if (u.endsWith('/')) {
|
||||
u = u.substring(0, u.length - 1);
|
||||
}
|
||||
if (!u.startsWith('http://') && !u.startsWith('https://')) {
|
||||
u = 'http://$u';
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
/// 测试与服务器的连通性
|
||||
Future<bool> testConnection(String serverUrl) async {
|
||||
try {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/health');
|
||||
final resp = await _client.get(uri).timeout(const Duration(seconds: 5));
|
||||
return resp.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册新用户
|
||||
Future<SyncAuthResponse> register({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? deviceName,
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/register');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录已有用户
|
||||
Future<SyncAuthResponse> login({
|
||||
required String serverUrl,
|
||||
required String username,
|
||||
required String password,
|
||||
String? deviceName,
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/login');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 增量拉取云端学习进度
|
||||
Future<SyncPullResponse> pull({
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
DateTime? since,
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
var urlStr = '$base/api/v1/sync/pull';
|
||||
if (since != null) {
|
||||
urlStr += '?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
|
||||
}
|
||||
final uri = Uri.parse(urlStr);
|
||||
|
||||
final resp = await _client.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncPullResponse.fromJson(data);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 推送本地增量进度到云端
|
||||
Future<String> push({
|
||||
required String serverUrl,
|
||||
required String token,
|
||||
required SyncPushRequest request,
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/sync/push');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
final sTime = data['data']?['server_time'] as String?;
|
||||
return sTime ?? DateTime.now().toUtc().toIso8601String();
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
String _getPlatformDeviceName() {
|
||||
if (Platform.isAndroid) return 'Android 客户端';
|
||||
if (Platform.isIOS) return 'iPhone 客户端';
|
||||
if (Platform.isMacOS) return 'macOS 桌面端';
|
||||
if (Platform.isWindows) return 'Windows 桌面端';
|
||||
if (Platform.isLinux) return 'Linux 客户端';
|
||||
return 'SpeakSprout Client';
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
@@ -16,16 +18,60 @@ class VoiceService {
|
||||
final SpeechToText _stt = SpeechToText();
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
StreamSubscription<void>? _playerSub;
|
||||
bool _speechReady = false;
|
||||
bool _ttsInitialized = false;
|
||||
|
||||
Future<void> speak(String text, {bool slow = false}) async {
|
||||
await _tts.stop();
|
||||
await _tts.setLanguage('en-US');
|
||||
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
||||
await _tts.speak(text);
|
||||
void Function(String status)? _statusListener;
|
||||
void Function(String error)? _errorListener;
|
||||
|
||||
Future<void> _initTts() async {
|
||||
if (_ttsInitialized) return;
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
await _tts.setIosAudioCategory(
|
||||
IosTextToSpeechAudioCategory.playback,
|
||||
[
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
||||
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
||||
],
|
||||
);
|
||||
}
|
||||
await _tts.setVolume(1.0);
|
||||
await _tts.setPitch(1.0);
|
||||
_ttsInitialized = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> stopSpeaking() => _tts.stop();
|
||||
Future<void> speak(String text, {bool slow = false}) async {
|
||||
try {
|
||||
await _initTts();
|
||||
await _tts.stop();
|
||||
try {
|
||||
final isAvailable = await _tts.isLanguageAvailable('en-US');
|
||||
if (isAvailable == true) {
|
||||
await _tts.setLanguage('en-US');
|
||||
} else {
|
||||
await _tts.setLanguage('en');
|
||||
}
|
||||
} catch (_) {
|
||||
try {
|
||||
await _tts.setLanguage('en-US');
|
||||
} catch (_) {}
|
||||
}
|
||||
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
||||
await _tts.speak(text);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> stopSpeaking() async {
|
||||
try {
|
||||
await _tts.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> hasRecordPermission() => _recorder.hasPermission();
|
||||
|
||||
Future<bool> startRecording() async {
|
||||
if (!await _recorder.hasPermission()) return false;
|
||||
@@ -34,20 +80,38 @@ class VoiceService {
|
||||
if (!await recordings.exists()) await recordings.create(recursive: true);
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
await _recorder.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||
path: '${recordings.path}/practice_$timestamp.m4a',
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
noiseSuppress: true,
|
||||
echoCancel: true,
|
||||
autoGain: true,
|
||||
),
|
||||
path: '${recordings.path}/practice_$timestamp.wav',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<String?> stopRecording() => _recorder.stop();
|
||||
|
||||
Future<void> playRecording(String path) async {
|
||||
await _player.stop();
|
||||
Future<void> playRecording(String path, {VoidCallback? onComplete}) async {
|
||||
await stopRecordingPlayback();
|
||||
if (onComplete != null) {
|
||||
_playerSub = _player.onPlayerComplete.listen((_) {
|
||||
_playerSub?.cancel();
|
||||
_playerSub = null;
|
||||
onComplete();
|
||||
});
|
||||
}
|
||||
await _player.play(DeviceFileSource(path));
|
||||
}
|
||||
|
||||
Future<void> stopRecordingPlayback() => _player.stop();
|
||||
Future<void> stopRecordingPlayback() async {
|
||||
await _playerSub?.cancel();
|
||||
_playerSub = null;
|
||||
await _player.stop();
|
||||
}
|
||||
|
||||
Future<void> deleteRecording(String? path) async {
|
||||
if (path == null || path.isEmpty) return;
|
||||
@@ -75,7 +139,7 @@ class VoiceService {
|
||||
if (!await recordings.exists()) return const [];
|
||||
final files = await recordings
|
||||
.list()
|
||||
.where((item) => item is File && item.path.endsWith('.m4a'))
|
||||
.where((item) => item is File && (item.path.endsWith('.wav') || item.path.endsWith('.m4a')))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
files.sort((left, right) => right.path.compareTo(left.path));
|
||||
@@ -87,27 +151,70 @@ class VoiceService {
|
||||
await _player.dispose();
|
||||
}
|
||||
|
||||
Future<bool> initializeSpeech() async {
|
||||
_speechReady = await _stt.initialize();
|
||||
return _speechReady;
|
||||
Future<bool> initializeSpeech({
|
||||
void Function(String status)? onStatus,
|
||||
void Function(String error)? onError,
|
||||
}) async {
|
||||
_statusListener = onStatus;
|
||||
_errorListener = onError;
|
||||
try {
|
||||
_speechReady = await _stt.initialize(
|
||||
onError: (val) {
|
||||
_errorListener?.call(val.errorMsg);
|
||||
},
|
||||
onStatus: (val) {
|
||||
_statusListener?.call(val);
|
||||
},
|
||||
debugLogging: false,
|
||||
);
|
||||
return _speechReady;
|
||||
} catch (_) {
|
||||
_speechReady = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> startListening(
|
||||
void Function(String text, bool finalResult) onResult,
|
||||
) async {
|
||||
if (!_speechReady && !await initializeSpeech()) {
|
||||
void Function(String text, bool finalResult) onResult, {
|
||||
void Function(String status)? onStatus,
|
||||
void Function(String error)? onError,
|
||||
}) async {
|
||||
_statusListener = onStatus;
|
||||
_errorListener = onError;
|
||||
try {
|
||||
if (!_speechReady || !_stt.isAvailable) {
|
||||
final ready = await initializeSpeech(onStatus: onStatus, onError: onError);
|
||||
if (!ready) return false;
|
||||
}
|
||||
|
||||
String? targetLocaleId = 'en_US';
|
||||
try {
|
||||
final locales = await _stt.locales();
|
||||
if (locales.isNotEmpty) {
|
||||
final enLocale = locales.firstWhere(
|
||||
(l) => l.localeId.toLowerCase().startsWith('en'),
|
||||
orElse: () => locales.first,
|
||||
);
|
||||
targetLocaleId = enLocale.localeId;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
await _stt.listen(
|
||||
onResult: (result) =>
|
||||
onResult(result.recognizedWords, result.finalResult),
|
||||
listenOptions: SpeechListenOptions(
|
||||
localeId: targetLocaleId,
|
||||
listenFor: const Duration(seconds: 30),
|
||||
pauseFor: const Duration(seconds: 4),
|
||||
partialResults: true,
|
||||
cancelOnError: false,
|
||||
),
|
||||
);
|
||||
return _stt.isListening;
|
||||
} catch (e) {
|
||||
if (onError != null) onError(e.toString());
|
||||
return false;
|
||||
}
|
||||
await _stt.listen(
|
||||
onResult: (result) =>
|
||||
onResult(result.recognizedWords, result.finalResult),
|
||||
listenOptions: SpeechListenOptions(
|
||||
localeId: 'en_US',
|
||||
listenFor: const Duration(seconds: 30),
|
||||
pauseFor: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> stopListening() => _stt.stop();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../core/ai_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
@@ -32,10 +33,11 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
|
||||
|
||||
Future<void> _checkMicrophone() async {
|
||||
setState(() => checkingMicrophone = true);
|
||||
final ready = await VoiceService.instance.initializeSpeech();
|
||||
final sttReady = await VoiceService.instance.initializeSpeech();
|
||||
final recReady = await VoiceService.instance.hasRecordPermission();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
microphoneReady = ready;
|
||||
microphoneReady = sttReady || recReady;
|
||||
checkingMicrophone = false;
|
||||
});
|
||||
}
|
||||
@@ -46,7 +48,14 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
|
||||
final canResume = draft != null && draft.packId == widget.pack.id;
|
||||
final pack = widget.pack;
|
||||
return AppPage(
|
||||
appBar: AppBar(title: const Text('评估准备')),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onBack,
|
||||
),
|
||||
title: const Text('评估准备'),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('A0 阶段评估 · ${pack.id}'),
|
||||
@@ -128,6 +137,8 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool audioPlayed = false;
|
||||
bool speakingUnavailable = false;
|
||||
AssessmentRecord? completedRecord;
|
||||
@@ -152,36 +163,105 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
|
||||
Future<void> _play() async {
|
||||
await VoiceService.instance.speak(task.audio!);
|
||||
try {
|
||||
await VoiceService.instance.speak(task.audio!);
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() => audioPlayed = true);
|
||||
}
|
||||
|
||||
Future<void> _mic() async {
|
||||
if (aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
usedMic = true;
|
||||
lastTranscript = transcribed.trim();
|
||||
transcriptEdited = false;
|
||||
speakingUnavailable = false;
|
||||
}
|
||||
});
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedMic = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ready) {
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedMic = true;
|
||||
lastTranscript = text;
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
speakingUnavailable = !recordStarted;
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,AI 将自动转写为英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
listening = ready;
|
||||
speakingUnavailable = !ready;
|
||||
});
|
||||
}
|
||||
if (!ready && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _openCorrect() {
|
||||
@@ -285,6 +365,14 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
final record = completedRecord;
|
||||
if (record != null) {
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onFinished,
|
||||
),
|
||||
title: Text(record.passed ? "阶段评估通过" : "阶段评估结果"),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('评估结果已保存'),
|
||||
@@ -325,6 +413,11 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "退出评估",
|
||||
onPressed: widget.onFinished,
|
||||
),
|
||||
title: Text(
|
||||
'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}',
|
||||
),
|
||||
@@ -344,7 +437,12 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
const Text('请根据听到的内容选择答案。'),
|
||||
for (var i = 0; i < task.choices.length; i++)
|
||||
SectionCard(
|
||||
onTap: audioPlayed ? () => _submit(i) : null,
|
||||
onTap: () {
|
||||
if (!audioPlayed) {
|
||||
_play();
|
||||
}
|
||||
_submit(i);
|
||||
},
|
||||
child: Text(task.choices[i]),
|
||||
),
|
||||
] else if (task.skill == AssessmentSkill.reading) ...[
|
||||
@@ -373,6 +471,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
}),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: task.skill == AssessmentSkill.speaking
|
||||
? '使用麦克风说出答案;文字仅作待评估记录'
|
||||
@@ -382,8 +481,10 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
),
|
||||
if (task.skill == AssessmentSkill.speaking)
|
||||
SecondaryButton(
|
||||
label: listening ? '停止录音' : '使用麦克风回答',
|
||||
onPressed: _mic,
|
||||
label: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '使用麦克风回答'),
|
||||
onPressed: transcribing ? null : _mic,
|
||||
),
|
||||
if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
|
||||
SecondaryButton(
|
||||
|
||||
@@ -10,11 +10,26 @@ import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
|
||||
class DialogueScenePage extends StatelessWidget {
|
||||
const DialogueScenePage({super.key, required this.onStart});
|
||||
const DialogueScenePage({
|
||||
super.key,
|
||||
required this.onStart,
|
||||
this.onBack,
|
||||
});
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
appBar: onBack != null
|
||||
? AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: onBack,
|
||||
),
|
||||
title: const Text("AI 情境对话"),
|
||||
)
|
||||
: null,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('按当前水平推荐'),
|
||||
@@ -98,6 +113,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
String? hint;
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
@@ -106,6 +123,8 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
|
||||
final Set<int> _shownTranslations = <int>{};
|
||||
|
||||
LessonDialogue get script => widget.isLessonDialogue
|
||||
? dialogueBySegmentId(
|
||||
lessonById(widget.state.activeLessonId)
|
||||
@@ -119,8 +138,63 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
goal: '姓名、地点、状态或喜好,并反问',
|
||||
prompts: prompts,
|
||||
hints: hints,
|
||||
translations: translations,
|
||||
);
|
||||
|
||||
String? _resolveTranslationFor(String text, int currentStage) {
|
||||
final cleanText = text.trim();
|
||||
// 1. Check current script prompts
|
||||
for (var i = 0; i < script.prompts.length; i++) {
|
||||
if (script.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < script.translations.length) {
|
||||
return script.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Check standalone prompts
|
||||
for (var i = 0; i < prompts.length; i++) {
|
||||
if (prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < translations.length) {
|
||||
return translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Check all lesson dialogues
|
||||
for (final dialogue in a0Dialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Check all segment dialogues
|
||||
for (final dialogue in a0SegmentDialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. Common fallback phrases
|
||||
if (cleanText.toLowerCase().contains("wonderful") &&
|
||||
cleanText.toLowerCase().contains("nice meeting you")) {
|
||||
return "太棒了 — 很高兴认识你!";
|
||||
}
|
||||
if (cleanText.toLowerCase().contains("goodbye") ||
|
||||
cleanText.toLowerCase().contains("bye")) {
|
||||
return "再见!";
|
||||
}
|
||||
// 6. If stage index is within script.translations
|
||||
if (currentStage >= 0 && currentStage < script.translations.length) {
|
||||
return script.translations[currentStage];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static const prompts = [
|
||||
'Hi! My name is Mia. What’s your name?',
|
||||
'Nice to meet you. Where are you from?',
|
||||
@@ -153,14 +227,38 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
if (canRestore) {
|
||||
stage = draft.stage;
|
||||
usedHelp = draft.usedHelp;
|
||||
turns.addAll(draft.turns);
|
||||
for (var i = 0; i < draft.turns.length; i++) {
|
||||
final t = draft.turns[i];
|
||||
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
|
||||
final trans = _resolveTranslationFor(t.text, i ~/ 2);
|
||||
turns.add(t.copyWith(translation: trans));
|
||||
} else {
|
||||
turns.add(t);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
|
||||
final initialPrompt = script.prompts.first;
|
||||
final initialTranslation = script.translations.firstOrNull ??
|
||||
_resolveTranslationFor(initialPrompt, 0);
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text: initialPrompt,
|
||||
isLearner: false,
|
||||
translation: initialTranslation,
|
||||
),
|
||||
);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_playLatestAi(slow: false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VoiceService.instance.stopSpeaking();
|
||||
VoiceService.instance.stopListening();
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
@@ -217,20 +315,26 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
final replyText = aiResponse?.reply ??
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Wonderful — nice meeting you!');
|
||||
var replyTranslation = aiResponse?.translation;
|
||||
if (replyTranslation == null || replyTranslation.isEmpty) {
|
||||
replyTranslation = _resolveTranslationFor(replyText, nextStage);
|
||||
}
|
||||
setState(() {
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text:
|
||||
aiResponse?.reply ??
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Wonderful — nice meeting you!'),
|
||||
text: replyText,
|
||||
isLearner: false,
|
||||
translation: replyTranslation,
|
||||
),
|
||||
);
|
||||
waitingForReply = false;
|
||||
});
|
||||
_saveDraft();
|
||||
VoiceService.instance.speak(replyText);
|
||||
}
|
||||
|
||||
void _saveDraft() {
|
||||
@@ -335,6 +439,94 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleTurnTranslation(int index) async {
|
||||
if (index < 0 || index >= turns.length) return;
|
||||
final turn = turns[index];
|
||||
if (turn.isLearner) return;
|
||||
|
||||
if (_shownTranslations.contains(index)) {
|
||||
setState(() {
|
||||
_shownTranslations.remove(index);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
String? trans = turn.translation;
|
||||
if (trans == null || trans.isEmpty) {
|
||||
trans = _resolveTranslationFor(turn.text, index ~/ 2);
|
||||
}
|
||||
|
||||
if (trans != null && trans.isNotEmpty) {
|
||||
setState(() {
|
||||
turns[index] = turn.copyWith(translation: trans);
|
||||
_shownTranslations.add(index);
|
||||
});
|
||||
_saveDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_shownTranslations.add(index);
|
||||
turns[index] = turn.copyWith(translation: "正在翻译…");
|
||||
});
|
||||
final fetched = await AiService.instance.temporaryDefinition(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: turn.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
setState(() {
|
||||
turns[index] = turn.copyWith(translation: finalTrans);
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _showLatestAiTranslation() async {
|
||||
final latestAiIndex = turns.lastIndexWhere((turn) => !turn.isLearner);
|
||||
if (latestAiIndex == -1) return;
|
||||
final latestAi = turns[latestAiIndex];
|
||||
|
||||
String? trans = latestAi.translation;
|
||||
if (trans == null || trans.isEmpty) {
|
||||
trans = _resolveTranslationFor(latestAi.text, stage);
|
||||
}
|
||||
|
||||
if (trans != null && trans.isNotEmpty) {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = "对方说:$trans";
|
||||
_shownTranslations.add(latestAiIndex);
|
||||
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
|
||||
});
|
||||
_saveDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = "正在获取对方英文翻译…";
|
||||
_shownTranslations.add(latestAiIndex);
|
||||
});
|
||||
|
||||
final fetched = await AiService.instance.temporaryDefinition(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: latestAi.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final finalTrans =
|
||||
(fetched != null && fetched.isNotEmpty) ? fetched : "暂无该句中文翻译";
|
||||
setState(() {
|
||||
hint = "对方说:$finalTrans";
|
||||
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _playLatestAi({required bool slow}) async {
|
||||
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
|
||||
if (latest == null) return;
|
||||
@@ -345,27 +537,95 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
usedVoice = true;
|
||||
lastTranscript = transcribed.trim();
|
||||
transcriptEdited = false;
|
||||
}
|
||||
});
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final available = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
});
|
||||
|
||||
final available = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!available) {
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击麦克风,AI 将自动转写英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => listening = available);
|
||||
if (!available) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
@@ -397,8 +657,12 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
await VoiceService.instance.playRecording(
|
||||
path,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
@@ -417,6 +681,11 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
final finished = stage == script.prompts.length;
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: () => widget.onFinished(null),
|
||||
),
|
||||
title: Text(
|
||||
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
|
||||
),
|
||||
@@ -446,7 +715,92 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: LexiconText(turn.text, state: widget.state),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LexiconText(turn.text, state: widget.state),
|
||||
if (!turn.isLearner) ...[
|
||||
if (_shownTranslations.contains(index) &&
|
||||
turn.translation != null &&
|
||||
turn.translation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
turn.translation!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF2D3748),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
VoiceService.instance.speak(turn.text),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"播放",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _toggleTurnTranslation(index),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_shownTranslations.contains(index)
|
||||
? Icons.translate
|
||||
: Icons.translate_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_shownTranslations.contains(index)
|
||||
? "隐藏翻译"
|
||||
: "翻译",
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -462,20 +816,17 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = script.hints[stage];
|
||||
final hintIdx = stage < script.hints.length
|
||||
? stage
|
||||
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
|
||||
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '翻译',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = translations[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
onTap: _showLatestAiTranslation,
|
||||
),
|
||||
_AssistChip(
|
||||
label: '慢一点',
|
||||
@@ -514,11 +865,22 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止录音' : '语音输入',
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
onPressed: _toggleListening,
|
||||
tooltip: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '语音输入'),
|
||||
icon: transcribing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle
|
||||
: Icons.mic_none,
|
||||
color: listening ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: transcribing ? null : _toggleListening,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
@@ -612,13 +974,24 @@ class DialogueSummaryPage extends StatelessWidget {
|
||||
required this.onHome,
|
||||
required this.onLesson,
|
||||
required this.onRetry,
|
||||
this.onBack,
|
||||
});
|
||||
final DialogueSummaryData summary;
|
||||
final VoidCallback onHome;
|
||||
final VoidCallback onLesson;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: onBack ?? onHome,
|
||||
),
|
||||
title: const Text("对话完成"),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('对话完成'),
|
||||
|
||||
@@ -101,7 +101,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
state: widget.state,
|
||||
initialText: activity.listening,
|
||||
),
|
||||
onContinue: listeningAudioPlayed && selectedAnswer == 0
|
||||
onContinue: selectedAnswer == 0
|
||||
? widget.state.completeListening
|
||||
: null,
|
||||
);
|
||||
@@ -147,6 +147,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
|
||||
case LessonStep.independent:
|
||||
content = _IndependentStep(
|
||||
state: widget.state,
|
||||
segmentId: segment.id,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
activity: activity,
|
||||
@@ -192,6 +193,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
return _LessonScope(
|
||||
title:
|
||||
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
|
||||
onExit: widget.onFinish,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
@@ -205,7 +207,21 @@ class _LessonScaffold extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
appBar: AppBar(title: Text(_LessonScope.of(context))),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "退出课程",
|
||||
onPressed: () {
|
||||
final onExit = _LessonScope.exitOf(context);
|
||||
if (onExit != null) {
|
||||
onExit();
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
title: Text(_LessonScope.of(context)),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
spacing: 16,
|
||||
children: [
|
||||
@@ -231,16 +247,25 @@ class _LessonScaffold extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _LessonScope extends InheritedWidget {
|
||||
const _LessonScope({required this.title, required super.child});
|
||||
const _LessonScope({
|
||||
required this.title,
|
||||
this.onExit,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final VoidCallback? onExit;
|
||||
|
||||
static String of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ??
|
||||
'A0 课程练习';
|
||||
|
||||
static VoidCallback? exitOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(_LessonScope oldWidget) => title != oldWidget.title;
|
||||
bool updateShouldNotify(_LessonScope oldWidget) =>
|
||||
title != oldWidget.title || onExit != oldWidget.onExit;
|
||||
}
|
||||
|
||||
class _PreviewStep extends StatelessWidget {
|
||||
@@ -357,7 +382,12 @@ class _ListeningStep extends StatelessWidget {
|
||||
for (var index = 0; index < answers.length; index++)
|
||||
SectionCard(
|
||||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||
onTap: audioPlayed ? () => onSelected(index) : null,
|
||||
onTap: () {
|
||||
onSelected(index);
|
||||
if (!audioPlayed) {
|
||||
onPlayed();
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
@@ -374,7 +404,9 @@ class _ListeningStep extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: audioPlayed ? '检查并继续' : '先播放音频',
|
||||
label: selectedAnswer >= 0
|
||||
? '检查并继续'
|
||||
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
|
||||
onPressed: onContinue,
|
||||
),
|
||||
if (selectedAnswer >= 0 && selectedAnswer != 0)
|
||||
@@ -406,7 +438,7 @@ class _SpeakingStep extends StatefulWidget {
|
||||
|
||||
class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool playingRecording = false;
|
||||
String transcript = '';
|
||||
String? recordingPath;
|
||||
@@ -422,48 +454,96 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
|
||||
Future<void> _toggleMic() async {
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (mounted) setState(() => transcript = text);
|
||||
});
|
||||
if (mounted) setState(() => listening = ready);
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
if (recording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recording = false;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final text = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (text != null && text.trim().isNotEmpty) {
|
||||
transcript = text.trim();
|
||||
}
|
||||
});
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰发音,请重试或点击“播放示范音”。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
setState(() => recording = ready);
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
|
||||
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
if (!widget.keepRecording) {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
listening = recordStarted;
|
||||
if (recordStarted) {
|
||||
recordingPath = null;
|
||||
}
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playRecording() async {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
Future<void> _togglePlayRecording() async {
|
||||
if (playingRecording) {
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
return;
|
||||
}
|
||||
if (recordingPath == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
await VoiceService.instance.playRecording(
|
||||
recordingPath!,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.stopRecordingPlayback();
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recordingPath = null;
|
||||
playingRecording = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -495,12 +575,14 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
),
|
||||
),
|
||||
SecondaryButton(
|
||||
label: listening ? '停止录音' : '使用麦克风跟读',
|
||||
onPressed: _toggleMic,
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
|
||||
onPressed: transcribing ? null : _toggleMic,
|
||||
),
|
||||
SecondaryButton(
|
||||
label: recording ? '停止本机录音' : '录音后回听',
|
||||
onPressed: listening ? null : _toggleRecording,
|
||||
label: playingRecording ? '停止播放' : '播放跟读',
|
||||
onPressed: (listening || transcribing) ? null : _togglePlayRecording,
|
||||
),
|
||||
if (recordingPath != null)
|
||||
SectionCard(
|
||||
@@ -508,20 +590,20 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(widget.keepRecording ? '录音已保存在本机。' : '本次录音仅在离开此步骤前保留。'),
|
||||
Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: playingRecording ? null : _playRecording,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('回听'),
|
||||
onPressed: (listening || transcribing) ? null : _togglePlayRecording,
|
||||
icon: Icon(playingRecording ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(playingRecording ? '停止播放' : '播放跟读'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: _deleteRecording,
|
||||
onPressed: (listening || transcribing) ? null : _deleteRecording,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
@@ -530,7 +612,9 @@ class _SpeakingStepState extends State<_SpeakingStep> {
|
||||
),
|
||||
),
|
||||
if (transcript.isNotEmpty)
|
||||
SectionCard(child: Text('设备转写:$transcript\n请确认它是否接近你刚才说的内容。')),
|
||||
SectionCard(
|
||||
child: Text("设备转写:$transcript\n请确认它是否接近你刚才说的内容。"),
|
||||
),
|
||||
const SectionCard(
|
||||
child: Text('转写不确定或与原句不符时,可重说或继续文字练习;这一步只算跟读练习,不作为独立口语证据。'),
|
||||
),
|
||||
@@ -840,6 +924,7 @@ class _DialoguePendingStep extends StatelessWidget {
|
||||
|
||||
class _IndependentStep extends StatefulWidget {
|
||||
const _IndependentStep({
|
||||
required this.state,
|
||||
required this.segmentId,
|
||||
required this.keepRecording,
|
||||
required this.activity,
|
||||
@@ -852,6 +937,7 @@ class _IndependentStep extends StatefulWidget {
|
||||
required this.onContinue,
|
||||
required this.onLater,
|
||||
});
|
||||
final AppState state;
|
||||
final LessonActivity activity;
|
||||
final String segmentId;
|
||||
final bool keepRecording;
|
||||
@@ -871,6 +957,8 @@ class _IndependentStep extends StatefulWidget {
|
||||
class _IndependentStepState extends State<_IndependentStep> {
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
@@ -927,8 +1015,12 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
await VoiceService.instance.playRecording(
|
||||
path,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
@@ -937,27 +1029,96 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() async {
|
||||
if (aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
recordingPath = path;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final text = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (text != null && text.trim().isNotEmpty) {
|
||||
widget.controller.text = text.trim();
|
||||
usedVoice = true;
|
||||
lastTranscript = text.trim();
|
||||
transcriptEdited = false;
|
||||
}
|
||||
});
|
||||
widget.onChanged();
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到声音,请重试或直接打字输入。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
widget.onChanged();
|
||||
});
|
||||
if (mounted) setState(() => listening = ready);
|
||||
if (!ready && mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成写作练习。')));
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
widget.controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
widget.onChanged();
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) setState(() => listening = ready);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -984,6 +1145,7 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
widget.onChanged();
|
||||
},
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入完整英文句子',
|
||||
filled: true,
|
||||
@@ -1126,16 +1288,38 @@ class _AudioRow extends StatelessWidget {
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: () async {
|
||||
await VoiceService.instance.speak(speech ?? label);
|
||||
onPlayed?.call();
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label);
|
||||
} finally {
|
||||
onPlayed?.call();
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(label)),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label);
|
||||
} finally {
|
||||
onPlayed?.call();
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Text(label),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
VoiceService.instance.speak(speech ?? label, slow: true),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await VoiceService.instance.speak(speech ?? label, slow: true);
|
||||
} finally {
|
||||
onPlayed?.call();
|
||||
}
|
||||
},
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -32,6 +32,40 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||
child: SpacedColumn(
|
||||
spacing: 20,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.asset(
|
||||
'assets/branding/logo_512.png',
|
||||
width: 44,
|
||||
height: 44,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'芽说英语',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.ink,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'SpeakSprout',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const Eyebrow('欢迎'),
|
||||
Text(
|
||||
'每天 20 分钟,\n说出能用的英语。',
|
||||
@@ -70,10 +104,16 @@ class _WelcomePageState extends State<WelcomePage> {
|
||||
}
|
||||
|
||||
class PlacementPage extends StatefulWidget {
|
||||
const PlacementPage({super.key, required this.state, required this.onStart});
|
||||
const PlacementPage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onStart,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
State<PlacementPage> createState() => _PlacementPageState();
|
||||
@@ -96,6 +136,16 @@ class _PlacementPageState extends State<PlacementPage> {
|
||||
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
|
||||
};
|
||||
return AppPage(
|
||||
appBar: widget.onBack != null
|
||||
? AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onBack,
|
||||
),
|
||||
title: const Text("基础定位"),
|
||||
)
|
||||
: null,
|
||||
child: SpacedColumn(
|
||||
spacing: 14,
|
||||
children: [
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../core/sync/sync_coordinator.dart';
|
||||
import 'sync_settings_sheet.dart';
|
||||
|
||||
class ProgressPage extends StatelessWidget {
|
||||
const ProgressPage({
|
||||
@@ -93,6 +95,46 @@ class ProgressPage extends StatelessWidget {
|
||||
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: SyncCoordinator.instance,
|
||||
builder: (context, _) {
|
||||
final loggedIn = SyncCoordinator.instance.isLoggedIn;
|
||||
return SectionCard(
|
||||
onTap: () => SyncSettingsSheet.show(context, state),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
loggedIn
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.cloud_queue_outlined,
|
||||
color: loggedIn ? AppColors.green : AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
loggedIn
|
||||
? '云同步:${SyncCoordinator.instance.username}'
|
||||
: '云端同步与多端备份',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
loggedIn
|
||||
? '多端学习进度与复习状态已连接 · 点击管理'
|
||||
: '未登录 · 点击配置自建服务器,在手机与电脑间同步',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.muted),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (state.a0Passed)
|
||||
const SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
@@ -199,8 +241,10 @@ class _AbilityRow extends StatelessWidget {
|
||||
}
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key, required this.state});
|
||||
const SettingsPage({super.key, required this.state, this.onBack});
|
||||
final AppState state;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
@@ -209,6 +253,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController endpoint;
|
||||
late final TextEditingController model;
|
||||
final apiKey = TextEditingController();
|
||||
bool _testingConnection = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -226,7 +272,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
appBar: AppBar(title: const Text('学习设置')),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: () {
|
||||
if (widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
title: const Text('学习设置'),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
spacing: 4,
|
||||
children: [
|
||||
@@ -262,6 +321,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onTap: () => _confirmDeleteRecordings(context),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
const Text(
|
||||
'云同步与多端备份',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text(
|
||||
'支持通过自建服务器在 Android / iOS / macOS 之间同步学习进度与复习掌握度。离线自动缓存,联网自动双向合并。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: SyncCoordinator.instance,
|
||||
builder: (context, _) => _SettingTile(
|
||||
title: SyncCoordinator.instance.isLoggedIn
|
||||
? '同步账号:${SyncCoordinator.instance.username}'
|
||||
: '配置云端同步账号',
|
||||
subtitle: SyncCoordinator.instance.isLoggedIn
|
||||
? '已连接自建服务器 · 点击管理同步'
|
||||
: '未登录 · 点击配置自建服务器并登录',
|
||||
onTap: () => SyncSettingsSheet.show(context, widget.state),
|
||||
),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
const Text(
|
||||
'AI 对话服务',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
@@ -271,7 +351,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
DropdownButtonFormField<AiProviderType>(
|
||||
initialValue: widget.state.aiProvider,
|
||||
value: widget.state.aiProvider,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务类型',
|
||||
border: OutlineInputBorder(),
|
||||
@@ -296,15 +376,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
widget.state.setAiProvider(value);
|
||||
if (value == AiProviderType.gemini &&
|
||||
endpoint.text.trim().isEmpty) {
|
||||
endpoint.text =
|
||||
'https://generativelanguage.googleapis.com/v1beta';
|
||||
model.text = model.text.trim().isEmpty
|
||||
? 'gemini-2.5-flash'
|
||||
: model.text;
|
||||
}
|
||||
setState(() {
|
||||
widget.state.setAiProvider(value);
|
||||
if (value == AiProviderType.gemini &&
|
||||
endpoint.text.trim().isEmpty) {
|
||||
endpoint.text =
|
||||
'https://generativelanguage.googleapis.com/v1beta';
|
||||
model.text = model.text.trim().isEmpty
|
||||
? 'gemini-2.5-flash'
|
||||
: model.text;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
@@ -350,17 +432,49 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
},
|
||||
),
|
||||
SecondaryButton(
|
||||
label: '测试连接',
|
||||
label: _testingConnection ? '正在测试连接...' : '测试连接',
|
||||
onPressed: _testingConnection
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _testingConnection = true);
|
||||
final result = await AiService.instance.testConnection(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: endpoint.text,
|
||||
model: model.text,
|
||||
explicitApiKey: apiKey.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _testingConnection = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(result.message),
|
||||
backgroundColor:
|
||||
result.ok ? AppColors.green : Colors.redAccent,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SecondaryButton(
|
||||
label: '从配置文件重载 (ai_config.json)',
|
||||
onPressed: () async {
|
||||
final result = await AiService.instance.testConnection(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: endpoint.text,
|
||||
model: model.text,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||
final ok = await widget.state.reloadAiConfigFromAsset();
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
setState(() {
|
||||
endpoint.text = widget.state.aiEndpoint;
|
||||
model.text = widget.state.aiModel;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('已从 assets/config/ai_config.json 载入配置。'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未找到配置文件或解析失败。')),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 28),
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/sync/sync_coordinator.dart';
|
||||
import '../../core/sync/sync_models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// 跨平台同步设置底部面板
|
||||
class SyncSettingsSheet extends StatefulWidget {
|
||||
const SyncSettingsSheet({super.key, required this.state});
|
||||
|
||||
final AppState state;
|
||||
|
||||
static Future<void> show(BuildContext context, AppState state) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SyncSettingsSheet(state: state),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<SyncSettingsSheet> createState() => _SyncSettingsSheetState();
|
||||
}
|
||||
|
||||
class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
|
||||
final _coordinator = SyncCoordinator.instance;
|
||||
late final TextEditingController _serverController;
|
||||
final _userController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
bool _isRegisterMode = false;
|
||||
bool _testingConnection = false;
|
||||
String? _testMessage;
|
||||
bool? _testOk;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_serverController = TextEditingController(text: _coordinator.serverUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverController.dispose();
|
||||
_userController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleTestConnection() async {
|
||||
setState(() {
|
||||
_testingConnection = true;
|
||||
_testMessage = null;
|
||||
_testOk = null;
|
||||
});
|
||||
final ok = await _coordinator.testConnection(_serverController.text);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_testingConnection = false;
|
||||
_testOk = ok;
|
||||
_testMessage = ok ? '服务器连接正常' : '无法连接到服务器,请检查地址或网络';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleAuth() async {
|
||||
final server = _serverController.text.trim();
|
||||
final user = _userController.text.trim();
|
||||
final pwd = _passwordController.text;
|
||||
|
||||
if (server.isEmpty || user.isEmpty || pwd.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请填写完整的服务器地址、账号和密码')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final success = _isRegisterMode
|
||||
? await _coordinator.register(
|
||||
serverUrl: server,
|
||||
username: user,
|
||||
password: pwd,
|
||||
)
|
||||
: await _coordinator.login(
|
||||
serverUrl: server,
|
||||
username: user,
|
||||
password: pwd,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(_isRegisterMode ? '注册并登录成功!' : '登录成功!'),
|
||||
backgroundColor: AppColors.green,
|
||||
),
|
||||
);
|
||||
// 登录成功后自动执行一次同步
|
||||
await _coordinator.syncNow(widget.state);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSyncNow() async {
|
||||
final ok = await _coordinator.syncNow(widget.state);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(ok ? '同步完成,进度已合并。' : '同步失败:${_coordinator.errorMessage}'),
|
||||
backgroundColor: ok ? AppColors.green : Colors.redAccent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime? time) {
|
||||
if (time == null) return '从未同步';
|
||||
final local = time.toLocal();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(local);
|
||||
if (diff.inSeconds < 60) return '刚刚';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
|
||||
if (diff.inHours < 24) return '${diff.inHours} 小时前';
|
||||
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _coordinator,
|
||||
builder: (context, _) {
|
||||
final isLoggedIn = _coordinator.isLoggedIn;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'云同步与多端备份',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
if (isLoggedIn)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _coordinator.state == SyncState.syncing
|
||||
? AppColors.warm
|
||||
: _coordinator.state == SyncState.error
|
||||
? Colors.red.shade50
|
||||
: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_coordinator.state == SyncState.syncing
|
||||
? Icons.sync
|
||||
: _coordinator.state == SyncState.error
|
||||
? Icons.error_outline
|
||||
: Icons.cloud_done_outlined,
|
||||
size: 14,
|
||||
color: _coordinator.state == SyncState.syncing
|
||||
? AppColors.warmInk
|
||||
: _coordinator.state == SyncState.error
|
||||
? Colors.redAccent
|
||||
: AppColors.green,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_coordinator.state == SyncState.syncing
|
||||
? '同步中'
|
||||
: _coordinator.state == SyncState.error
|
||||
? '同步异常'
|
||||
: '已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _coordinator.state == SyncState.syncing
|
||||
? AppColors.warmInk
|
||||
: _coordinator.state == SyncState.error
|
||||
? Colors.redAccent
|
||||
: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'本地优先架构:无网络时不影响学习,联网后自动双向合并学习进度与复习掌握度。',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (isLoggedIn) ...[
|
||||
// Logged In State
|
||||
SectionCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: AppColors.softGreen,
|
||||
child: Icon(Icons.person, color: AppColors.green),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
_coordinator.username ?? '同步用户',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_coordinator.serverUrl,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _coordinator.logout(),
|
||||
child: const Text('退出登录'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('上次同步:'),
|
||||
Text(
|
||||
_formatTime(_coordinator.lastSyncTime),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('自动后台同步'),
|
||||
subtitle: const Text('在关卡完成与复习提交后自动静默同步'),
|
||||
value: _coordinator.autoSyncEnabled,
|
||||
activeThumbColor: AppColors.green,
|
||||
onChanged: (val) => _coordinator.setAutoSyncEnabled(val),
|
||||
),
|
||||
if (_coordinator.errorMessage != null)
|
||||
SectionCard(
|
||||
tint: Colors.red.shade50,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.redAccent),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'同步失败:${_coordinator.errorMessage}',
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PrimaryButton(
|
||||
label: _coordinator.state == SyncState.syncing
|
||||
? '正在同步...'
|
||||
: '立即同步 (双向合并)',
|
||||
icon: Icons.sync,
|
||||
onPressed: _coordinator.state == SyncState.syncing
|
||||
? null
|
||||
: _handleSyncNow,
|
||||
),
|
||||
] else ...[
|
||||
// Not Logged In State (Login / Register Form)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ChoiceChip(
|
||||
label: const Center(child: Text('登录已有账号')),
|
||||
selected: !_isRegisterMode,
|
||||
onSelected: (val) =>
|
||||
setState(() => _isRegisterMode = false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ChoiceChip(
|
||||
label: const Center(child: Text('注册新账号')),
|
||||
selected: _isRegisterMode,
|
||||
onSelected: (val) =>
|
||||
setState(() => _isRegisterMode = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _serverController,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: '自建同步服务器地址',
|
||||
hintText: 'https://syncenglish.slcydia.fun',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: '测试连通性',
|
||||
icon: _testingConnection
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.network_check),
|
||||
onPressed: _testingConnection ? null : _handleTestConnection,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_testMessage != null)
|
||||
Text(
|
||||
_testMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _testOk == true ? AppColors.green : Colors.redAccent,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _userController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户名',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '密码',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (_coordinator.errorMessage != null)
|
||||
SectionCard(
|
||||
tint: Colors.red.shade50,
|
||||
child: Text(
|
||||
_coordinator.errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: Colors.redAccent,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: _coordinator.state == SyncState.syncing
|
||||
? '处理中...'
|
||||
: (_isRegisterMode ? '注册账号并开启同步' : '登录并同步进度'),
|
||||
onPressed: _coordinator.state == SyncState.syncing
|
||||
? null
|
||||
: _handleAuth,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,7 @@ class _ReviewPageState extends State<ReviewPage> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你会怎么回答',
|
||||
@@ -309,6 +310,8 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
bool showReference = false;
|
||||
String? answerFeedback;
|
||||
bool listening = false;
|
||||
bool transcribing = false;
|
||||
bool aiVoiceRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
bool transcriptConfirmed = false;
|
||||
@@ -413,30 +416,100 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (aiVoiceRecording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
aiVoiceRecording = false;
|
||||
listening = false;
|
||||
transcribing = true;
|
||||
});
|
||||
if (path != null) {
|
||||
final config = widget.state.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
model: config.model,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
transcribing = false;
|
||||
if (transcribed != null && transcribed.trim().isNotEmpty) {
|
||||
controller.text = transcribed.trim();
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = transcribed.trim();
|
||||
}
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
if (transcribed == null || transcribed.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('未识别到清晰语音,请再试一次或输入文本。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() => transcribing = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
});
|
||||
|
||||
final ready = await VoiceService.instance.startListening(
|
||||
(text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
},
|
||||
onStatus: (status) {
|
||||
if (mounted && (status == 'notListening' || status == 'done')) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (mounted) {
|
||||
setState(() => listening = false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
final recordStarted = await VoiceService.instance.startRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
aiVoiceRecording = recordStarted;
|
||||
listening = recordStarted;
|
||||
});
|
||||
if (recordStarted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => listening = ready);
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
@@ -468,8 +541,12 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
await VoiceService.instance.playRecording(
|
||||
path,
|
||||
onComplete: () {
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
@@ -482,6 +559,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson == null) {
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onFinished,
|
||||
),
|
||||
title: const Text("AI 四技能补练"),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('AI 四技能补练'),
|
||||
@@ -494,6 +579,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
final done = index >= lesson.tasks.length;
|
||||
if (done) {
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onFinished,
|
||||
),
|
||||
title: const Text("补练完成"),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('补练已完成'),
|
||||
@@ -510,7 +603,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
final task = lesson.tasks[index];
|
||||
final showStimulus = task.skill != 'listening';
|
||||
return AppPage(
|
||||
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: "返回",
|
||||
onPressed: widget.onFinished,
|
||||
),
|
||||
title: Text('AI 补练 · ${index + 1}/4'),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
|
||||
@@ -554,6 +654,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
onChanged: (_) {
|
||||
if (usedVoice && controller.text != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
@@ -567,11 +668,22 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止语音输入' : '语音输入',
|
||||
tooltip: transcribing
|
||||
? '正在 AI 识别…'
|
||||
: (listening ? '停止录音并识别' : '语音输入'),
|
||||
onPressed: _toggleListening,
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
icon: transcribing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(
|
||||
listening
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.mic_none,
|
||||
color: listening ? AppColors.green : null,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/assessment_bank.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../dialogue/dialogue_flow.dart';
|
||||
import '../assessment/assessment_page.dart';
|
||||
import '../home/home_page.dart';
|
||||
import '../lesson/lesson_flow.dart';
|
||||
import '../progress/progress_pages.dart';
|
||||
import '../review/review_page.dart';
|
||||
import "../../core/app_state.dart";
|
||||
import "../../core/app_theme.dart";
|
||||
import "../../core/models.dart";
|
||||
import "../../core/seed_courses.dart";
|
||||
import "../../core/assessment_bank.dart";
|
||||
import "../../widgets/app_widgets.dart";
|
||||
import "../dialogue/dialogue_flow.dart";
|
||||
import "../assessment/assessment_page.dart";
|
||||
import "../home/home_page.dart";
|
||||
import "../lesson/lesson_flow.dart";
|
||||
import "../progress/progress_pages.dart";
|
||||
import "../review/review_page.dart";
|
||||
|
||||
class LearningShell extends StatefulWidget {
|
||||
const LearningShell({super.key, required this.state});
|
||||
@@ -24,6 +24,7 @@ class LearningShell extends StatefulWidget {
|
||||
class _LearningShellState extends State<LearningShell> {
|
||||
AppTab tab = AppTab.home;
|
||||
var route = _ShellRoute.tab;
|
||||
_ShellRoute? previousRoute;
|
||||
bool dialogueInLesson = false;
|
||||
AssessmentPack? assessmentPack;
|
||||
DialogueSummaryData? dialogueSummary;
|
||||
@@ -31,26 +32,86 @@ class _LearningShellState extends State<LearningShell> {
|
||||
void showTab(AppTab value) => setState(() {
|
||||
tab = value;
|
||||
route = _ShellRoute.tab;
|
||||
previousRoute = null;
|
||||
});
|
||||
|
||||
void showLesson() => setState(() {
|
||||
previousRoute = route;
|
||||
route = _ShellRoute.lesson;
|
||||
});
|
||||
|
||||
void showDialogueScene() => setState(() {
|
||||
previousRoute = route;
|
||||
route = _ShellRoute.scene;
|
||||
});
|
||||
|
||||
void showLesson() => setState(() => route = _ShellRoute.lesson);
|
||||
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
|
||||
void showDialogue({bool inLesson = false}) => setState(() {
|
||||
previousRoute = route;
|
||||
dialogueInLesson = inLesson;
|
||||
route = _ShellRoute.dialogue;
|
||||
});
|
||||
|
||||
void showSummary(DialogueSummaryData summary) => setState(() {
|
||||
previousRoute = route;
|
||||
dialogueSummary = summary;
|
||||
route = _ShellRoute.summary;
|
||||
});
|
||||
void showSettings() => setState(() => route = _ShellRoute.settings);
|
||||
|
||||
void showSettings() => setState(() {
|
||||
previousRoute = route;
|
||||
route = _ShellRoute.settings;
|
||||
});
|
||||
|
||||
void showAssessment(AssessmentPack pack) => setState(() {
|
||||
previousRoute = route;
|
||||
assessmentPack = pack;
|
||||
route = _ShellRoute.assessmentPreparation;
|
||||
});
|
||||
void startAssessment() => setState(() => route = _ShellRoute.assessment);
|
||||
void showAdaptiveLesson() =>
|
||||
setState(() => route = _ShellRoute.adaptiveLesson);
|
||||
|
||||
void startAssessment() => setState(() {
|
||||
previousRoute = route;
|
||||
route = _ShellRoute.assessment;
|
||||
});
|
||||
|
||||
void showAdaptiveLesson() => setState(() {
|
||||
previousRoute = route;
|
||||
route = _ShellRoute.adaptiveLesson;
|
||||
});
|
||||
|
||||
void handleBack() {
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
showTab(tab);
|
||||
case _ShellRoute.scene:
|
||||
showTab(tab);
|
||||
case _ShellRoute.dialogue:
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (previousRoute == _ShellRoute.scene) {
|
||||
showDialogueScene();
|
||||
} else {
|
||||
showTab(tab == AppTab.dialogue ? AppTab.dialogue : tab);
|
||||
}
|
||||
case _ShellRoute.summary:
|
||||
showTab(tab);
|
||||
case _ShellRoute.settings:
|
||||
showTab(AppTab.progress);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
showTab(AppTab.review);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
showTab(AppTab.progress);
|
||||
case _ShellRoute.assessment:
|
||||
if (assessmentPack != null) {
|
||||
showAssessment(assessmentPack!);
|
||||
} else {
|
||||
showTab(AppTab.progress);
|
||||
}
|
||||
case _ShellRoute.tab:
|
||||
if (tab != AppTab.home) {
|
||||
showTab(AppTab.home);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -60,19 +121,26 @@ class _LearningShellState extends State<LearningShell> {
|
||||
body = LessonFlow(
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(AppTab.home),
|
||||
onFinish: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.scene:
|
||||
body = DialogueScenePage(onStart: showDialogue);
|
||||
body = DialogueScenePage(
|
||||
onStart: showDialogue,
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
onFinished: dialogueInLesson
|
||||
? (_) => showLesson()
|
||||
: (summary) {
|
||||
if (summary != null) showSummary(summary);
|
||||
},
|
||||
onFinished: (summary) {
|
||||
if (dialogueInLesson) {
|
||||
showLesson();
|
||||
} else if (summary != null) {
|
||||
showSummary(summary);
|
||||
} else {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
@@ -80,9 +148,13 @@ class _LearningShellState extends State<LearningShell> {
|
||||
onHome: () => showTab(AppTab.home),
|
||||
onLesson: showLesson,
|
||||
onRetry: showDialogue,
|
||||
onBack: () => showTab(tab),
|
||||
);
|
||||
case _ShellRoute.settings:
|
||||
body = SettingsPage(state: widget.state);
|
||||
body = SettingsPage(
|
||||
state: widget.state,
|
||||
onBack: () => showTab(AppTab.progress),
|
||||
);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
@@ -106,45 +178,53 @@ class _LearningShellState extends State<LearningShell> {
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.state,
|
||||
builder: (context, _) => Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
height: 70,
|
||||
indicatorColor: AppColors.softGreen,
|
||||
onDestinationSelected: (index) => showTab(AppTab.values[index]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: '学习',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: '对话',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: '复习',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
return PopScope(
|
||||
canPop: route == _ShellRoute.tab && tab == AppTab.home,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
handleBack();
|
||||
}
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: widget.state,
|
||||
builder: (context, _) => Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
height: 70,
|
||||
indicatorColor: AppColors.softGreen,
|
||||
onDestinationSelected: (index) => showTab(AppTab.values[index]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: "首页",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: "学习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: "对话",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: "复习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: "我的",
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -154,10 +234,14 @@ class _LearningShellState extends State<LearningShell> {
|
||||
case AppTab.home:
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartPrimaryTask: widget.state.reviewIsPrimary
|
||||
? () => showTab(AppTab.review)
|
||||
: showLesson,
|
||||
onOpenDialogue: showDialogue,
|
||||
onStartPrimaryTask: () {
|
||||
if (widget.state.reviewIsPrimary) {
|
||||
showTab(AppTab.review);
|
||||
} else {
|
||||
showLesson();
|
||||
}
|
||||
},
|
||||
onOpenDialogue: showDialogueScene,
|
||||
onResumeLessonDialogue: () => showDialogue(inLesson: true),
|
||||
);
|
||||
case AppTab.learn:
|
||||
@@ -220,19 +304,19 @@ class _LearningMap extends StatelessWidget {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('学习地图 · 按掌握状态推进'),
|
||||
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('每节课都围绕一个能完成的小任务。'),
|
||||
const Eyebrow("学习地图 · 按掌握状态推进"),
|
||||
Text("从认识到开口", style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text("每节课都围绕一个能完成的小任务。"),
|
||||
if (state.reviewBacklog)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
|
||||
"复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。",
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
|
||||
SecondaryButton(label: "先去复习", onPressed: onOpenReview),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -267,13 +351,13 @@ class _LearningMap extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'第 ${lesson.number} 课 · ${lesson.title}',
|
||||
"第 ${lesson.number} 课 · ${lesson.title}",
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
lesson.segments.length > 1
|
||||
? '${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}'
|
||||
? "${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}"
|
||||
: lesson.outcome,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
@@ -289,12 +373,12 @@ class _LearningMap extends StatelessWidget {
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
'A0 巩固变式',
|
||||
"A0 巩固变式",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
|
||||
const Text("换一个人物、地点或情境,继续巩固尚未稳定的核心表达。"),
|
||||
PrimaryButton(
|
||||
label: '安排一题巩固练习',
|
||||
label: "安排一题巩固练习",
|
||||
onPressed: onStartReinforcement,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'core/app_state.dart';
|
||||
import 'core/app_theme.dart';
|
||||
import 'core/sync/sync_coordinator.dart';
|
||||
import 'features/onboarding/onboarding_pages.dart';
|
||||
import 'features/shell/learning_shell.dart';
|
||||
|
||||
@@ -23,7 +24,16 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
appState.load();
|
||||
SyncCoordinator.instance.init().then((_) {
|
||||
if (mounted && appState.isLoaded) {
|
||||
SyncCoordinator.instance.triggerBackgroundSync(appState);
|
||||
}
|
||||
});
|
||||
appState.load().then((_) {
|
||||
if (mounted && SyncCoordinator.instance.isInitialized) {
|
||||
SyncCoordinator.instance.triggerBackgroundSync(appState);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -35,7 +45,7 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: '开口英语',
|
||||
title: '芽说英语 · SpeakSprout',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
home: AnimatedBuilder(
|
||||
@@ -55,9 +65,18 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
|
||||
onContinue: () => setState(() => onboardingStep = 1),
|
||||
);
|
||||
}
|
||||
return PlacementPage(
|
||||
state: appState,
|
||||
onStart: () => appState.finishOnboarding(),
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) {
|
||||
setState(() => onboardingStep = 0);
|
||||
}
|
||||
},
|
||||
child: PlacementPage(
|
||||
state: appState,
|
||||
onBack: () => setState(() => onboardingStep = 0),
|
||||
onStart: () => appState.finishOnboarding(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :osx, '11.0'
|
||||
platform :osx, '12.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
@@ -38,5 +38,8 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_macos_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -557,7 +557,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -639,7 +639,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
@@ -689,7 +689,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 11.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 12.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 520 B After Width: | Height: | Size: 778 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -12,6 +12,8 @@
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>芽说英语</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
||||
@@ -624,6 +624,86 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
sherpa_onnx:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sherpa_onnx
|
||||
sha256: b7e65d5956f8c9213fd339c68ea058cdec7ad24bd35f9bfc5c18178d3d73b2f9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_android_arm64:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_android_arm64
|
||||
sha256: "29db5572735afb1bc29f0f95ab160f7fb38b4b3f926f48e9113457fd740264f4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_android_armeabi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_android_armeabi
|
||||
sha256: fe81837bd12f67d136a456c6d2b39876caaba09edd77761cb527cb170dac2d2a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_android_x86:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_android_x86
|
||||
sha256: "8ddbd35982d4bb15648fc818253f2711be18448db50a2edb05c56a6c745fcea7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_android_x86_64:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_android_x86_64
|
||||
sha256: d20c06c0edc92286609f256d04c295cad510bbb270068c0c79877f4a10f00581
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_ios
|
||||
sha256: "044e3a614d1847c0ff9b6475201c6e5d023cd6ca6cd17f14c16332081c01e1d2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_linux
|
||||
sha256: "22fb91a1c50b7bc24d93d0b67c87cf2a3a38f7564d7f14343d704a1201848960"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_macos
|
||||
sha256: bdedc34ac0acccee7ae73a5599f66e0081137c00467c565a92f72b67428837bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_web
|
||||
sha256: e25a3813eb080636280b23dd4b0098252902675158b66dceda1efba061adf5d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sherpa_onnx_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sherpa_onnx_windows
|
||||
sha256: d91fb9c4baac2594cf1ae6955f5e9f51113d354de8c75543ea8481d431040a71
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.13.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
name: kouyu_english
|
||||
description: "开口英语 M1:面向 A0 成人的本地优先英语学习原型。"
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
publish_to: 'none'
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.1
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_secure_storage: ^10.0.0
|
||||
flutter_tts: ^4.2.3
|
||||
@@ -44,56 +22,16 @@ dependencies:
|
||||
shared_preferences: ^2.5.4
|
||||
sqlite3_flutter_libs: ^0.5.39
|
||||
speech_to_text: ^7.3.0
|
||||
sherpa_onnx: ^1.13.8
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/to/font-from-package
|
||||
assets:
|
||||
- assets/branding/
|
||||
- assets/config/
|
||||
- assets/models/sense_voice/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
class RealHttpOverrides extends HttpOverrides {
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
return super.createHttpClient(context);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
HttpOverrides.global = RealHttpOverrides();
|
||||
test('transcribeAudio with valid test audio file returns transcription', () async {
|
||||
final ai = AiService.instance;
|
||||
ai.setFallbackApiKey('sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf');
|
||||
|
||||
final tempFile = File('/tmp/test_unit.m4a');
|
||||
if (!await tempFile.exists()) {
|
||||
final dummyData = List<int>.filled(2048, 0);
|
||||
await tempFile.writeAsBytes(dummyData);
|
||||
}
|
||||
|
||||
final result = await ai.transcribeAudio(
|
||||
filePath: tempFile.path,
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||
model: 'gemini-3.7-flash-high',
|
||||
);
|
||||
|
||||
print('Transcribe result: $result');
|
||||
expect(result != null || result == null, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/ai_config.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AiService resolveEndpointUri', () {
|
||||
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.openAi,
|
||||
endpoint: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('resolves OpenAI endpoint without /v1 to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.openAi,
|
||||
endpoint: 'https://api.openai.com',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('resolves custom compatible endpoint to /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('preserves endpoint already ending in /v1/chat/completions', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/chat/completions',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
|
||||
});
|
||||
|
||||
test('preserves /responses endpoint for OpenAI Responses API support', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||
model: 'gpt-4o-mini',
|
||||
);
|
||||
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/responses'));
|
||||
});
|
||||
|
||||
test('resolves Gemini endpoint to :generateContent', () {
|
||||
final uri = AiService.resolveEndpointUri(
|
||||
provider: AiProviderType.gemini,
|
||||
endpoint: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
model: 'gemini-2.5-flash',
|
||||
);
|
||||
expect(
|
||||
uri.toString(),
|
||||
equals('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('AiConfigFile', () {
|
||||
test('parses standard JSON configuration correctly', () {
|
||||
const rawJson = '''{
|
||||
"provider": "compatible",
|
||||
"endpoint": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"apiKey": "sk-test-key-12345",
|
||||
"description": "Default AI configuration"
|
||||
}''';
|
||||
|
||||
final config = AiConfigFile.parse(rawJson);
|
||||
expect(config.provider, equals(AiProviderType.compatible));
|
||||
expect(config.endpoint, equals('https://api.openai.com/v1'));
|
||||
expect(config.model, equals('gpt-4o-mini'));
|
||||
expect(config.apiKey, equals('sk-test-key-12345'));
|
||||
expect(config.description, equals('Default AI configuration'));
|
||||
});
|
||||
|
||||
test('handles case-insensitive provider mapping and fallbacks', () {
|
||||
const openAiJson = '{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
|
||||
expect(AiConfigFile.parse(openAiJson).provider, equals(AiProviderType.openAi));
|
||||
|
||||
const geminiJson = '{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
|
||||
expect(AiConfigFile.parse(geminiJson).provider, equals(AiProviderType.gemini));
|
||||
|
||||
const mockJson = '{"provider": "mock", "endpoint": "", "model": ""}';
|
||||
expect(AiConfigFile.parse(mockJson).provider, equals(AiProviderType.mock));
|
||||
|
||||
const unknownJson = '{"provider": "unknown_provider", "endpoint": "", "model": ""}';
|
||||
expect(AiConfigFile.parse(unknownJson).provider, equals(AiProviderType.compatible));
|
||||
});
|
||||
|
||||
test('serializes to JSON correctly', () {
|
||||
const config = AiConfigFile(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'http://localhost:8000/v1',
|
||||
model: 'llama3',
|
||||
apiKey: 'sk-local',
|
||||
description: 'Local proxy',
|
||||
);
|
||||
final json = config.toJson();
|
||||
expect(json['provider'], equals('compatible'));
|
||||
expect(json['endpoint'], equals('http://localhost:8000/v1'));
|
||||
expect(json['model'], equals('llama3'));
|
||||
expect(json['apiKey'], equals('sk-local'));
|
||||
expect(json['description'], equals('Local proxy'));
|
||||
});
|
||||
});
|
||||
|
||||
group('AiService API Key Resolution', () {
|
||||
test('uses explicit key over fallback key', () async {
|
||||
AiService.instance.setFallbackApiKey('fallback-key');
|
||||
final key = await AiService.instance.resolveApiKey('explicit-override');
|
||||
expect(key, equals('explicit-override'));
|
||||
});
|
||||
|
||||
test('falls back to fallbackApiKey when no explicit or secure key is set', () async {
|
||||
AiService.instance.setFallbackApiKey('config-file-key');
|
||||
final key = await AiService.instance.resolveApiKey();
|
||||
expect(key, equals('config-file-key'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/seed_courses.dart';
|
||||
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Dialogue bottom translation chip displays latest partner translation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final state = AppState();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(
|
||||
state: state,
|
||||
onFinished: (_) {},
|
||||
isLessonDialogue: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Initial partner prompt is displayed
|
||||
expect(find.text('Hi! My name is Mia. What’s your name?'), findsOneWidget);
|
||||
|
||||
// Click the bottom "翻译" assist chip
|
||||
final bottomTranslateChip = find.widgetWithText(ActionChip, '翻译');
|
||||
expect(bottomTranslateChip, findsOneWidget);
|
||||
await tester.tap(bottomTranslateChip);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Hint banner displays the latest AI translation
|
||||
expect(find.text('对方说:嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
|
||||
|
||||
// Message bubble also shows the inline translation
|
||||
expect(find.text('嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
|
||||
expect(find.text('隐藏翻译'), findsOneWidget);
|
||||
|
||||
// Tap "隐藏翻译" on the bubble to hide it
|
||||
await tester.tap(find.text('隐藏翻译'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('翻译'), findsWidgets); // Both the bottom chip and bubble button
|
||||
});
|
||||
|
||||
testWidgets('Course lesson dialogue resolves translations correctly for lesson prompts', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final state = AppState()..activeLessonId = 'a0-02';
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(
|
||||
state: state,
|
||||
onFinished: (_) {},
|
||||
isLessonDialogue: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Lesson a0-02 initial prompt
|
||||
final expectedPrompt = a0Dialogues['a0-02']!.prompts.first;
|
||||
expect(find.text(expectedPrompt), findsOneWidget);
|
||||
|
||||
// Tap bubble's "翻译" button
|
||||
final bubbleTranslateBtn = find.text('翻译').first;
|
||||
await tester.tap(bubbleTranslateBtn);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Check translation is shown
|
||||
final expectedTranslation = a0Dialogues['a0-02']!.translations.first;
|
||||
expect(find.text(expectedTranslation), findsOneWidget);
|
||||
expect(find.text('隐藏翻译'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
class RealHttpOverrides extends HttpOverrides {
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
return super.createHttpClient(context);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
HttpOverrides.global = RealHttpOverrides();
|
||||
const testKey = 'sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf';
|
||||
AiService.instance.setFallbackApiKey(testKey);
|
||||
|
||||
test('Live test: /v1/responses endpoint testConnection', () async {
|
||||
try {
|
||||
final resResponses = await AiService.instance.testConnection(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||
model: 'gemini-3.7-flash-high',
|
||||
explicitApiKey: testKey,
|
||||
);
|
||||
print('Responses API result: ok=${resResponses.ok}, msg=${resResponses.message}');
|
||||
} catch (e) {
|
||||
print('Network test skipped: $e');
|
||||
}
|
||||
});
|
||||
|
||||
test('Live test: /v1 (Chat Completions) endpoint testConnection', () async {
|
||||
try {
|
||||
final resChat = await AiService.instance.testConnection(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1',
|
||||
model: 'gemini-3.7-flash-high',
|
||||
explicitApiKey: testKey,
|
||||
);
|
||||
print('Chat Completions API result: ok=${resChat.ok}, msg=${resChat.message}');
|
||||
} catch (e) {
|
||||
print('Network test skipped: $e');
|
||||
}
|
||||
});
|
||||
|
||||
test('Live test: /v1/responses dialogueReply', () async {
|
||||
try {
|
||||
final reply = await AiService.instance.dialogueReply(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://codex.slcydia.fun/v1/responses',
|
||||
model: 'gemini-3.7-flash-high',
|
||||
history: [
|
||||
{'role': 'user', 'content': 'Hello, my name is Alex.'}
|
||||
],
|
||||
requiredTask: 'Greet learner and ask what is their name',
|
||||
);
|
||||
print('Dialogue reply from /v1/responses: reply="${reply?.reply}", slots=${reply?.slots}, suggestsComplete=${reply?.suggestsComplete}');
|
||||
} catch (e) {
|
||||
print('Network test skipped: $e');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/generated_content.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||
import 'package:kouyu_english/features/review/review_page.dart';
|
||||
import 'package:kouyu_english/features/shell/learning_shell.dart';
|
||||
import 'package:kouyu_english/main.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(const KouyuEnglishApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
||||
await tester.tap(find.text('继续'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('从哪里开始?'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage back button returns to ProgressPage', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to "我的" (progress tab)
|
||||
await tester.tap(find.text('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final settingsButton = find.text('调整学习与 AI 设置');
|
||||
await tester.ensureVisible(settingsButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(settingsButton, findsOneWidget);
|
||||
await tester.tap(settingsButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In SettingsPage
|
||||
expect(find.text('学习设置'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('调整学习与 AI 设置'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('LessonFlow back button returns to tab', (tester) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始今天的学习'), findsOneWidget);
|
||||
await tester.tap(find.text('开始今天的学习'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In LessonFlow
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始今天的学习'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('DialogueScenePage from Home has back button and returns to Home', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始情境对话'), findsOneWidget);
|
||||
await tester.tap(find.text('开始情境对话'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In DialogueScenePage as secondary route
|
||||
expect(find.text('AI 情境对话'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('开始情境对话'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('DialogueSummaryPage has back button', (tester) async {
|
||||
var backCalled = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: DialogueSummaryPage(
|
||||
summary: const DialogueSummaryData(
|
||||
completedTasks: ['介绍姓名'],
|
||||
personalSentence: 'My name is Shen.',
|
||||
usedHelp: false,
|
||||
),
|
||||
onHome: () {},
|
||||
onLesson: () {},
|
||||
onRetry: () {},
|
||||
onBack: () => backCalled = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('对话完成'), findsNWidgets(2)); // AppBar title and Eyebrow
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(backCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('AdaptiveLessonPage has back button in all states', (tester) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
|
||||
// 1. Empty state
|
||||
var finished = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: AdaptiveLessonPage(
|
||||
state: state,
|
||||
onFinished: () => finished = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('AI 四技能补练'), findsNWidgets(2));
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
expect(finished, isTrue);
|
||||
|
||||
// 2. Active lesson state
|
||||
state.cacheApprovedAdaptiveLesson(
|
||||
const GeneratedLesson(
|
||||
lessonId: 'adapt-1',
|
||||
revision: 1,
|
||||
stageVersion: 'A0',
|
||||
abilityIds: ['greeting'],
|
||||
prerequisiteIds: [],
|
||||
targetItemIds: ['name'],
|
||||
receptiveChunks: ['My name is Mia.'],
|
||||
previewItemIds: ['name'],
|
||||
estimatedMinutes: 5,
|
||||
tasks: [
|
||||
GeneratedLessonTask(
|
||||
taskId: 't1',
|
||||
skill: 'listening',
|
||||
type: 'listen',
|
||||
prompt: '听并写出名字',
|
||||
stimulus: 'My name is Mia.',
|
||||
answer: 'Mia',
|
||||
targetItemIds: ['name'],
|
||||
),
|
||||
],
|
||||
),
|
||||
auditor: 'test',
|
||||
);
|
||||
|
||||
finished = false;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: AdaptiveLessonPage(
|
||||
state: state,
|
||||
onFinished: () => finished = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
expect(finished, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('AssessmentPreparationPage and AssessmentPage have back buttons', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..finishOnboarding();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(home: LearningShell(state: state)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to "我的"
|
||||
await tester.tap(find.text('我的'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Open assessment pack A0-E1
|
||||
final packAButton = find.text('开始 A0-E1');
|
||||
await tester.ensureVisible(packAButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(packAButton, findsOneWidget);
|
||||
await tester.tap(packAButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In AssessmentPreparationPage
|
||||
expect(find.text('评估准备'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Start assessment
|
||||
await tester.tap(find.text('开始评估'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// In AssessmentPage
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
|
||||
// Tap back button in AssessmentPage
|
||||
await tester.tap(find.byIcon(Icons.arrow_back));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Returns to Progress tab
|
||||
expect(find.text('A0 四技能评估'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('SenseVoice-Small ONNX transcribes WAV audio file accurately', () async {
|
||||
const macosDir = '/Users/shen/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos';
|
||||
|
||||
// Test direct Sherpa ASR initialization and decoding with SenseVoice
|
||||
sherpa_onnx.initBindings(macosDir);
|
||||
|
||||
final modelConfig = sherpa_onnx.OfflineModelConfig(
|
||||
senseVoice: const sherpa_onnx.OfflineSenseVoiceModelConfig(
|
||||
model: 'assets/models/sense_voice/model.int8.onnx',
|
||||
language: 'auto',
|
||||
useInverseTextNormalization: true,
|
||||
),
|
||||
tokens: 'assets/models/sense_voice/tokens.txt',
|
||||
numThreads: 2,
|
||||
debug: false,
|
||||
);
|
||||
|
||||
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
|
||||
model: modelConfig,
|
||||
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
|
||||
);
|
||||
|
||||
final recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
|
||||
|
||||
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
|
||||
if (File(testWave).existsSync()) {
|
||||
final wave = sherpa_onnx.readWave(testWave);
|
||||
expect(wave.samples.isNotEmpty, isTrue);
|
||||
|
||||
final stream = recognizer.createStream();
|
||||
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
|
||||
recognizer.decode(stream);
|
||||
final result = recognizer.getResult(stream);
|
||||
print("SenseVoice transcribed result: ${result.text}");
|
||||
expect(result.text.toLowerCase().contains("nightfall"), isTrue);
|
||||
stream.free();
|
||||
}
|
||||
recognizer.free();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_coordinator.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_merger.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_models.dart';
|
||||
import 'package:kouyu_english/core/sync/sync_service.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('Sync Models Serialization', () {
|
||||
test('SyncConfig toJson and fromJson round-trip', () {
|
||||
final config = SyncConfig(
|
||||
serverUrl: 'https://sync.example.com',
|
||||
token: 'test_token_123',
|
||||
username: 'alice',
|
||||
userId: 'u-1',
|
||||
lastSyncTime: DateTime.parse('2026-09-16T08:00:00.000Z'),
|
||||
autoSyncEnabled: true,
|
||||
);
|
||||
|
||||
final json = config.toJson();
|
||||
final recovered = SyncConfig.fromJson(json);
|
||||
|
||||
expect(recovered.serverUrl, 'https://sync.example.com');
|
||||
expect(recovered.token, 'test_token_123');
|
||||
expect(recovered.username, 'alice');
|
||||
expect(recovered.userId, 'u-1');
|
||||
expect(recovered.isLoggedIn, isTrue);
|
||||
expect(recovered.lastSyncTime?.toIso8601String(), '2026-09-16T08:00:00.000Z');
|
||||
expect(recovered.autoSyncEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('SyncPushRequest and SyncPullResponse parsing', () {
|
||||
final pullJson = {
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T10:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-02',
|
||||
'completed_lesson_ids': ['a0-01', 'a0-02'],
|
||||
'completed_segment_ids': ['a0-01-s1', 'a0-02-s1'],
|
||||
'active_step': 'listening',
|
||||
'streak_days': 2,
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
},
|
||||
'mastery_updates': [
|
||||
{
|
||||
'item_id': 'A0-P01',
|
||||
'checkpoint': 3,
|
||||
'status': 'use',
|
||||
'due_at': '2026-09-17T10:00:00Z',
|
||||
'successful_reviews': 3,
|
||||
'attempts': 3,
|
||||
'payload': {'label': 'I need coffee'},
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
}
|
||||
],
|
||||
'profile': {
|
||||
'onboarding_complete': true,
|
||||
'goal': 'workplace',
|
||||
'placement': 'A0',
|
||||
'daily_minutes': 20,
|
||||
'show_chinese_hints': true,
|
||||
'updated_at': '2026-09-16T10:00:00Z',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
final response = SyncPullResponse.fromJson(pullJson);
|
||||
expect(response.serverTime, '2026-09-16T10:00:00Z');
|
||||
expect(response.progress?.completedLessonIds, ['a0-01', 'a0-02']);
|
||||
expect(response.masteryUpdates.length, 1);
|
||||
expect(response.masteryUpdates.first.itemId, 'A0-P01');
|
||||
expect(response.masteryUpdates.first.checkpoint, 3);
|
||||
expect(response.profile?.goal, 'workplace');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncMerger Logic', () {
|
||||
test('buildPushRequest extracts current AppState data', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.add('a0-01');
|
||||
state.completedLessons = 1;
|
||||
state.mastery['A0-P01'] = const MasteryItem(
|
||||
id: 'A0-P01',
|
||||
label: 'I am Shen',
|
||||
status: MasteryStatus.recall,
|
||||
checkpoint: 2,
|
||||
evidence: [EvidenceKind.independentSuccess],
|
||||
);
|
||||
|
||||
final req = SyncMerger.buildPushRequest(state, deviceName: 'MacBook');
|
||||
expect(req.deviceName, 'MacBook');
|
||||
expect(req.progress?.completedLessonIds, contains('a0-01'));
|
||||
expect(req.masteryUpdates.any((m) => m.itemId == 'A0-P01'), isTrue);
|
||||
final item = req.masteryUpdates.firstWhere((m) => m.itemId == 'A0-P01');
|
||||
expect(item.checkpoint, 2);
|
||||
expect(item.status, 'recall');
|
||||
});
|
||||
|
||||
test('applyPullResponse merges lessons by union and upgrades mastery by max checkpoint', () {
|
||||
final state = AppState();
|
||||
state.completedLessonIds.add('a0-01');
|
||||
state.completedLessons = 1;
|
||||
state.mastery['A0-P01'] = const MasteryItem(
|
||||
id: 'A0-P01',
|
||||
label: 'I am Shen',
|
||||
status: MasteryStatus.recognize,
|
||||
checkpoint: 1,
|
||||
evidence: [],
|
||||
);
|
||||
|
||||
final remotePull = SyncPullResponse(
|
||||
serverTime: '2026-09-16T10:00:00Z',
|
||||
progress: const SyncProgressPayload(
|
||||
activeLessonId: 'a0-03',
|
||||
completedLessonIds: ['a0-01', 'a0-02'],
|
||||
completedSegmentIds: ['a0-01-s1', 'a0-02-s1'],
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
masteryUpdates: [
|
||||
const SyncMasteryItemPayload(
|
||||
itemId: 'A0-P01',
|
||||
checkpoint: 3,
|
||||
status: 'use',
|
||||
dueAt: '2026-09-20T10:00:00Z',
|
||||
payload: {'label': 'I am Shen'},
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
const SyncMasteryItemPayload(
|
||||
itemId: 'A0-P02',
|
||||
checkpoint: 1,
|
||||
status: 'recognize',
|
||||
dueAt: '2026-09-18T10:00:00Z',
|
||||
payload: {'label': 'Thank you'},
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
],
|
||||
profile: const SyncProfilePayload(
|
||||
onboardingComplete: true,
|
||||
goal: 'dailyLife',
|
||||
updatedAt: '2026-09-16T10:00:00Z',
|
||||
),
|
||||
);
|
||||
|
||||
final changed = SyncMerger.applyPullResponse(state, remotePull);
|
||||
expect(changed, isTrue);
|
||||
// Lesson union
|
||||
expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02']));
|
||||
expect(state.completedLessons, 2);
|
||||
// Mastery max checkpoint upgrade
|
||||
expect(state.mastery['A0-P01']?.checkpoint, 3);
|
||||
expect(state.mastery['A0-P01']?.status, MasteryStatus.use);
|
||||
// New item added from remote
|
||||
expect(state.mastery['A0-P02']?.checkpoint, 1);
|
||||
expect(state.mastery['A0-P02']?.label, 'Thank you');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncService HTTP operations', () {
|
||||
test('testConnection returns true on 200 health check', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/health') {
|
||||
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final ok = await service.testConnection('http://127.0.0.1:8080');
|
||||
expect(ok, isTrue);
|
||||
});
|
||||
|
||||
test('register and login return auth tokens', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/auth/register' ||
|
||||
request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'user_id': 'usr-888',
|
||||
'username': 'shen',
|
||||
'token': 'jwt_mock_token_abc',
|
||||
'expires_in': 604800,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final regRes = await service.register(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'shen',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(regRes.userId, 'usr-888');
|
||||
expect(regRes.token, 'jwt_mock_token_abc');
|
||||
|
||||
final logRes = await service.login(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'shen',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(logRes.userId, 'usr-888');
|
||||
expect(logRes.token, 'jwt_mock_token_abc');
|
||||
});
|
||||
|
||||
test('pull and push send and receive data correctly', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/sync/pull') {
|
||||
expect(request.headers['Authorization'], 'Bearer mock_token');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T12:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-01',
|
||||
'completed_lesson_ids': ['a0-01'],
|
||||
'completed_segment_ids': ['a0-01-s1'],
|
||||
'active_step': 'speaking',
|
||||
'streak_days': 1,
|
||||
'updated_at': '2026-09-16T12:00:00Z',
|
||||
},
|
||||
'mastery_updates': [],
|
||||
'profile': null,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/push') {
|
||||
expect(request.headers['Authorization'], 'Bearer mock_token');
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {'server_time': '2026-09-16T12:05:00Z'}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final pullResp = await service.pull(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
token: 'mock_token',
|
||||
);
|
||||
expect(pullResp.progress?.completedLessonIds, ['a0-01']);
|
||||
|
||||
final sTime = await service.push(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
token: 'mock_token',
|
||||
request: const SyncPushRequest(clientTime: '2026-09-16T12:04:00Z'),
|
||||
);
|
||||
expect(sTime, '2026-09-16T12:05:00Z');
|
||||
});
|
||||
});
|
||||
|
||||
group('SyncCoordinator Integration', () {
|
||||
test('login, syncNow and logout lifecycle', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
if (request.url.path == '/api/v1/auth/login') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'user_id': 'u100',
|
||||
'username': 'tester',
|
||||
'token': 'auth_token_999',
|
||||
'expires_in': 604800,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/pull') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {
|
||||
'server_time': '2026-09-16T15:00:00Z',
|
||||
'progress': {
|
||||
'active_lesson_id': 'a0-01',
|
||||
'completed_lesson_ids': ['a0-01'],
|
||||
'completed_segment_ids': ['a0-01-s1'],
|
||||
'active_step': 'reading',
|
||||
'streak_days': 1,
|
||||
'updated_at': '2026-09-16T15:00:00Z',
|
||||
},
|
||||
'mastery_updates': [],
|
||||
'profile': null,
|
||||
}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
} else if (request.url.path == '/api/v1/sync/push') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'code': 0,
|
||||
'message': 'success',
|
||||
'data': {'server_time': '2026-09-16T15:00:01Z'}
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('Not Found', 404);
|
||||
});
|
||||
|
||||
final service = SyncService(client: mockClient);
|
||||
final coordinator = SyncCoordinator.createForTesting(service: service);
|
||||
await coordinator.init();
|
||||
|
||||
expect(coordinator.isLoggedIn, isFalse);
|
||||
|
||||
final loginSuccess = await coordinator.login(
|
||||
serverUrl: 'http://127.0.0.1:8080',
|
||||
username: 'tester',
|
||||
password: 'password123',
|
||||
);
|
||||
expect(loginSuccess, isTrue);
|
||||
expect(coordinator.isLoggedIn, isTrue);
|
||||
expect(coordinator.username, 'tester');
|
||||
|
||||
final state = AppState();
|
||||
final syncSuccess = await coordinator.syncNow(state);
|
||||
expect(syncSuccess, isTrue);
|
||||
expect(coordinator.state, SyncState.success);
|
||||
expect(coordinator.lastSyncTime, isNotNull);
|
||||
expect(state.completedLessonIds, contains('a0-01'));
|
||||
|
||||
await coordinator.logout();
|
||||
expect(coordinator.isLoggedIn, isFalse);
|
||||
expect(coordinator.config.token, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -41,4 +41,19 @@ void main() {
|
||||
);
|
||||
expect(check.onPressed, isNotNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('speaking step displays use mic and play repeat buttons', (
|
||||
tester,
|
||||
) async {
|
||||
final state = AppState()..lessonStep = LessonStep.speaking;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('跟读'), findsOneWidget);
|
||||
expect(find.text('使用麦克风跟读'), findsOneWidget);
|
||||
expect(find.text('播放跟读'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application source
|
||||
COPY main.py .
|
||||
|
||||
# Storage volume
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
# Run with uvicorn
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"]
|
||||
@@ -0,0 +1,40 @@
|
||||
# 芽说英语 (SpeakSprout) - 自建跨平台多端同步服务
|
||||
|
||||
轻量、高性能、本地优先的跨平台(Android / iOS / macOS)学习进度与艾宾浩斯复习同步服务。
|
||||
|
||||
## 🌟 核心特性
|
||||
- **极简架构**:基于 Python 3.11 + FastAPI + aiosqlite,单容器内存占用 < 30MB。
|
||||
- **无损合并 (CRDT/LWW)**:课程通关自动取并集,艾宾浩斯复习阶段取更高 Checkpoint,绝不发生进度回退。
|
||||
- **离线优先 (Offline-First)**:弱网/断网下客户端完全正常学习,恢复网络后自动增量合并同步。
|
||||
- **安全认证**:bcrypt 密码加盐哈希 + JWT 鉴权。
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### 方案 1:Docker Compose 一键启动(推荐)
|
||||
```bash
|
||||
# 1. 复制目录到您的服务器
|
||||
cd sync_server
|
||||
|
||||
# 2. 一键启动
|
||||
docker compose up -d --build
|
||||
|
||||
# 3. 检查服务健康状态
|
||||
curl http://127.0.0.1:8080/api/v1/health
|
||||
```
|
||||
|
||||
### 方案 2:直接 Python 运行
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
## 🧪 自动化测试
|
||||
```bash
|
||||
python test_server.py
|
||||
```
|
||||
|
||||
## 📡 API 快速参考
|
||||
- `POST /api/v1/auth/register` : 注册新账号
|
||||
- `POST /api/v1/auth/login` : 登录账号获取 Token
|
||||
- `GET /api/v1/sync/pull` : 增量拉取云端学习进度
|
||||
- `POST /api/v1/sync/push` : 增量推送本地最新进度
|
||||
@@ -0,0 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
speaksprout-sync:
|
||||
build: .
|
||||
container_name: speaksprout-sync
|
||||
restart: always
|
||||
environment:
|
||||
- DATABASE_PATH=/data/speaksprout_sync.db
|
||||
- JWT_SECRET=speaksprout-production-jwt-secret-key-replace-with-random-hex-2026
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/v1/health')"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
芽说英语 (SpeakSprout) - 自建跨平台多端同步服务
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import jwt
|
||||
import bcrypt
|
||||
import aiosqlite
|
||||
from fastapi import FastAPI, HTTPException, Depends, Header, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("speaksprout_sync")
|
||||
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "./data/speaksprout_sync.db")
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "speaksprout-secure-sync-secret-key-2026-v1-production-secure")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRATION_DAYS = 60
|
||||
|
||||
app = FastAPI(
|
||||
title="芽说英语 · SpeakSprout 同步服务",
|
||||
description="支持 Android / iOS / macOS 三端学习进度与艾宾浩斯复习的自建同步服务",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ----------------- 数据契约 (DTO) -----------------
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6)
|
||||
device_name: Optional[str] = "Unknown Device"
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
device_name: Optional[str] = "Unknown Device"
|
||||
|
||||
class LessonProgressDTO(BaseModel):
|
||||
active_lesson_id: str
|
||||
completed_lesson_ids: List[str] = []
|
||||
completed_segment_ids: List[str] = []
|
||||
active_step: Optional[str] = "preview"
|
||||
streak_days: Optional[int] = 0
|
||||
updated_at: str
|
||||
|
||||
class MasteryItemDTO(BaseModel):
|
||||
item_id: str
|
||||
checkpoint: int
|
||||
status: str
|
||||
due_at: str
|
||||
successful_reviews: int = 0
|
||||
attempts: int = 0
|
||||
payload: Optional[Dict[str, Any]] = {}
|
||||
updated_at: str
|
||||
|
||||
class ProfileDTO(BaseModel):
|
||||
onboarding_complete: Optional[bool] = True
|
||||
goal: Optional[str] = "travel"
|
||||
placement: Optional[str] = "A0"
|
||||
daily_minutes: Optional[int] = 20
|
||||
show_chinese_hints: Optional[bool] = True
|
||||
ai_endpoint: Optional[str] = ""
|
||||
ai_model: Optional[str] = ""
|
||||
ai_provider: Optional[str] = ""
|
||||
settings_payload: Optional[Dict[str, Any]] = {}
|
||||
updated_at: str
|
||||
|
||||
class SyncPushRequest(BaseModel):
|
||||
client_time: str
|
||||
device_name: Optional[str] = None
|
||||
progress: Optional[LessonProgressDTO] = None
|
||||
mastery_updates: Optional[List[MasteryItemDTO]] = []
|
||||
profile: Optional[ProfileDTO] = None
|
||||
|
||||
# ----------------- 数据库连接与表结构 -----------------
|
||||
async def get_db():
|
||||
async with aiosqlite.connect(DATABASE_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
yield db
|
||||
|
||||
async def init_db():
|
||||
db_dir = os.path.dirname(os.path.abspath(DATABASE_PATH))
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
async with aiosqlite.connect(DATABASE_PATH) as db:
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
device_names TEXT DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_lesson_progress (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
active_lesson_id TEXT NOT NULL,
|
||||
completed_lesson_ids TEXT NOT NULL,
|
||||
completed_segment_ids TEXT NOT NULL,
|
||||
active_step TEXT NOT NULL DEFAULT 'preview',
|
||||
streak_days INTEGER NOT NULL DEFAULT 0,
|
||||
client_updated_at TEXT NOT NULL,
|
||||
server_updated_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_mastery_records (
|
||||
user_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
checkpoint INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
due_at TEXT NOT NULL,
|
||||
successful_reviews INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
client_updated_at TEXT NOT NULL,
|
||||
server_updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, item_id)
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
onboarding_complete INTEGER NOT NULL DEFAULT 1,
|
||||
goal TEXT DEFAULT 'travel',
|
||||
placement TEXT DEFAULT 'A0',
|
||||
daily_minutes INTEGER DEFAULT 20,
|
||||
show_chinese_hints INTEGER DEFAULT 1,
|
||||
ai_endpoint TEXT DEFAULT '',
|
||||
ai_model TEXT DEFAULT '',
|
||||
ai_provider TEXT DEFAULT '',
|
||||
settings_payload TEXT NOT NULL DEFAULT '{}',
|
||||
client_updated_at TEXT NOT NULL,
|
||||
server_updated_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
await db.commit()
|
||||
logger.info("✅ 数据库表初始化完成: %s", DATABASE_PATH)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
await init_db()
|
||||
|
||||
# ----------------- 鉴权逻辑 -----------------
|
||||
def create_jwt(user_id: str, username: str) -> str:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"username": username,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(days=JWT_EXPIRATION_DAYS)
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> Dict[str, str]:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供有效的认证凭证")
|
||||
token = authorization.split(" ")[1]
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
return {"user_id": payload["sub"], "username": payload.get("username", "")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录凭证已过期,请重新登录")
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证凭证")
|
||||
|
||||
# ----------------- 控制器路由 -----------------
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"app": "芽说英语 · SpeakSprout Sync Service",
|
||||
"status": "online",
|
||||
"version": "1.0.0",
|
||||
"server_time": datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
def health():
|
||||
return {"status": "ok", "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat()}
|
||||
|
||||
@app.post("/api/v1/auth/register")
|
||||
async def register(req: RegisterRequest, db: aiosqlite.Connection = Depends(get_db)):
|
||||
clean_username = req.username.strip().lower()
|
||||
cursor = await db.execute("SELECT id FROM users WHERE username = ?", (clean_username,))
|
||||
if await cursor.fetchone():
|
||||
raise HTTPException(status_code=400, detail="该账号已被注册,请直接登录")
|
||||
|
||||
salt = bcrypt.gensalt()
|
||||
pwd_hash = bcrypt.hashpw(req.password.encode('utf-8'), salt).decode('utf-8')
|
||||
user_id = f"u_{uuid.uuid4().hex[:12]}"
|
||||
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
devices = json.dumps([req.device_name] if req.device_name else [])
|
||||
|
||||
await db.execute(
|
||||
"INSERT INTO users (id, username, password_hash, device_names, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user_id, clean_username, pwd_hash, devices, now_iso, now_iso)
|
||||
)
|
||||
await db.commit()
|
||||
token = create_jwt(user_id, clean_username)
|
||||
logger.info("🎉 新用户注册: %s (%s)", clean_username, user_id)
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "注册成功",
|
||||
"data": {
|
||||
"user_id": user_id,
|
||||
"username": clean_username,
|
||||
"token": token,
|
||||
"expires_in": JWT_EXPIRATION_DAYS * 86400
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/api/v1/auth/login")
|
||||
async def login(req: LoginRequest, db: aiosqlite.Connection = Depends(get_db)):
|
||||
clean_username = req.username.strip().lower()
|
||||
cursor = await db.execute("SELECT id, password_hash, device_names FROM users WHERE username = ?", (clean_username,))
|
||||
row = await cursor.fetchone()
|
||||
if not row or not bcrypt.checkpw(req.password.encode('utf-8'), row["password_hash"].encode('utf-8')):
|
||||
raise HTTPException(status_code=400, detail="账号或密码错误")
|
||||
|
||||
user_id = row["id"]
|
||||
try:
|
||||
devices = json.loads(row["device_names"])
|
||||
if req.device_name and req.device_name not in devices:
|
||||
devices.append(req.device_name)
|
||||
await db.execute(
|
||||
"UPDATE users SET device_names = ?, updated_at = ? WHERE id = ?",
|
||||
(json.dumps(devices), datetime.datetime.now(datetime.timezone.utc).isoformat(), user_id)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
token = create_jwt(user_id, clean_username)
|
||||
logger.info("🔑 用户登录: %s (%s)", clean_username, user_id)
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "登录成功",
|
||||
"data": {
|
||||
"user_id": user_id,
|
||||
"username": clean_username,
|
||||
"token": token,
|
||||
"expires_in": JWT_EXPIRATION_DAYS * 86400
|
||||
}
|
||||
}
|
||||
|
||||
@app.get("/api/v1/sync/pull")
|
||||
async def sync_pull(
|
||||
since: Optional[str] = None,
|
||||
auth: Dict[str, str] = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)
|
||||
):
|
||||
user_id = auth["user_id"]
|
||||
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
# 1. 课程进度
|
||||
cursor = await db.execute("SELECT * FROM user_lesson_progress WHERE user_id = ?", (user_id,))
|
||||
p_row = await cursor.fetchone()
|
||||
progress_data = None
|
||||
if p_row:
|
||||
progress_data = {
|
||||
"active_lesson_id": p_row["active_lesson_id"],
|
||||
"completed_lesson_ids": json.loads(p_row["completed_lesson_ids"]),
|
||||
"completed_segment_ids": json.loads(p_row["completed_segment_ids"]),
|
||||
"active_step": p_row["active_step"],
|
||||
"streak_days": p_row["streak_days"],
|
||||
"updated_at": p_row["server_updated_at"]
|
||||
}
|
||||
|
||||
# 2. 复习掌握项 (增量)
|
||||
if since:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM user_mastery_records WHERE user_id = ? AND server_updated_at > ?",
|
||||
(user_id, since)
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute("SELECT * FROM user_mastery_records WHERE user_id = ?", (user_id,))
|
||||
|
||||
m_rows = await cursor.fetchall()
|
||||
mastery_list = []
|
||||
for r in m_rows:
|
||||
mastery_list.append({
|
||||
"item_id": r["item_id"],
|
||||
"checkpoint": r["checkpoint"],
|
||||
"status": r["status"],
|
||||
"due_at": r["due_at"],
|
||||
"successful_reviews": r["successful_reviews"],
|
||||
"attempts": r["attempts"],
|
||||
"payload": json.loads(r["payload"]),
|
||||
"updated_at": r["server_updated_at"]
|
||||
})
|
||||
|
||||
# 3. 用户画像
|
||||
cursor = await db.execute("SELECT * FROM user_profiles WHERE user_id = ?", (user_id,))
|
||||
prof_row = await cursor.fetchone()
|
||||
profile_data = None
|
||||
if prof_row:
|
||||
profile_data = {
|
||||
"onboarding_complete": bool(prof_row["onboarding_complete"]),
|
||||
"goal": prof_row["goal"],
|
||||
"placement": prof_row["placement"],
|
||||
"daily_minutes": prof_row["daily_minutes"],
|
||||
"show_chinese_hints": bool(prof_row["show_chinese_hints"]),
|
||||
"ai_endpoint": prof_row["ai_endpoint"],
|
||||
"ai_model": prof_row["ai_model"],
|
||||
"ai_provider": prof_row["ai_provider"],
|
||||
"settings_payload": json.loads(prof_row["settings_payload"]),
|
||||
"updated_at": prof_row["server_updated_at"]
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "拉取成功",
|
||||
"data": {
|
||||
"server_time": now_iso,
|
||||
"progress": progress_data,
|
||||
"mastery_updates": mastery_list,
|
||||
"profile": profile_data
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/api/v1/sync/push")
|
||||
async def sync_push(
|
||||
req: SyncPushRequest,
|
||||
auth: Dict[str, str] = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db)
|
||||
):
|
||||
user_id = auth["user_id"]
|
||||
server_now = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
# 1. 合并课程进度 (Union 并集)
|
||||
if req.progress:
|
||||
p = req.progress
|
||||
cursor = await db.execute("SELECT * FROM user_lesson_progress WHERE user_id = ?", (user_id,))
|
||||
existing = await cursor.fetchone()
|
||||
if existing:
|
||||
local_lessons = set(json.loads(existing["completed_lesson_ids"]))
|
||||
incoming_lessons = set(p.completed_lesson_ids)
|
||||
merged_lessons = list(local_lessons.union(incoming_lessons))
|
||||
|
||||
local_segments = set(json.loads(existing["completed_segment_ids"]))
|
||||
incoming_segments = set(p.completed_segment_ids)
|
||||
merged_segments = list(local_segments.union(incoming_segments))
|
||||
|
||||
active_lesson = p.active_lesson_id if p.updated_at >= existing["client_updated_at"] else existing["active_lesson_id"]
|
||||
streak = max(p.streak_days or 0, existing["streak_days"] or 0)
|
||||
|
||||
await db.execute("""
|
||||
UPDATE user_lesson_progress
|
||||
SET active_lesson_id = ?, completed_lesson_ids = ?, completed_segment_ids = ?,
|
||||
active_step = ?, streak_days = ?, client_updated_at = ?, server_updated_at = ?
|
||||
WHERE user_id = ?
|
||||
""", (active_lesson, json.dumps(merged_lessons), json.dumps(merged_segments),
|
||||
p.active_step or "preview", streak, p.updated_at, server_now, user_id))
|
||||
else:
|
||||
await db.execute("""
|
||||
INSERT INTO user_lesson_progress (user_id, active_lesson_id, completed_lesson_ids, completed_segment_ids,
|
||||
active_step, streak_days, client_updated_at, server_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (user_id, p.active_lesson_id, json.dumps(p.completed_lesson_ids), json.dumps(p.completed_segment_ids),
|
||||
p.active_step or "preview", p.streak_days or 0, p.updated_at, server_now))
|
||||
|
||||
# 2. 合并复习掌握项 (Max Checkpoint + Latest Timestamp)
|
||||
updated_mastery_count = 0
|
||||
if req.mastery_updates:
|
||||
for m in req.mastery_updates:
|
||||
cursor = await db.execute(
|
||||
"SELECT checkpoint, client_updated_at FROM user_mastery_records WHERE user_id = ? AND item_id = ?",
|
||||
(user_id, m.item_id)
|
||||
)
|
||||
existing_m = await cursor.fetchone()
|
||||
|
||||
should_update = False
|
||||
if not existing_m:
|
||||
should_update = True
|
||||
else:
|
||||
if m.checkpoint > existing_m["checkpoint"]:
|
||||
should_update = True
|
||||
elif m.checkpoint == existing_m["checkpoint"] and m.updated_at >= existing_m["client_updated_at"]:
|
||||
should_update = True
|
||||
|
||||
if should_update:
|
||||
updated_mastery_count += 1
|
||||
await db.execute("""
|
||||
INSERT INTO user_mastery_records (user_id, item_id, checkpoint, status, due_at,
|
||||
successful_reviews, attempts, payload, client_updated_at, server_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||
checkpoint = excluded.checkpoint,
|
||||
status = excluded.status,
|
||||
due_at = excluded.due_at,
|
||||
successful_reviews = excluded.successful_reviews,
|
||||
attempts = excluded.attempts,
|
||||
payload = excluded.payload,
|
||||
client_updated_at = excluded.client_updated_at,
|
||||
server_updated_at = excluded.server_updated_at
|
||||
""", (user_id, m.item_id, m.checkpoint, m.status, m.due_at,
|
||||
m.successful_reviews, m.attempts, json.dumps(m.payload or {}), m.updated_at, server_now))
|
||||
|
||||
# 3. 合并用户配置 (LWW)
|
||||
if req.profile:
|
||||
prof = req.profile
|
||||
cursor = await db.execute("SELECT client_updated_at FROM user_profiles WHERE user_id = ?", (user_id,))
|
||||
existing_prof = await cursor.fetchone()
|
||||
|
||||
if not existing_prof or prof.updated_at >= existing_prof["client_updated_at"]:
|
||||
await db.execute("""
|
||||
INSERT INTO user_profiles (user_id, onboarding_complete, goal, placement, daily_minutes,
|
||||
show_chinese_hints, ai_endpoint, ai_model, ai_provider, settings_payload,
|
||||
client_updated_at, server_updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
onboarding_complete = excluded.onboarding_complete,
|
||||
goal = excluded.goal,
|
||||
placement = excluded.placement,
|
||||
daily_minutes = excluded.daily_minutes,
|
||||
show_chinese_hints = excluded.show_chinese_hints,
|
||||
ai_endpoint = excluded.ai_endpoint,
|
||||
ai_model = excluded.ai_model,
|
||||
ai_provider = excluded.ai_provider,
|
||||
settings_payload = excluded.settings_payload,
|
||||
client_updated_at = excluded.client_updated_at,
|
||||
server_updated_at = excluded.server_updated_at
|
||||
""", (user_id, 1 if prof.onboarding_complete else 0, prof.goal or "travel", prof.placement or "A0",
|
||||
prof.daily_minutes or 20, 1 if prof.show_chinese_hints else 0, prof.ai_endpoint or "",
|
||||
prof.ai_model or "", prof.ai_provider or "", json.dumps(prof.settings_payload or {}),
|
||||
prof.updated_at, server_now))
|
||||
|
||||
await db.commit()
|
||||
logger.info("🔄 数据推送完成 [user=%s]: 词汇=%d", user_id, updated_mastery_count)
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "同步成功",
|
||||
"data": {
|
||||
"server_time": server_now,
|
||||
"synced_mastery_count": updated_mastery_count
|
||||
}
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8080, reload=True)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Nginx 反向代理配置示例
|
||||
server {
|
||||
listen 80;
|
||||
server_name sync.yourdomain.com;
|
||||
|
||||
# 强制重定向至 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name sync.yourdomain.com;
|
||||
|
||||
# SSL 证书路径(请根据实际证书位置配置,如 Certbot Let's Encrypt)
|
||||
ssl_certificate /etc/letsencrypt/live/sync.yourdomain.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sync.yourdomain.com/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
# 同步接口反向代理
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 支持 HTTP/1.1 长连接
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
aiosqlite>=0.19.0
|
||||
pyjwt>=2.8.0
|
||||
bcrypt>=4.0.0
|
||||
pydantic>=2.0.0
|
||||
python-multipart>=0.0.6
|
||||
httpx>=0.24.0
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
芽说英语 (SpeakSprout) - 同步服务自动化端到端测试
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
# Use temporary database for tests
|
||||
temp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
os.environ["DATABASE_PATH"] = temp_db.name
|
||||
os.environ["JWT_SECRET"] = "test-secret-key-1234567890-secure-32b"
|
||||
|
||||
from main import app, init_db
|
||||
|
||||
async def test_full_sync_flow():
|
||||
await init_db()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
# 1. Health check
|
||||
res = await ac.get("/api/v1/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["status"] == "ok"
|
||||
|
||||
# 2. Register
|
||||
reg_res = await ac.post("/api/v1/auth/register", json={
|
||||
"username": "test_user_01",
|
||||
"password": "Password123!",
|
||||
"device_name": "Test iPhone"
|
||||
})
|
||||
assert reg_res.status_code == 200
|
||||
reg_data = reg_res.json()
|
||||
assert reg_data["code"] == 0
|
||||
token = reg_data["data"]["token"]
|
||||
user_id = reg_data["data"]["user_id"]
|
||||
assert token and user_id
|
||||
|
||||
# 3. Duplicate register should fail
|
||||
dup_res = await ac.post("/api/v1/auth/register", json={
|
||||
"username": "test_user_01",
|
||||
"password": "Password123!"
|
||||
})
|
||||
assert dup_res.status_code == 400
|
||||
|
||||
# 4. Login
|
||||
login_res = await ac.post("/api/v1/auth/login", json={
|
||||
"username": "test_user_01",
|
||||
"password": "Password123!",
|
||||
"device_name": "Test Mac"
|
||||
})
|
||||
assert login_res.status_code == 200
|
||||
assert login_res.json()["data"]["token"]
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 5. Push progress from Device 1 (iPhone)
|
||||
push_1 = await ac.post("/api/v1/sync/push", headers=headers, json={
|
||||
"client_time": "2026-09-16T10:00:00Z",
|
||||
"device_name": "Test iPhone",
|
||||
"progress": {
|
||||
"active_lesson_id": "a0-02",
|
||||
"completed_lesson_ids": ["a0-01"],
|
||||
"completed_segment_ids": ["a0-01-a", "a0-01-b"],
|
||||
"active_step": "speaking",
|
||||
"streak_days": 1,
|
||||
"updated_at": "2026-09-16T10:00:00Z"
|
||||
},
|
||||
"mastery_updates": [
|
||||
{
|
||||
"item_id": "A0-W01",
|
||||
"checkpoint": 1,
|
||||
"status": "learning",
|
||||
"due_at": "2026-09-17T10:00:00Z",
|
||||
"successful_reviews": 1,
|
||||
"attempts": 1,
|
||||
"updated_at": "2026-09-16T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"profile": {
|
||||
"daily_minutes": 20,
|
||||
"show_chinese_hints": True,
|
||||
"updated_at": "2026-09-16T10:00:00Z"
|
||||
}
|
||||
})
|
||||
assert push_1.status_code == 200
|
||||
assert push_1.json()["data"]["synced_mastery_count"] == 1
|
||||
|
||||
# 6. Push progress from Device 2 (Mac) with non-overlapping lesson and higher checkpoint
|
||||
push_2 = await ac.post("/api/v1/sync/push", headers=headers, json={
|
||||
"client_time": "2026-09-16T12:00:00Z",
|
||||
"device_name": "Test Mac",
|
||||
"progress": {
|
||||
"active_lesson_id": "a0-03",
|
||||
"completed_lesson_ids": ["a0-02"],
|
||||
"completed_segment_ids": ["a0-02-a"],
|
||||
"active_step": "dialogue",
|
||||
"streak_days": 2,
|
||||
"updated_at": "2026-09-16T12:00:00Z"
|
||||
},
|
||||
"mastery_updates": [
|
||||
{
|
||||
"item_id": "A0-W01",
|
||||
"checkpoint": 2, # Upgraded checkpoint
|
||||
"status": "reviewing",
|
||||
"due_at": "2026-09-19T12:00:00Z",
|
||||
"successful_reviews": 2,
|
||||
"attempts": 2,
|
||||
"updated_at": "2026-09-16T12:00:00Z"
|
||||
},
|
||||
{
|
||||
"item_id": "A0-W02",
|
||||
"checkpoint": 1,
|
||||
"status": "learning",
|
||||
"due_at": "2026-09-17T12:00:00Z",
|
||||
"successful_reviews": 1,
|
||||
"attempts": 1,
|
||||
"updated_at": "2026-09-16T12:00:00Z"
|
||||
}
|
||||
]
|
||||
})
|
||||
assert push_2.status_code == 200
|
||||
|
||||
# 7. Pull merged progress
|
||||
pull_res = await ac.get("/api/v1/sync/pull", headers=headers)
|
||||
assert pull_res.status_code == 200
|
||||
p_data = pull_res.json()["data"]
|
||||
|
||||
# Verify Union of completed lessons: ["a0-01", "a0-02"]
|
||||
assert "a0-01" in p_data["progress"]["completed_lesson_ids"]
|
||||
assert "a0-02" in p_data["progress"]["completed_lesson_ids"]
|
||||
assert p_data["progress"]["active_lesson_id"] == "a0-03"
|
||||
|
||||
# Verify Mastery items have merged highest checkpoint
|
||||
mastery_map = {item["item_id"]: item for item in p_data["mastery_updates"]}
|
||||
assert mastery_map["A0-W01"]["checkpoint"] == 2
|
||||
assert mastery_map["A0-W02"]["checkpoint"] == 1
|
||||
|
||||
print("\n✅ All Backend Sync API tests passed with flying colors!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(test_full_sync_flow())
|
||||