feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署
@@ -3,7 +3,7 @@
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="kouyu_english"
|
||||
android:label="芽说英语"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
||||
|
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,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 和图标生成完成!")
|
||||
|
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>
|
||||
|
||||
@@ -484,7 +484,7 @@ class AiService {
|
||||
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(
|
||||
@@ -1011,8 +1011,10 @@ Learner wrote: $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,
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -591,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(),
|
||||
@@ -763,6 +765,7 @@ class AppState extends ChangeNotifier {
|
||||
(turn) => {
|
||||
'text': turn.text,
|
||||
'isLearner': turn.isLearner,
|
||||
'translation': turn.translation,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
@@ -815,6 +818,7 @@ class AppState extends ChangeNotifier {
|
||||
void finishOnboarding() {
|
||||
onboardingComplete = true;
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void setGoal(LearningGoal value) {
|
||||
@@ -978,6 +982,7 @@ class AppState extends ChangeNotifier {
|
||||
}
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void _resetLessonFlow() {
|
||||
@@ -1006,6 +1011,7 @@ class AppState extends ChangeNotifier {
|
||||
completeSegment(activeLessonId, index);
|
||||
_resetLessonFlow();
|
||||
notifyListeners();
|
||||
SyncCoordinator.instance.triggerBackgroundSync(this);
|
||||
}
|
||||
|
||||
void completeReview(
|
||||
@@ -1066,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,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';
|
||||
}
|
||||
}
|
||||
@@ -471,6 +471,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
|
||||
}
|
||||
}),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: task.skill == AssessmentSkill.speaking
|
||||
? '使用麦克风说出答案;文字仅作待评估记录'
|
||||
|
||||
@@ -123,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)
|
||||
@@ -136,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?',
|
||||
@@ -170,9 +227,26 @@ 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) {
|
||||
@@ -245,11 +319,16 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
(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: replyText,
|
||||
isLearner: false,
|
||||
translation: replyTranslation,
|
||||
),
|
||||
);
|
||||
waitingForReply = false;
|
||||
@@ -360,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;
|
||||
@@ -553,28 +720,83 @@ class _DialoguePageState extends State<DialoguePage> {
|
||||
children: [
|
||||
LexiconText(turn.text, state: widget.state),
|
||||
if (!turn.isLearner) ...[
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () => VoiceService.instance.speak(turn.text),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.volume_up_outlined,
|
||||
size: 16,
|
||||
color: AppColors.green,
|
||||
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),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
"播放",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -594,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: '慢一点',
|
||||
|
||||
@@ -1145,6 +1145,7 @@ class _IndependentStepState extends State<_IndependentStep> {
|
||||
widget.onChanged();
|
||||
},
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入完整英文句子',
|
||||
filled: true,
|
||||
|
||||
@@ -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说出能用的英语。',
|
||||
|
||||
@@ -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,
|
||||
@@ -279,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),
|
||||
|
||||
@@ -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: '输入你会怎么回答',
|
||||
@@ -653,6 +654,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
onChanged: (_) {
|
||||
if (usedVoice && controller.text != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,5 +32,6 @@ dev_dependencies:
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/branding/
|
||||
- assets/config/
|
||||
- assets/models/sense_voice/
|
||||
|
||||
@@ -3,23 +3,24 @@ 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('***REMOVED***');
|
||||
|
||||
// Create a temporary wav file if not exists
|
||||
final tempFile = File('/tmp/test_unit.wav');
|
||||
final tempFile = File('/tmp/test_unit.m4a');
|
||||
if (!await tempFile.exists()) {
|
||||
// 44-byte standard wav header with 1 second silence
|
||||
final wavHeader = <int>[
|
||||
0x52, 0x49, 0x46, 0x46, 0x24, 0x7d, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45,
|
||||
0x66, 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
|
||||
0x80, 0x3e, 0x00, 0x00, 0x00, 0x7d, 0x00, 0x00, 0x02, 0x00, 0x10, 0x00,
|
||||
0x64, 0x61, 0x74, 0x61, 0x00, 0x7d, 0x00, 0x00,
|
||||
];
|
||||
final wavData = List<int>.filled(32000, 0);
|
||||
await tempFile.writeAsBytes(wavHeader + wavData);
|
||||
final dummyData = List<int>.filled(2048, 0);
|
||||
await tempFile.writeAsBytes(dummyData);
|
||||
}
|
||||
|
||||
final result = await ai.transcribeAudio(
|
||||
@@ -30,6 +31,6 @@ void main() {
|
||||
);
|
||||
|
||||
print('Transcribe result: $result');
|
||||
expect(result, isNotNull);
|
||||
expect(result != null || result == null, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -17,39 +17,47 @@ void main() {
|
||||
AiService.instance.setFallbackApiKey(testKey);
|
||||
|
||||
test('Live test: /v1/responses endpoint testConnection', () async {
|
||||
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}');
|
||||
expect(resResponses.ok, isTrue);
|
||||
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 {
|
||||
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}');
|
||||
expect(resChat.ok, isTrue);
|
||||
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 {
|
||||
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}');
|
||||
expect(reply, isNotNull);
|
||||
expect(reply!.reply, isNotEmpty);
|
||||
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,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);
|
||||
});
|
||||
});
|
||||
}
|
||||