feat: tab shell, home/onboarding UI, settings and keyboard polish

Introduce MainTabView with history and Liquid Glass dock; refresh home and
onboarding layouts; unify accent green and provider localization; refine
settings/API rows; redesign keyboard mic and flanking controls; set Utility
iPhone-only targets and Flow session reliability fixes.
This commit is contained in:
Rocky
2026-06-20 00:31:58 +08:00
parent e2ccc10cfd
commit 38a36ed504
42 changed files with 1380 additions and 547 deletions
@@ -16,8 +16,7 @@
<rect x="838.794" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/> <rect x="838.794" y="677.633" width="81.4876" height="81.4876" rx="40.7438" stroke="white" stroke-opacity="0.8" style="stroke:white;stroke-opacity:0.8;" stroke-width="10"/>
<path d="M578.033 284.581C578.033 248.262 548.706 218.82 512.53 218.82C476.353 218.82 447.026 248.262 447.026 284.581C447.026 320.9 476.353 350.343 512.53 350.343V356.163C473.151 356.163 441.229 324.115 441.229 284.581C441.229 245.048 473.151 213 512.53 213C551.908 213 583.83 245.048 583.83 284.581C583.83 324.115 551.908 356.163 512.53 356.163V350.343C548.706 350.343 578.033 320.9 578.033 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/> <path d="M578.033 284.581C578.033 248.262 548.706 218.82 512.53 218.82C476.353 218.82 447.026 248.262 447.026 284.581C447.026 320.9 476.353 350.343 512.53 350.343V356.163C473.151 356.163 441.229 324.115 441.229 284.581C441.229 245.048 473.151 213 512.53 213C551.908 213 583.83 245.048 583.83 284.581C583.83 324.115 551.908 356.163 512.53 356.163V350.343C548.706 350.343 578.033 320.9 578.033 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
<path d="M252 284.581C252 324.115 283.922 356.163 323.301 356.163C362.679 356.163 394.601 324.115 394.601 284.581C394.601 245.048 362.679 213 323.301 213C283.922 213 252 245.048 252 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/> <path d="M252 284.581C252 324.115 283.922 356.163 323.301 356.163C362.679 356.163 394.601 324.115 394.601 284.581C394.601 245.048 362.679 213 323.301 213C283.922 213 252 245.048 252 284.581Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
<path d="M629.896 284.861C629.896 324.24 661.819 356.162 701.197 356.162C740.575 356.162 772.498 324.24 772.498 284.861C772.498 245.483 740.575 213.561 701.197 213.561C661.819 213.561 629.896 245.483 629.896 284.861Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/> <path d="M701.197 213.561C739.824 213.561 771.275 244.276 772.461 282.615L701.197 282.616V288.229H772.418C770.659 326.044 739.446 356.162 701.197 356.162C661.819 356.162 629.897 324.24 629.896 284.861C629.896 245.483 661.819 213.561 701.197 213.561ZM772.498 284.861C772.498 284.912 772.497 284.964 772.497 285.015V284.707C772.497 284.758 772.498 284.81 772.498 284.861Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
<path d="M772.497 282.616L772.497 288.23L701.197 288.23L701.197 282.616L772.497 282.616Z" fill="#5A5A5A" style="fill:#5A5A5A;fill:color(display-p3 0.3529 0.3529 0.3529);fill-opacity:1;"/>
<path d="M447.966 312.933L447.966 307.319L512.53 307.319L512.53 312.933L447.966 312.933Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/> <path d="M447.966 312.933L447.966 307.319L512.53 307.319L512.53 312.933L447.966 312.933Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
<path d="M512.53 268.018V262.404H577.093V268.018H512.53Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/> <path d="M512.53 268.018V262.404H577.093V268.018H512.53Z" fill="#3AA05A" style="fill:#3AA05A;fill:color(display-p3 0.2267 0.6268 0.3534);fill-opacity:1;"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+1 -4
View File
@@ -1,9 +1,6 @@
{ {
"fill" : { "fill" : {
"linear-gradient" : [ "automatic-gradient" : "display-p3:0.13763,0.15253,0.17418,1.00000"
"extended-gray:0.25000,1.00000",
"display-p3:0.13763,0.15253,0.17418,1.00000"
]
}, },
"groups" : [ "groups" : [
{ {
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "apple.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Apple</title><path d="M11.932 6.908c.95 0 2.727-1.291 4.595-1.1.782.032 2.976.316 4.388 2.38-.113.069-2.622 1.528-2.593 4.565.034 3.617 3.166 4.828 3.221 4.85-.029.086-.506 1.723-1.658 3.416-1.002 1.463-2.039 2.919-3.675 2.95-1.606.03-2.125-.955-3.96-.955s-2.409.923-3.931.984c-1.581.06-2.78-1.58-3.79-3.037-2.065-2.98-3.64-8.422-1.527-12.087 1.051-1.824 2.93-2.98 4.969-3.009 1.549-.032 3.011 1.043 3.96 1.043zM16.552 0c.153 1.407-.411 2.817-1.251 3.833-.837 1.013-2.214 1.804-3.555 1.7-.185-1.378.495-2.814 1.27-3.712C13.883.805 15.346.05 16.553 0z"></path></svg>

After

Width:  |  Height:  |  Size: 729 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "custom.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Custom</title><path d="M4 8h4.34a3 3 0 016.66 0H20a1 1 0 110 2h-5a3 3 0 01-6.66 0H4a1 1 0 110-2zm0 8h9.34a3 3 0 016.66 0H20a1 1 0 110 2h-5a3 3 0 01-6.66 0H4a1 1 0 110-2z"></path></svg>

After

Width:  |  Height:  |  Size: 349 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "deepseek.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "moonshot.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "openai.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "osglogo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "qwen.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "zhipu.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+2
View File
@@ -36,6 +36,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
+10 -2
View File
@@ -12,13 +12,21 @@ struct OSGKeyboardApp: App {
@StateObject private var dictationCoordinator = DictationSessionCoordinator() @StateObject private var dictationCoordinator = DictationSessionCoordinator()
@StateObject private var flowManager = FlowSessionManager() @StateObject private var flowManager = FlowSessionManager()
init() {
MaterialIconsFont.registerIfNeeded()
#if DEBUG
// Reset onboarding on each launch so guide screens can be reviewed while iterating UI.
ProviderConfig.shared.hasCompletedOnboarding = false
ProviderConfig.shared.onboardingPage = 0
#endif
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ThemedRoot { ThemedRoot {
if AppGroup.isAvailable { if AppGroup.isAvailable {
if config.hasCompletedOnboarding { if config.hasCompletedOnboarding {
HomeView() MainTabView()
.onAppear { flowManager.autoStartIfNeeded() }
} else { } else {
OnboardingView(config: config) OnboardingView(config: config)
} }
+58 -10
View File
@@ -29,6 +29,8 @@ final class FlowSessionManager: ObservableObject {
private var levelTask: Task<Void, Never>? private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>? private var startTask: Task<Void, Never>?
private var isUtteranceRecording = false private var isUtteranceRecording = false
/// True from `stopped` until the result/error is written back to App Group.
private var isUtteranceProcessing = false
private var finalizeTask: Task<Void, Never>? private var finalizeTask: Task<Void, Never>?
private var asrTask: Task<Void, Never>? private var asrTask: Task<Void, Never>?
private var currentPartial = "" private var currentPartial = ""
@@ -141,14 +143,15 @@ final class FlowSessionManager: ObservableObject {
levelTask = nil levelTask = nil
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = nil finalizeTask = nil
asrTask?.cancel()
asrTask = nil
if isUtteranceRecording { if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance() capture.cancelUtterance()
asrTask?.cancel()
asr.cancel() asr.cancel()
isUtteranceRecording = false
} }
asrTask = nil
isUtteranceRecording = false
isUtteranceProcessing = false
capture.stop() capture.stop()
FlowSessionBridge.markSessionInactive() FlowSessionBridge.markSessionInactive()
@@ -222,7 +225,7 @@ final class FlowSessionManager: ObservableObject {
private func handleKeyboardSignal() { private func handleKeyboardSignal() {
switch FlowSessionBridge.recordingState() { switch FlowSessionBridge.recordingState() {
case .recording: case .recording:
guard !isUtteranceRecording else { return } guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance() beginUtterance()
case .stopped: case .stopped:
guard isUtteranceRecording else { return } guard isUtteranceRecording else { return }
@@ -239,10 +242,14 @@ final class FlowSessionManager: ObservableObject {
failUtterance(message: NSLocalizedString("flow.error.audioUnavailable", comment: "")) failUtterance(message: NSLocalizedString("flow.error.audioUnavailable", comment: ""))
return return
} }
// Mirror `LiveDictationController.start`: only begin when the previous
// utterance fully finished. Never cancel an in-flight analyzer here
// that was the source of intermittent CancellationError / noSpeech.
guard !isUtteranceProcessing else {
debug("beginUtterance ignored — previous utterance still processing")
return
}
finalizeTask?.cancel()
asrTask?.cancel()
asr.cancel()
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
@@ -271,6 +278,8 @@ final class FlowSessionManager: ObservableObject {
self.debug("asr error: \(message)") self.debug("asr error: \(message)")
if self.isUtteranceRecording { if self.isUtteranceRecording {
self.failUtterance(message: message) self.failUtterance(message: message)
} else if self.isUtteranceProcessing {
self.finishProcessing(withError: message)
} }
} }
} }
@@ -280,9 +289,18 @@ final class FlowSessionManager: ObservableObject {
} }
private func endUtterance() { private func endUtterance() {
isUtteranceRecording = false guard isUtteranceRecording else { return }
// Close the mic gate first, then mark processing before dropping the
// recording flag so the poll loop cannot start a second utterance.
capture.endUtterance() capture.endUtterance()
FlowSessionBridge.setRecordingState(.processing) FlowSessionBridge.setRecordingState(.processing)
isUtteranceRecording = false
isUtteranceProcessing = true
// Do NOT cancel `asrTask` or `asr` the preview pipeline relies on
// the consumer staying alive until `.final` lands (see
// `PreviewASRControllerStateTests`).
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = Task { @MainActor [weak self] in finalizeTask = Task { @MainActor [weak self] in
@@ -293,7 +311,9 @@ final class FlowSessionManager: ObservableObject {
private func abortUtterance() { private func abortUtterance() {
isUtteranceRecording = false isUtteranceRecording = false
isUtteranceProcessing = false
finalizeTask?.cancel() finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel() asrTask?.cancel()
asr.cancel() asr.cancel()
capture.cancelUtterance() capture.cancelUtterance()
@@ -305,19 +325,40 @@ final class FlowSessionManager: ObservableObject {
private func failUtterance(message: String) { private func failUtterance(message: String) {
isUtteranceRecording = false isUtteranceRecording = false
isUtteranceProcessing = false
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel() asrTask?.cancel()
asr.cancel() asr.cancel()
capture.cancelUtterance() capture.cancelUtterance()
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
FlowSessionBridge.storeTranscriptionError(message) FlowSessionBridge.storeTranscriptionError(message)
FlowSessionBridge.setRecordingState(.idle)
debug("utterance failed: \(message)") debug("utterance failed: \(message)")
} }
private func finishProcessing(withError message: String) {
isUtteranceProcessing = false
finalizeTask?.cancel()
finalizeTask = nil
currentPartial = ""
lastFinal = ""
FlowSessionBridge.storeTranscriptionError(message)
FlowSessionBridge.setRecordingState(.idle)
debug("utterance processing failed: \(message)")
}
private func finalizeUtterance() async { private func finalizeUtterance() async {
defer {
isUtteranceProcessing = false
FlowSessionBridge.setRecordingState(.idle)
}
let deadline = Date().addingTimeInterval(30) let deadline = Date().addingTimeInterval(30)
while Date() < deadline { while Date() < deadline {
if !lastFinal.isEmpty { break } if !lastFinal.isEmpty { break }
if asrTask?.isCancelled == true { break }
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
} }
@@ -326,8 +367,11 @@ final class FlowSessionManager: ObservableObject {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines) text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
} }
guard !text.isEmpty else { guard !text.isEmpty else {
let key = (asrTask?.isCancelled == true)
? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech"
FlowSessionBridge.storeTranscriptionError( FlowSessionBridge.storeTranscriptionError(
NSLocalizedString("flow.error.noSpeech", comment: "") NSLocalizedString(key, comment: "")
) )
return return
} }
@@ -336,9 +380,11 @@ final class FlowSessionManager: ObservableObject {
let modeId = store.modeId let modeId = store.modeId
let shouldPolish = engineMode != "local" && modeId == "polish" let shouldPolish = engineMode != "local" && modeId == "polish"
var delivered = text
if shouldPolish { if shouldPolish {
do { do {
let polished = try await polisher.polish(text) let polished = try await polisher.polish(text)
delivered = polished
FlowSessionBridge.storeTranscriptionResult(polished) FlowSessionBridge.storeTranscriptionResult(polished)
} catch { } catch {
FlowSessionBridge.storeTranscriptionResult(text) FlowSessionBridge.storeTranscriptionResult(text)
@@ -347,6 +393,8 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionResult(text) FlowSessionBridge.storeTranscriptionResult(text)
} }
SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode)
currentPartial = "" currentPartial = ""
lastFinal = "" lastFinal = ""
debug("utterance finalized length=\(text.count)") debug("utterance finalized length=\(text.count)")
@@ -0,0 +1,81 @@
// SpeechHistoryStore.swift
// OSGKeyboard · Main App
//
// Local-only log of successful voice transcriptions (Flow + future paths).
import Foundation
import Combine
struct SpeechHistoryEntry: Codable, Identifiable, Equatable {
let id: UUID
let text: String
let createdAt: Date
let engineMode: String
init(id: UUID = UUID(), text: String, createdAt: Date = Date(), engineMode: String) {
self.id = id
self.text = text
self.createdAt = createdAt
self.engineMode = engineMode
}
}
@MainActor
final class SpeechHistoryStore: ObservableObject {
static let shared = SpeechHistoryStore()
@Published private(set) var entries: [SpeechHistoryEntry] = []
private let defaults: UserDefaults
private let storageKey = "speechHistory.entries.v1"
private let maxEntries = 500
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
load()
}
func append(text: String, engineMode: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
entries.insert(entry, at: 0)
if entries.count > maxEntries {
entries = Array(entries.prefix(maxEntries))
}
persist()
}
func clearAll() {
entries.removeAll()
persist()
}
/// Entries grouped by calendar day (newest day first).
var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
let calendar = Calendar.current
var buckets: [Date: [SpeechHistoryEntry]] = [:]
for entry in entries {
let day = calendar.startOfDay(for: entry.createdAt)
buckets[day, default: []].append(entry)
}
return buckets.keys.sorted(by: >).map { day in
(day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
}
}
private func load() {
guard let data = defaults.data(forKey: storageKey) else { return }
do {
entries = try JSONDecoder().decode([SpeechHistoryEntry].self, from: data)
} catch {
entries = []
}
}
private func persist() {
guard let data = try? JSONEncoder().encode(entries) else { return }
defaults.set(data, forKey: storageKey)
}
}
+17 -41
View File
@@ -17,7 +17,7 @@ struct APISettingsCard: View {
private enum TestStatus: Equatable { private enum TestStatus: Equatable {
case idle case idle
case running case running
case success(String) case success
case failure(String) case failure(String)
} }
@@ -48,16 +48,15 @@ struct APISettingsCard: View {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} label: { } label: {
HStack { HStack {
Image(systemName: "key.fill")
.foregroundStyle(palette.accent)
Text("api.getKey") Text("api.getKey")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Image(systemName: "arrow.up.right.square") Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -104,7 +103,7 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
} }
// MARK: - Generic field // MARK: - Generic field
@@ -120,12 +119,6 @@ struct APISettingsCard: View {
Text(title) Text(title)
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
// `.asciiCapable` is the *minimum* contract for these fields:
// API keys, base URLs, and model names are all ASCII by spec,
// and SwiftUI's `.URL` keyboard can still occasionally
// hands control to the system keyboard which then auto-suggests
// Chinese / emoji completions that corrupt the value. Forcing
// `.asciiCapable` keeps the system out of the way.
TextField(placeholder, text: text) TextField(placeholder, text: text)
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
@@ -137,7 +130,7 @@ struct APISettingsCard: View {
and dismissing the sheet unexpectedly */ } and dismissing the sheet unexpectedly */ }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
} }
// MARK: - Test connection // MARK: - Test connection
@@ -146,20 +139,18 @@ struct APISettingsCard: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Text("api.connection") Text("api.connection")
.font(TypeStyle.caption) .font(TypeStyle.body)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Button(action: runTest) { Button(action: runTest) {
HStack(spacing: 6) { Group {
if testStatus == .running { if testStatus == .running {
ProgressView().controlSize(.mini) ProgressView().controlSize(.mini)
} else { } else {
Image(systemName: testIcon) Text(testButtonLabel)
.font(TypeStyle.body)
.foregroundStyle(testTint) .foregroundStyle(testTint)
} }
Text(testButtonLabel)
.font(TypeStyle.caption)
.foregroundStyle(testTint)
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -173,7 +164,8 @@ struct APISettingsCard: View {
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
} }
private var testButtonLabel: String { private var testButtonLabel: String {
@@ -185,27 +177,18 @@ struct APISettingsCard: View {
} }
} }
private var testIcon: String {
switch testStatus {
case .idle, .running: return "bolt.horizontal.circle"
case .success: return "checkmark.circle.fill"
case .failure: return "exclamationmark.triangle.fill"
}
}
private var testTint: Color { private var testTint: Color {
switch testStatus { switch testStatus {
case .idle, .running: return palette.accent case .idle, .running: return palette.accent
case .success: return palette.success case .success: return palette.accent
case .failure: return palette.danger case .failure: return palette.danger
} }
} }
private var testDetail: String? { private var testDetail: String? {
switch testStatus { switch testStatus {
case .idle, .running: return nil case .idle, .running, .success: return nil
case .success(let s): return s case .failure(let message): return message
case .failure(let s): return s
} }
} }
@@ -219,15 +202,8 @@ struct APISettingsCard: View {
) )
Task { Task {
do { do {
let reply = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
// Interpolated success message LocalizedStringKey can't testStatus = .success
// take %@, so we use `String.localizedStringWithFormat`
// with a key that has %@ in the value.
let preview = String(reply.prefix(60))
testStatus = .success(String.localizedStringWithFormat(
NSLocalizedString("api.test.connectedWith", comment: "Connected test prefix"),
preview
))
} catch LLMError.noAPIKey { } catch LLMError.noAPIKey {
testStatus = .failure(NSLocalizedString("api.test.missing", comment: "")) testStatus = .failure(NSLocalizedString("api.test.missing", comment: ""))
} catch let error as LLMError { } catch let error as LLMError {
@@ -0,0 +1,90 @@
// MaterialIcon.swift
// OSGKeyboard · Main App
//
// Google Material Icons (bundled MaterialIcons-Regular.ttf).
// Resolves the PostScript name at runtime and falls back to SF Symbols.
import SwiftUI
import UIKit
import CoreText
enum MaterialIconName {
case keyboard
case menuBook
case settings
case chevronRight
case openInNew
var codepoint: UInt32 {
switch self {
case .keyboard: return 0xE312
case .menuBook: return 0xEA19
case .settings: return 0xE8B8
case .chevronRight: return 0xE5CC
case .openInNew: return 0xE89E
}
}
var sfSymbol: String {
switch self {
case .keyboard: return "keyboard"
case .menuBook: return "book"
case .settings: return "gearshape"
case .chevronRight: return "chevron.right"
case .openInNew: return "arrow.up.right.square"
}
}
}
enum MaterialIconsFont {
private nonisolated(unsafe) static var cachedName: String?
static var postScriptName: String? {
if let cachedName { return cachedName }
let candidates = [
"MaterialIcons-Regular",
"Material Icons Regular",
"Material Icons"
]
for name in candidates where UIFont(name: name, size: 17) != nil {
cachedName = name
return name
}
for family in UIFont.familyNames where family.localizedCaseInsensitiveContains("material") {
for name in UIFont.fontNames(forFamilyName: family) {
cachedName = name
return name
}
}
return nil
}
static func registerIfNeeded() {
guard postScriptName == nil else { return }
guard let url = Bundle.main.url(forResource: "MaterialIcons-Regular", withExtension: "ttf") else {
return
}
var error: Unmanaged<CFError>?
CTFontManagerRegisterFontsForURL(url as CFURL, .process, &error)
cachedName = nil
_ = postScriptName
}
}
struct MaterialIcon: View {
let name: MaterialIconName
var size: CGFloat = 24
var body: some View {
Group {
if let fontName = MaterialIconsFont.postScriptName,
let scalar = UnicodeScalar(name.codepoint) {
Text(String(scalar))
.font(.custom(fontName, size: size))
} else {
Image(systemName: name.sfSymbol)
.font(.system(size: size * 0.92, weight: .regular))
}
}
}
}
@@ -0,0 +1,64 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar three Material icons, no labels.
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
// behind the dock refracts through on scroll.
import SwiftUI
import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
case history
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .history: return .menuBook
case .settings: return .settings
}
}
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .history: return "tab.history"
case .settings: return "tab.settings"
}
}
}
struct MinimalTabBar: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Binding var selection: AppTab
var body: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.quick) { selection = tab }
} label: {
MaterialIcon(
name: tab.icon,
size: 24
)
.foregroundStyle(selection == tab ? palette.accent : palette.textTertiary)
.frame(maxWidth: .infinity)
.frame(height: 48)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(tab.accessibilityKey)
.accessibilityAddTraits(selection == tab ? .isSelected : [])
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 252)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, Spacing.xs)
}
}
@@ -0,0 +1,22 @@
// SafariSheet.swift
// OSGKeyboard · Main App
import SafariServices
import SwiftUI
import OSGKeyboardShared
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
struct SafariSheet: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
let controller = SFSafariViewController(url: url)
controller.preferredControlTintColor = UIColor(Palette.accent)
return controller
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {}
}
+43 -23
View File
@@ -24,22 +24,19 @@ struct EnginePickerSection: View {
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader( sectionHeader("settings.engine.title")
"settings.engine.title",
subtitle: "settings.engine.subtitle"
)
VStack(spacing: 0) { VStack(spacing: 0) {
engineOptionRow( engineOptionRow(
id: "local", id: "local",
icon: "iphone.badge.checkmark", assetName: "apple",
title: NSLocalizedString("settings.engine.local.title", comment: ""), title: NSLocalizedString("settings.engine.local.title", comment: ""),
subtitle: localSubtitle subtitle: localSubtitle
) )
Divider().background(palette.divider) Divider().background(palette.divider)
engineOptionRow( engineOptionRow(
id: "cloud", id: "cloud",
icon: "wand.and.stars", systemIcon: "wand.and.stars",
title: NSLocalizedString("settings.engine.cloud.title", comment: ""), title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "") subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
) )
@@ -58,7 +55,13 @@ struct EnginePickerSection: View {
NSLocalizedString("settings.engine.local.ios26", comment: "") NSLocalizedString("settings.engine.local.ios26", comment: "")
} }
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View { private func engineOptionRow(
id: String,
assetName: String? = nil,
systemIcon: String? = nil,
title: String,
subtitle: String
) -> some View {
let isSelected = config.engineMode == id let isSelected = config.engineMode == id
return Button { return Button {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
@@ -67,10 +70,11 @@ struct EnginePickerSection: View {
} }
} label: { } label: {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
Image(systemName: icon) engineMark(
.font(.system(size: 18, weight: .medium)) assetName: assetName,
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary) systemIcon: systemIcon,
.frame(width: 28) isSelected: isSelected
)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) Text(title)
.font(TypeStyle.body) .font(TypeStyle.body)
@@ -87,23 +91,39 @@ struct EnginePickerSection: View {
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@ViewBuilder @ViewBuilder
private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View { private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
VStack(alignment: .leading, spacing: 2) { ZStack {
Text(title) Circle()
.font(TypeStyle.caption2) .fill(isSelected ? palette.accentMuted : palette.surfaceElevated)
.foregroundStyle(palette.textSecondary) .frame(width: 32, height: 32)
.textCase(.uppercase) if let assetName {
Text(subtitle) Image(assetName)
.font(TypeStyle.caption2) .resizable()
.foregroundStyle(palette.textTertiary) .scaledToFit()
.frame(width: 18, height: 18)
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
} else if let systemIcon {
Image(systemName: systemIcon)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
}
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(width: 32, height: 32)
}
@ViewBuilder
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
+133
View File
@@ -0,0 +1,133 @@
// HistoryView.swift
// OSGKeyboard · Main App
import SwiftUI
import OSGKeyboardShared
struct HistoryView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var store = SpeechHistoryStore.shared
@State private var showClearConfirm = false
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .short
return f
}()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
if store.entries.isEmpty {
emptyState
} else {
VStack(spacing: 0) {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(store.groupedByDay, id: \.day) { group in
daySection(day: group.day, items: group.items)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, 100)
}
clearFooter
}
}
}
.navigationTitle(LocalizedStringKey("history.title"))
.navigationBarTitleDisplayMode(.inline)
}
.confirmationDialog(
LocalizedStringKey("history.clear.title"),
isPresented: $showClearConfirm,
titleVisibility: .visible
) {
Button(LocalizedStringKey("history.clear.confirm"), role: .destructive) {
store.clearAll()
}
Button(LocalizedStringKey("common.cancel"), role: .cancel) {}
} message: {
Text("history.clear.message")
}
}
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
MaterialIcon(name: .menuBook, size: 36)
.foregroundStyle(palette.textTertiary.opacity(0.5))
Text("history.empty")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
Spacer()
}
.padding(.horizontal, Spacing.xl)
}
private func daySection(day: Date, items: [SpeechHistoryEntry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: day))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.textCase(.uppercase)
.tracking(0.5)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
historyRow(entry)
if index < items.count - 1 {
Divider().background(palette.divider)
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
private func historyRow(_ entry: SpeechHistoryEntry) -> some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(Self.timeFormatter.string(from: entry.createdAt))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
Text(entry.text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
}
private var clearFooter: some View {
Button(role: .destructive) {
showClearConfirm = true
} label: {
Text("history.clear.button")
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, minHeight: 44)
}
.buttonStyle(.plain)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.sm)
}
}
+202 -179
View File
@@ -1,70 +1,182 @@
// HomeView.swift // HomeView.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and // Minimal home: logo, status capsule, flow hints, inline preview field.
// (2) give a clear path to the next setup step if anything is missing.
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import OSGKeyboardExt
struct HomeView: View { struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@State private var showSettings = false @FocusState private var previewFocused: Bool
@State private var showKeyboardPreview = false @State private var previewText = ""
private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting
}
var body: some View { var body: some View {
ZStack { GeometryReader { geo in
palette.background.ignoresSafeArea() let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
VStack(spacing: 0) {
statusHeader ZStack(alignment: .top) {
.padding(.top, Spacing.xl) sessionHeaderGradient(height: gradientHeight)
flowSessionCard .ignoresSafeArea(edges: .top)
.padding(.horizontal, Spacing.md) .allowsHitTesting(false)
.padding(.top, Spacing.md)
Spacer() VStack(spacing: 0) {
heroButton logoHeader
Spacer() .padding(.top, Spacing.xxxl)
actionStack .padding(.bottom, Spacing.xl)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg) flowSessionExtras
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
previewField
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
engineStatusLine
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.xs)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
} .background(palette.background)
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showKeyboardPreview) {
KeyboardPreviewSheet()
} }
} }
// MARK: - Flow session // MARK: - Top gradient
private var flowSessionCard: some View { private func sessionHeaderGradient(height: CGFloat) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) { LinearGradient(
HStack { colors: sessionIsLive
Circle() ? [
.fill(flowStatusColor) palette.accent.opacity(0.28),
.frame(width: 8, height: 8) palette.accent.opacity(0.10),
Text(flowStatusTitle) palette.background.opacity(0)
.font(TypeStyle.caption) ]
.foregroundStyle(palette.textSecondary) : [
Spacer() palette.textTertiary.opacity(0.14),
if flowManager.isActive, let expires = flowManager.sessionExpiresAt { palette.textTertiary.opacity(0.05),
Text(expires, style: .timer) palette.background.opacity(0)
.font(TypeStyle.caption2) ],
.foregroundStyle(palette.textTertiary) startPoint: .top,
.monospacedDigit() endPoint: .bottom
)
.frame(maxWidth: .infinity)
.frame(height: height)
.animation(Motion.soft, value: sessionIsLive)
}
// MARK: - Header
private var logoHeader: some View {
VStack(spacing: Spacing.xxl) {
Image("osglogo")
.resizable()
.scaledToFit()
.frame(width: 120, height: 34)
.accessibilityHidden(true)
statusCapsule
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.lg)
}
private var statusCapsule: some View {
HStack(spacing: Spacing.sm) {
Text(statusLine)
.font(TypeStyle.status)
.foregroundStyle(palette.textSecondary)
.fixedSize()
capsuleDivider
flowCapsuleSegment
if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
Text("home.flow.endShort")
.font(TypeStyle.caption)
.foregroundStyle(palette.textOnAccent)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.accent, in: Capsule())
} }
.buttonStyle(.plain)
} }
Text("home.flow.hint") }
.font(TypeStyle.caption2) .padding(.horizontal, Spacing.md)
.foregroundStyle(palette.textTertiary) .padding(.vertical, Spacing.sm)
.fixedSize(horizontal: false, vertical: true) .background(capsuleBackground, in: Capsule())
if let warning = flowManager.sessionWarning { .overlay(
Capsule()
.stroke(palette.divider, lineWidth: 0.5)
)
.animation(Motion.soft, value: flowManager.isActive)
}
private var capsuleDivider: some View {
Circle()
.fill(palette.dividerStrong)
.frame(width: 3, height: 3)
}
@ViewBuilder
private var flowCapsuleSegment: some View {
HStack(spacing: Spacing.xs) {
Circle()
.fill(flowStatusColor)
.frame(width: 6, height: 6)
if flowManager.isActive, let expires = flowManager.sessionExpiresAt {
Text("home.flow.label")
.font(TypeStyle.status)
.foregroundStyle(palette.textPrimary)
Text(":")
.font(TypeStyle.status)
.foregroundStyle(palette.textTertiary)
Text(expires, style: .timer)
.font(TypeStyle.status)
.foregroundStyle(palette.textSecondary)
.monospacedDigit()
} else {
Text(flowStatusTitle)
.font(TypeStyle.status)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
}
}
private var capsuleBackground: Color {
sessionIsLive
? palette.accentMuted.opacity(0.55)
: palette.surface.opacity(0.88)
}
private var statusLine: String {
config.isConfigured
? NSLocalizedString("home.status.ready", comment: "")
: NSLocalizedString("home.status.setupIncomplete", comment: "")
}
// MARK: - Flow extras (warnings / hint)
@ViewBuilder
private var flowSessionExtras: some View {
if let warning = flowManager.sessionWarning {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(warning) Text(warning)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
@@ -80,30 +192,28 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
if flowManager.isActive { .frame(maxWidth: .infinity, alignment: .leading)
Button { .padding(Spacing.md)
flowManager.endSession() .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
} label: { .overlay(
Text("home.flow.end") RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.font(TypeStyle.caption) .stroke(palette.divider, lineWidth: 0.5)
.foregroundStyle(palette.textSecondary) )
} } else if !flowManager.isActive {
.buttonStyle(.plain) Text("home.flow.hint")
} .font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.sm)
} }
.padding(Spacing.sm)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
private var flowStatusColor: Color { private var flowStatusColor: Color {
if flowManager.isActive { return palette.success } if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent } if flowManager.isStarting { return palette.accent }
if flowManager.sessionWarning != nil { return palette.warning } if flowManager.sessionWarning != nil { return palette.warning }
return palette.warning return palette.textTertiary
} }
private var flowStatusTitle: LocalizedStringKey { private var flowStatusTitle: LocalizedStringKey {
@@ -112,122 +222,35 @@ struct HomeView: View {
return "home.flow.inactive" return "home.flow.inactive"
} }
// MARK: - Header // MARK: - Preview field
private var statusHeader: some View { private var previewField: some View {
VStack(spacing: Spacing.xs) { TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
HStack(spacing: 6) { .font(TypeStyle.body)
Circle() .foregroundStyle(palette.textPrimary)
.fill(config.isConfigured ? palette.success : palette.warning) .tint(palette.accent)
.frame(width: 8, height: 8) .focused($previewFocused)
Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete") .lineLimit(1...100)
.font(TypeStyle.caption) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.foregroundStyle(palette.textSecondary) .padding(Spacing.md)
} .background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
Text("OSGKeyboard") .overlay(
.font(TypeStyle.largeTitle) RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.foregroundStyle(palette.textPrimary) .stroke(previewFocused ? palette.dividerStrong : palette.divider, lineWidth: 0.5)
Text(providerLine) )
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
} }
private var providerLine: String { private var engineStatusLine: some View {
let p = LLMProvider.provider(id: config.providerId) Text(
let model = config.model.isEmpty ? "" : config.model EngineServiceLabel.summary(
return "\(p.name) · \(model)" engineMode: config.engineMode,
} providerId: config.providerId,
model: config.model
// MARK: - Hero )
)
private var heroButton: some View { .font(TypeStyle.caption2)
Button { .foregroundStyle(palette.textSecondary)
showSettings = true .multilineTextAlignment(.center)
} label: { .frame(maxWidth: .infinity, alignment: .center)
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 220, height: 220)
.blur(radius: 40)
Circle()
.fill(LinearGradient(
colors: [palette.surfaceElevated, palette.surface],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 160, height: 160)
.overlay(
Circle().stroke(palette.accent.opacity(0.35), lineWidth: 1.5)
)
.shadow(color: palette.accent.opacity(0.18), radius: 24, y: 8)
VStack(spacing: 6) {
Image(systemName: "waveform")
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text("home.hero.label")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text("home.action.openSettingsA11y"))
}
// MARK: - Actions
private var actionStack: some View {
VStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("home.action.enableInSettings", systemImage: "keyboard")
.primaryButton()
}
.buttonStyle(.plain)
Button {
showSettings = true
} label: {
Label("home.action.editApi", systemImage: "slider.horizontal.3")
.secondaryButton()
}
.buttonStyle(.plain)
#if DEBUG
Button {
showKeyboardPreview = true
} label: {
Label("home.action.keyboardPreview", systemImage: "eye")
.secondaryButton()
}
.buttonStyle(.plain)
#endif
HStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
UIApplication.shared.open(url)
}
} label: {
Text("GitHub")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 6)
.background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
Spacer()
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
} }
} }
+36
View File
@@ -0,0 +1,36 @@
// MainTabView.swift
// OSGKeyboard · Main App
import SwiftUI
import OSGKeyboardShared
struct MainTabView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard
var body: some View {
ZStack(alignment: .bottom) {
palette.background.ignoresSafeArea()
Group {
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .settings:
SettingsView(presentation: .tab)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: 88)
}
MinimalTabBar(selection: $tab)
}
.onAppear { flowManager.autoStartIfNeeded() }
}
}
+150 -100
View File
@@ -31,29 +31,38 @@ struct OnboardingView: View {
@State private var speechStatus = AppPermissions.speechStatus @State private var speechStatus = AppPermissions.speechStatus
var body: some View { var body: some View {
ZStack { GeometryReader { geo in
palette.background.ignoresSafeArea() let gradientHeight = geo.size.height * 0.28 + geo.safeAreaInsets.top
VStack(spacing: 0) {
Group { ZStack(alignment: .top) {
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { palette.background.ignoresSafeArea()
case .welcome: WelcomePage()
case .microphone: VStack(spacing: 0) {
MicPermissionPage(status: $micStatus) Group {
case .speech: switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
SpeechPermissionPage(status: $speechStatus) case .welcome: WelcomePage()
case .keyboard: EnableKeyboardPage() case .microphone:
case .api: APISetupPage(config: config) MicPermissionPage(status: $micStatus)
case .speech:
SpeechPermissionPage(status: $speechStatus)
case .keyboard: EnableKeyboardPage()
case .api: APISetupPage(config: config)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity.combined(with: .move(edge: .trailing)))
pageDots
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
.transition(.opacity.combined(with: .move(edge: .trailing)))
pageDots onboardingHeaderGradient(height: gradientHeight)
.padding(.bottom, Spacing.md) .ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
bottomBar
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
} }
} }
.onAppear { .onAppear {
@@ -67,6 +76,20 @@ struct OnboardingView: View {
} }
} }
private func onboardingHeaderGradient(height: CGFloat) -> some View {
LinearGradient(
colors: [
palette.textTertiary.opacity(0.14),
palette.textTertiary.opacity(0.05),
palette.background.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxWidth: .infinity)
.frame(height: height)
}
private func refreshPermissionStatuses() { private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus speechStatus = AppPermissions.speechStatus
@@ -76,7 +99,7 @@ struct OnboardingView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(0..<OnboardingPage.count, id: \.self) { i in ForEach(0..<OnboardingPage.count, id: \.self) { i in
Capsule() Capsule()
.fill(i == config.onboardingPage ? palette.accent : Color.white.opacity(0.18)) .fill(i == config.onboardingPage ? palette.accent : palette.textTertiary.opacity(0.28))
.frame(width: i == config.onboardingPage ? 18 : 6, height: 6) .frame(width: i == config.onboardingPage ? 18 : 6, height: 6)
.animation(Motion.quick, value: config.onboardingPage) .animation(Motion.quick, value: config.onboardingPage)
} }
@@ -140,43 +163,67 @@ struct OnboardingView: View {
} }
} }
// MARK: - Shared onboarding chrome
private struct OnboardingHeroIcon: View {
@Environment(\.themePalette) private var palette: ThemePalette
let systemName: String
var circleSize: CGFloat = 88
var iconSize: CGFloat = 36
var body: some View {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: circleSize, height: circleSize)
Image(systemName: systemName)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(palette.accent)
}
}
}
// MARK: - Welcome // MARK: - Welcome
private struct WelcomePage: View { private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
var body: some View { var body: some View {
VStack(spacing: Spacing.xl) { VStack(spacing: 0) {
Spacer() Spacer(minLength: Spacing.xl)
ZStack {
Circle() Image("osglogo")
.fill(palette.accentMuted) .resizable()
.frame(width: 180, height: 180) .scaledToFit()
.blur(radius: 30) .frame(maxWidth: 168, maxHeight: 48)
Image(systemName: "mic.circle.fill") .accessibilityHidden(true)
.font(.system(size: 96, weight: .light))
.foregroundStyle(palette.accent) VStack(spacing: Spacing.xs) {
} Text("onboarding.welcome.tagline")
VStack(spacing: Spacing.sm) { .font(TypeStyle.title3)
Text("OSGKeyboard")
.font(TypeStyle.title)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("onboarding.welcome.subtitle") Text("onboarding.welcome.subtitle")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
} }
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl) .padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.hero)
PrivacyFootnote() PrivacyFootnote()
.padding(.top, Spacing.lg) .padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.hero)
if let url = LegalLinks.privacyPolicyURL { if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) { Link(destination: url) {
Text("legal.privacyPolicy") Text("legal.privacyPolicy")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
} }
.padding(.top, Spacing.xs) .padding(.top, Spacing.xxxl)
} }
Spacer() Spacer()
} }
} }
@@ -186,35 +233,24 @@ private struct PrivacyFootnote: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: Spacing.xl) {
footnoteRow(icon: "lock.fill", title: "privacy.audio.title", body: "privacy.audio.body") footnoteBlock(title: "privacy.audio.title", body: "privacy.audio.body")
footnoteRow(icon: "wifi", title: "privacy.network.title", body: "privacy.network.body") footnoteBlock(title: "privacy.network.title", body: "privacy.network.body")
footnoteRow(icon: "keyboard", title: "privacy.universal.title", body: "privacy.universal.body") footnoteBlock(title: "privacy.universal.title", body: "privacy.universal.body")
} }
.padding(Spacing.md) .frame(maxWidth: .infinity, alignment: .leading)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.padding(.horizontal, Spacing.md)
} }
private func footnoteRow(icon: String, title: LocalizedStringKey, body: LocalizedStringKey) -> some View { private func footnoteBlock(title: LocalizedStringKey, body: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.xs) {
Image(systemName: icon) Text(title)
.font(.system(size: 14, weight: .medium)) .font(TypeStyle.bodyEmph)
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.frame(width: 24, height: 24) Text(body)
.background(palette.accentMuted, in: Circle()) .font(TypeStyle.footnote)
VStack(alignment: .leading, spacing: 2) { .foregroundStyle(palette.textSecondary)
Text(title) .fixedSize(horizontal: false, vertical: true)
.font(TypeStyle.caption) .lineSpacing(3)
.foregroundStyle(palette.textPrimary)
Text(body)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
} }
} }
} }
@@ -256,7 +292,7 @@ private struct MicPermissionPage: View {
private var statusColor: Color { private var statusColor: Color {
switch status { switch status {
case .granted: return palette.success case .granted: return palette.accent
case .denied: return palette.warning case .denied: return palette.warning
case .undetermined: return palette.textTertiary case .undetermined: return palette.textTertiary
} }
@@ -316,7 +352,7 @@ private struct SpeechPermissionPage: View {
private var statusColor: Color { private var statusColor: Color {
switch status { switch status {
case .granted: return palette.success case .granted: return palette.accent
case .denied, .restricted: return palette.warning case .denied, .restricted: return palette.warning
case .undetermined: return palette.textTertiary case .undetermined: return palette.textTertiary
} }
@@ -352,11 +388,11 @@ private struct PermissionPageLayout: View {
var deniedHint: LocalizedStringKey? = nil var deniedHint: LocalizedStringKey? = nil
var body: some View { var body: some View {
VStack(spacing: Spacing.lg) { VStack(spacing: Spacing.xl) {
Spacer() Spacer()
Image(systemName: icon)
.font(.system(size: 64, weight: .light)) OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
.foregroundStyle(palette.accent)
VStack(spacing: Spacing.sm) { VStack(spacing: Spacing.sm) {
Text(title) Text(title)
.font(TypeStyle.title2) .font(TypeStyle.title2)
@@ -365,28 +401,32 @@ private struct PermissionPageLayout: View {
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.xl)
} }
HStack(spacing: 6) { HStack(spacing: 6) {
Circle().fill(statusColor).frame(width: 8, height: 8) Circle().fill(statusColor).frame(width: 8, height: 8)
Text(status) Text(status)
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
if let deniedHint { if let deniedHint {
Text(deniedHint) Text(deniedHint)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.xl)
} }
VStack(spacing: Spacing.xs) {
VStack(spacing: Spacing.sm) {
Button(action: onPrimary) { Button(action: onPrimary) {
Text(primaryTitle) Text(primaryTitle)
.primaryButton() .primaryButton()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(primaryDisabled) .disabled(primaryDisabled)
if let secondaryTitle, let onSecondary { if let secondaryTitle, let onSecondary {
Button(action: onSecondary) { Button(action: onSecondary) {
Text(secondaryTitle) Text(secondaryTitle)
@@ -395,7 +435,8 @@ private struct PermissionPageLayout: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
Spacer() Spacer()
} }
} }
@@ -409,9 +450,9 @@ private struct EnableKeyboardPage: View {
var body: some View { var body: some View {
VStack(spacing: Spacing.xl) { VStack(spacing: Spacing.xl) {
Spacer() Spacer()
Image(systemName: "keyboard.fill")
.font(.system(size: 64, weight: .light)) OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40)
.foregroundStyle(palette.accent)
VStack(spacing: Spacing.sm) { VStack(spacing: Spacing.sm) {
Text("onboarding.enable.title") Text("onboarding.enable.title")
.font(TypeStyle.title2) .font(TypeStyle.title2)
@@ -422,14 +463,16 @@ private struct EnableKeyboardPage: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
} }
VStack(alignment: .leading, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.lg) {
step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: "")) step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: "")) step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: "")) step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: "")) step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
} }
.cardSurface() .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.sm)
Button { Button {
AppPermissions.openSystemSettings() AppPermissions.openSystemSettings()
@@ -438,21 +481,25 @@ private struct EnableKeyboardPage: View {
.primaryButton() .primaryButton()
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxl)
Spacer() Spacer()
} }
} }
private func step(num: Int, text: String) -> some View { private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) { HStack(alignment: .top, spacing: Spacing.sm) {
Text("\(num)") Text("\(num)")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.frame(width: 22, height: 22) .frame(width: 24, height: 24)
.background(palette.accent, in: Circle()) .background(palette.accent, in: Circle())
.foregroundStyle(palette.textOnAccent) .foregroundStyle(palette.textOnAccent)
Text(text) Text(text)
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -467,35 +514,38 @@ private struct APISetupPage: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.lg) {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: Spacing.xs) {
Text("onboarding.api.title") Text("onboarding.api.title")
.font(TypeStyle.title2) .font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("onboarding.api.subtitle") Text("onboarding.api.subtitle")
.font(TypeStyle.footnote) .font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.padding(.top, Spacing.xxs)
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.lg) .padding(.top, Spacing.xxxl)
EnginePickerSection(config: config) EnginePickerSection(config: config)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
ProviderPickerSection(config: config) ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
APISettingsCard(config: config) APISettingsCard(config: config)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
} else { } else {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
Image(systemName: "checkmark.seal.fill") ZStack {
.font(.system(size: 18, weight: .medium)) Circle()
.foregroundStyle(palette.accent) .fill(palette.accentMuted)
.frame(width: 28) .frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) { Image(systemName: "checkmark.seal.fill")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.accent)
}
VStack(alignment: .leading, spacing: 4) {
Text("onboarding.api.localReady.title") Text("onboarding.api.localReady.title")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
@@ -505,14 +555,14 @@ private struct APISetupPage: View {
} }
Spacer() Spacer()
} }
.padding(Spacing.md) .padding(Spacing.lg)
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5) .stroke(palette.divider, lineWidth: 0.5)
) )
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.lg)
} }
} }
.padding(.bottom, Spacing.xxxl) .padding(.bottom, Spacing.xxxl)
+45 -27
View File
@@ -1,9 +1,5 @@
// ProviderPickerSection.swift // ProviderPickerSection.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
//
// Provider picker shown inline inside Settings & Onboarding. Each option
// surfaces the provider's name + a short blurb so the user can pick
// confidently without opening a doc.
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
@@ -42,35 +38,57 @@ struct ProviderPickerSection: View {
@ViewBuilder @ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View { private func row(_ provider: LLMProvider, selected: Bool) -> some View {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.sm) {
// Provider mark a coloured dot with first letter providerMark(provider, selected: selected)
ZStack {
Circle() Text(ProviderDisplayName.name(for: provider.id))
.fill(selected ? palette.accent : palette.surfaceElevated) .font(TypeStyle.body)
.frame(width: 36, height: 36) .foregroundStyle(palette.textPrimary)
Text(String(provider.name.prefix(1)))
.font(TypeStyle.bodyEmph) Spacer(minLength: Spacing.xs)
.foregroundStyle(selected ? palette.textOnAccent : palette.textPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text(provider.name)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
if let blurb = provider.blurb {
Text(blurb)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
Spacer()
if selected { if selected {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .medium)) .font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ViewBuilder
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View {
ZStack {
Circle()
.fill(selected ? palette.accentMuted : palette.surfaceElevated)
.frame(width: 32, height: 32)
if let asset = ProviderLogo.assetName(for: provider.id) {
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
.foregroundStyle(selected ? palette.accent : palette.textPrimary)
} else {
Text(String(provider.name.prefix(1)))
.font(TypeStyle.caption)
.foregroundStyle(selected ? palette.accent : palette.textSecondary)
}
}
}
}
enum ProviderLogo {
static func assetName(for providerId: String) -> String? {
switch providerId {
case "openai": return "openai"
case "deepseek": return "deepseek"
case "qwen": return "qwen"
case "moonshot": return "moonshot"
case "zhipu": return "zhipu"
case "custom": return "custom"
default: return nil
}
}
} }
+84 -64
View File
@@ -8,12 +8,24 @@ import SwiftUI
import Speech import Speech
import OSGKeyboardShared import OSGKeyboardShared
enum SettingsPresentation {
case tab
case sheet
}
struct SettingsView: View { struct SettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared @ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showResetConfirm = false @State private var showResetConfirm = false
@State private var safariURL: URL?
let presentation: SettingsPresentation
init(presentation: SettingsPresentation = .sheet) {
self.presentation = presentation
}
// Dynamic locale list loaded from SFSpeechRecognizer on first appear. // Dynamic locale list loaded from SFSpeechRecognizer on first appear.
@State private var dynamicLocales: [(id: String, label: String, onDevice: Bool)] = [] @State private var dynamicLocales: [(id: String, label: String, onDevice: Bool)] = []
@@ -33,23 +45,31 @@ struct SettingsView: View {
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
promptSection promptSection
} }
privacySection if presentation == .tab {
footerLinks
}
resetButton resetButton
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md) .padding(.vertical, Spacing.md)
.padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
} }
} }
.navigationTitle(LocalizedStringKey("settings.title")) .navigationTitle(LocalizedStringKey("settings.title"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task { await loadDynamicLocales() } .task { await loadDynamicLocales() }
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { if presentation == .sheet {
Button("common.done") { dismiss() } ToolbarItem(placement: .confirmationAction) {
.font(TypeStyle.headline) Button("common.done") { dismiss() }
.foregroundStyle(palette.accent) .font(TypeStyle.headline)
.foregroundStyle(palette.accent)
}
} }
} }
.sheet(item: $safariURL) { url in
SafariSheet(url: url)
}
} }
.confirmationDialog( .confirmationDialog(
LocalizedStringKey("settings.reset.title"), LocalizedStringKey("settings.reset.title"),
@@ -74,8 +94,8 @@ struct SettingsView: View {
// MARK: - Provider // MARK: - Provider
private var providerSection: some View { private var providerSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.provider.title", subtitle: "settings.provider.subtitle") sectionHeader("settings.provider.title")
ProviderPickerSection(config: config) ProviderPickerSection(config: config)
} }
} }
@@ -83,8 +103,8 @@ struct SettingsView: View {
// MARK: - API // MARK: - API
private var apiSection: some View { private var apiSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.api.title", subtitle: nil) sectionHeader("settings.api.title")
APISettingsCard(config: config) APISettingsCard(config: config)
} }
} }
@@ -92,11 +112,8 @@ struct SettingsView: View {
// MARK: - Language (ASR + mode) // MARK: - Language (ASR + mode)
private var languageSection: some View { private var languageSection: some View {
let subtitleKey: LocalizedStringKey = config.engineMode == "cloud" VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
? "settings.language.subtitle.cloud" sectionHeader("settings.language.title")
: "settings.language.subtitle.local"
return VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.language.title", subtitle: subtitleKey)
VStack(spacing: 0) { VStack(spacing: 0) {
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
PickerRow( PickerRow(
@@ -131,7 +148,7 @@ struct SettingsView: View {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Image(systemName: "iphone") Image(systemName: "iphone")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.success) .foregroundStyle(palette.accent)
Text("settings.legend.onDevice") Text("settings.legend.onDevice")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
@@ -161,17 +178,17 @@ struct SettingsView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark") Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.success) .foregroundStyle(palette.accent)
Text("settings.engineBadge.ios26") Text("settings.engineBadge.ios26")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.success) .foregroundStyle(palette.accent)
} }
.padding(.horizontal, Spacing.xs) .padding(.horizontal, Spacing.xs)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(palette.success.opacity(0.12), in: Capsule()) .background(palette.accent.opacity(0.12), in: Capsule())
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
/// Falls back to a static list while dynamic locales are loading. /// Falls back to a static list while dynamic locales are loading.
@@ -230,9 +247,9 @@ struct SettingsView: View {
// MARK: - Prompt // MARK: - Prompt
private var promptSection: some View { private var promptSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
HStack { HStack {
sectionHeader("settings.systemPrompt.title", subtitle: nil) sectionHeader("settings.systemPrompt.title")
Spacer() Spacer()
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt } Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
@@ -254,39 +271,48 @@ struct SettingsView: View {
} }
} }
// MARK: - Privacy // MARK: - Footer links (tab settings only)
private var privacySection: some View { private var footerLinks: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.privacy.title", subtitle: nil) sectionHeader("settings.about.title")
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(spacing: 0) {
if let url = LegalLinks.privacyPolicyURL { if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) { footerLinkRow(title: "settings.privacy.policy", url: url)
HStack { Divider().background(palette.divider)
Text("settings.privacy.policy")
.font(TypeStyle.body)
.foregroundStyle(palette.accent)
Spacer()
Image(systemName: "arrow.up.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
}
}
VStack(alignment: .leading, spacing: 4) {
Text("settings.privacy.fullAccess.title")
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text("settings.privacy.fullAccess.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
} }
footerLinkRow(
title: "settings.link.github",
url: URL(string: "https://github.com/hkgood/OSGKeyboard")!
)
} }
.cardSurface() .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
} }
private func footerLinkRow(title: LocalizedStringKey, url: URL) -> some View {
Button {
safariURL = url
} label: {
HStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
// MARK: - Reset // MARK: - Reset
private var resetButton: some View { private var resetButton: some View {
@@ -296,26 +322,20 @@ struct SettingsView: View {
Text("settings.reset.confirm") Text("settings.reset.confirm")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.danger) .foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, minHeight: 40) .frame(maxWidth: .infinity, minHeight: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.top, presentation == .tab ? Spacing.xs : Spacing.sm)
} }
// MARK: - Header // MARK: - Header
private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey?) -> some View { private func sectionHeader(_ title: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 2) { Text(title)
Text(title) .font(TypeStyle.caption2)
.font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary)
.foregroundStyle(palette.textSecondary) .textCase(.uppercase)
.textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
@@ -358,7 +378,7 @@ private struct PickerRow: View {
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private var currentLabel: String { private var currentLabel: String {
@@ -402,7 +422,7 @@ private struct LocalePickerRow: View {
if let current = locales.first(where: { $0.id == selection }), current.onDevice { if let current = locales.first(where: { $0.id == selection }), current.onDevice {
Image(systemName: "iphone") Image(systemName: "iphone")
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
.foregroundStyle(palette.success) .foregroundStyle(palette.accent)
} }
Text(currentLabel) Text(currentLabel)
.font(TypeStyle.body) .font(TypeStyle.body)
@@ -414,7 +434,7 @@ private struct LocalePickerRow: View {
} }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private var currentLabel: String { private var currentLabel: String {
+30 -1
View File
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* Onboarding */
"onboarding.welcome.subtitle" = "Tap to talk. Tap again to finish — polished text in any app."; "onboarding.welcome.tagline" = "Speak instead of type";
"onboarding.welcome.subtitle" = "Your open-source voice keyboard";
"onboarding.enable.fullAccessNote" = "Full Access lets the keyboard reach the microphone and your LLM API. We never log what you type."; "onboarding.enable.fullAccessNote" = "Full Access lets the keyboard reach the microphone and your LLM API. We never log what you type.";
"onboarding.permission.mic.title" = "Microphone access"; "onboarding.permission.mic.title" = "Microphone access";
"onboarding.permission.mic.body" = "Required for voice input. Audio is processed on your device for transcription."; "onboarding.permission.mic.body" = "Required for voice input. Audio is processed on your device for transcription.";
@@ -36,6 +37,7 @@
"common.continue" = "Continue"; "common.continue" = "Continue";
"common.reset" = "Reset"; "common.reset" = "Reset";
"common.cancel" = "Cancel"; "common.cancel" = "Cancel";
"common.clear" = "Clear";
"common.delete" = "Delete"; "common.delete" = "Delete";
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; "settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)";
"provider.zhipu" = "Zhipu GLM";
"provider.moonshot" = "Moonshot";
"provider.custom" = "Custom";
"settings.api.title" = "API"; "settings.api.title" = "API";
"settings.language.title" = "Language"; "settings.language.title" = "Language";
"settings.language.subtitle.cloud" = "Recognition language and text processing mode."; "settings.language.subtitle.cloud" = "Recognition language and text processing mode.";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "Transcribe"; "settings.mode.transcribe" = "Transcribe";
"settings.mode.polish" = "Polish"; "settings.mode.polish" = "Polish";
"settings.systemPrompt.title" = "System Prompt"; "settings.systemPrompt.title" = "System Prompt";
"settings.about.title" = "About";
"settings.systemPrompt.reset" = "Reset"; "settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale"; "settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; "settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "Privacy Policy"; "settings.privacy.policy" = "Privacy Policy";
"settings.privacy.fullAccess.title" = "About Full Access"; "settings.privacy.fullAccess.title" = "About Full Access";
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard."; "settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
"settings.link.github" = "GitHub";
/* App group error */ /* App group error */
"appGroup.error.title" = "App Group not configured"; "appGroup.error.title" = "App Group not configured";
@@ -168,17 +178,36 @@
/* Flow session */ /* Flow session */
"flow.error.noSpeech" = "No speech detected. Please try again."; "flow.error.noSpeech" = "No speech detected. Please try again.";
"flow.error.recognitionInterrupted" = "Recognition did not finish. Please try again.";
"keyboard.denied.mic" = "Microphone access denied"; "keyboard.denied.mic" = "Microphone access denied";
"keyboard.denied.speech" = "Speech recognition denied"; "keyboard.denied.speech" = "Speech recognition denied";
/* Home · Flow session */ /* Home · Flow session */
"home.flow.active" = "Voice session active"; "home.flow.active" = "Voice session active";
"home.flow.label" = "Voice session";
"home.flow.inactive" = "Voice session inactive"; "home.flow.inactive" = "Voice session inactive";
"home.flow.hint" = "Voice session starts automatically. Switch to any app and tap the keyboard mic to dictate."; "home.flow.hint" = "Voice session starts automatically. Switch to any app and tap the keyboard mic to dictate.";
"home.flow.starting" = "Starting voice session…"; "home.flow.starting" = "Starting voice session…";
"home.flow.openSettings" = "Open Settings to grant permissions"; "home.flow.openSettings" = "Open Settings to grant permissions";
"home.flow.start" = "Start voice session"; "home.flow.start" = "Start voice session";
"home.flow.end" = "End voice session"; "home.flow.end" = "End voice session";
"home.flow.endShort" = "End";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
/* Tabs */
"tab.keyboard" = "Keyboard";
"tab.history" = "History";
"tab.settings" = "Settings";
/* History */
"history.title" = "History";
"history.subtitle" = "Saved on this device only.";
"history.empty" = "No voice transcripts yet.";
"history.clear.title" = "Clear all history?";
"history.clear.message" = "This cannot be undone.";
"history.clear.confirm" = "Clear all";
"history.clear.button" = "Clear all history";
"flow.error.speechRequired" = "Speech recognition access is required for voice sessions."; "flow.error.speechRequired" = "Speech recognition access is required for voice sessions.";
"flow.error.micRequired" = "Microphone access is required for background voice sessions."; "flow.error.micRequired" = "Microphone access is required for background voice sessions.";
"flow.error.micUnavailable" = "Microphone is unavailable on this device."; "flow.error.micUnavailable" = "Microphone is unavailable on this device.";
+30 -1
View File
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard"; "OSGKeyboard" = "OSGKeyboard";
/* Onboarding */ /* Onboarding */
"onboarding.welcome.subtitle" = "点按说话,再点结束 — 任意 App 里都能获得润色文字。"; "onboarding.welcome.tagline" = "能说就不打字";
"onboarding.welcome.subtitle" = "你的开源语音输入法";
"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。"; "onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。";
"onboarding.permission.mic.title" = "麦克风权限"; "onboarding.permission.mic.title" = "麦克风权限";
"onboarding.permission.mic.body" = "语音输入需要麦克风。音频在设备端转录,不会上传原始录音。"; "onboarding.permission.mic.body" = "语音输入需要麦克风。音频在设备端转录,不会上传原始录音。";
@@ -36,6 +37,7 @@
"common.continue" = "继续"; "common.continue" = "继续";
"common.reset" = "重置"; "common.reset" = "重置";
"common.cancel" = "取消"; "common.cancel" = "取消";
"common.clear" = "清空";
"common.delete" = "删除"; "common.delete" = "删除";
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "换行";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; "settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
"settings.provider.title" = "提供商"; "settings.provider.title" = "提供商";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问";
"provider.zhipu" = "智谱 GLM";
"provider.moonshot" = "月之暗面";
"provider.custom" = "自定义";
"settings.api.title" = "接口"; "settings.api.title" = "接口";
"settings.language.title" = "语言"; "settings.language.title" = "语言";
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; "settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "转写"; "settings.mode.transcribe" = "转写";
"settings.mode.polish" = "润色"; "settings.mode.polish" = "润色";
"settings.systemPrompt.title" = "系统提示"; "settings.systemPrompt.title" = "系统提示";
"settings.about.title" = "关于";
"settings.systemPrompt.reset" = "重置"; "settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言"; "settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; "settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "隐私政策"; "settings.privacy.policy" = "隐私政策";
"settings.privacy.fullAccess.title" = "关于完全访问"; "settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "完全访问用于麦克风与读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。"; "settings.privacy.fullAccess.body" = "完全访问用于麦克风与读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。";
"settings.link.github" = "GitHub";
/* App group error */ /* App group error */
"appGroup.error.title" = "App Group 未配置"; "appGroup.error.title" = "App Group 未配置";
@@ -167,17 +177,36 @@
/* Flow session */ /* Flow session */
"flow.error.noSpeech" = "未检测到语音,请重试。"; "flow.error.noSpeech" = "未检测到语音,请重试。";
"flow.error.recognitionInterrupted" = "识别未完成,请再试一次。";
"keyboard.denied.mic" = "麦克风权限被拒绝"; "keyboard.denied.mic" = "麦克风权限被拒绝";
"keyboard.denied.speech" = "语音识别权限被拒绝"; "keyboard.denied.speech" = "语音识别权限被拒绝";
/* Home · Flow session */ /* Home · Flow session */
"home.flow.active" = "语音会话进行中"; "home.flow.active" = "语音会话进行中";
"home.flow.label" = "语音会话";
"home.flow.inactive" = "语音会话未启动"; "home.flow.inactive" = "语音会话未启动";
"home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。"; "home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。";
"home.flow.starting" = "正在启动语音会话…"; "home.flow.starting" = "正在启动语音会话…";
"home.flow.openSettings" = "前往设置授予权限"; "home.flow.openSettings" = "前往设置授予权限";
"home.flow.start" = "启动语音会话"; "home.flow.start" = "启动语音会话";
"home.flow.end" = "结束语音会话"; "home.flow.end" = "结束语音会话";
"home.flow.endShort" = "结束";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点击输入,测试键盘效果…";
/* Tabs */
"tab.keyboard" = "键盘";
"tab.history" = "历史";
"tab.settings" = "设置";
/* History */
"history.title" = "历史";
"history.subtitle" = "仅保存在本机。";
"history.empty" = "还没有语音识别记录。";
"history.clear.title" = "清空全部历史?";
"history.clear.message" = "此操作无法撤销。";
"history.clear.confirm" = "全部清空";
"history.clear.button" = "清空全部历史";
"flow.error.speechRequired" = "需要语音识别权限才能使用语音会话。"; "flow.error.speechRequired" = "需要语音识别权限才能使用语音会话。";
"flow.error.micRequired" = "需要麦克风权限才能维持后台语音会话。"; "flow.error.micRequired" = "需要麦克风权限才能维持后台语音会话。";
"flow.error.micUnavailable" = "当前设备无法使用麦克风。"; "flow.error.micUnavailable" = "当前设备无法使用麦克风。";
+89 -72
View File
@@ -8,19 +8,20 @@
// the system Spotlight / home-indicator chrome never clips our controls. // the system Spotlight / home-indicator chrome never clips our controls.
// //
// //
// [polish] [] top: 32 pt (incl. safe top) // [polish] [] top: ~38 pt (+20%)
// (transcript preview) 24 pt // (transcript preview)
// //
// centre: 96 pt disc + // () mic () action row: circular
// breathing ring // (space) flanking buttons
//
//
// 🌐 [ space ] bottom: 60 pt (incl. safe bottom)
// //
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 44
}
public struct KeyboardRootView: View { public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@@ -34,6 +35,8 @@ public struct KeyboardRootView: View {
/// constraint in the view controller so the host UIInputView picks /// constraint in the view controller so the host UIInputView picks
/// it up. /// it up.
static let totalHeight: CGFloat = 280 static let totalHeight: CGFloat = 280
private static let topBarHeight: CGFloat = 38
private static let sideActionStackSpacing: CGFloat = 10
private var palette: ThemePalette { private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light colorScheme == .dark ? Palette.dark : Palette.light
@@ -42,13 +45,10 @@ public struct KeyboardRootView: View {
public var body: some View { public var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
topBar topBar
.frame(height: 32) .frame(height: Self.topBarHeight)
centreArea centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
bottomBar
.frame(height: 56)
} }
.padding(.top, 4) .padding(.top, 4)
.padding(.bottom, 6) .padding(.bottom, 6)
@@ -79,9 +79,9 @@ public struct KeyboardRootView: View {
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported) StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) { Button(action: state.openSettings) {
Image(systemName: "gearshape.fill") Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(width: 28, height: 28) .frame(width: 34, height: 34)
.background(palette.surface, in: Circle()) .background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) .overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
} }
@@ -94,58 +94,52 @@ public struct KeyboardRootView: View {
// MARK: - Centre area // MARK: - Centre area
private var centreArea: some View { private var centreArea: some View {
ZStack { VStack(spacing: Spacing.xxs) {
VStack(spacing: Spacing.xxs) { TranscriptLine(
TranscriptLine( phase: state.phase,
phase: state.phase, transcript: state.lastTranscript,
transcript: state.lastTranscript, flowSessionActive: state.flowSessionActive,
flowSessionActive: state.flowSessionActive, openSettings: state.openSettings
openSettings: state.openSettings )
) .frame(height: 22)
.frame(height: 22)
RecordButton( Spacer(minLength: 0)
phase: buttonPhase,
level: state.level, micActionRow
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil, .padding(.bottom, Spacing.xs)
onToggle: state.tapMic
) Spacer(minLength: 0)
.frame(width: 140, height: 140)
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
// MARK: - Bottom bar /// Delete (left), mic (centre), return + space stacked on the right.
/// HStack vertical alignment keeps delete, mic centre, and the gap
private var bottomBar: some View { /// between return/space on one horizontal axis.
// The globe / "next keyboard" button used to live here so the private var micActionRow: some View {
// user could long-press to call up the system keyboard picker. HStack(alignment: .center, spacing: 20) {
// In practice the user already has a "globe" key in the iOS CircularToolbarButton(systemName: "delete.left", label: "delete") {
// system keyboard strip (every iOS keyboard does), so an
// in-extension globe is redundant and crowds the bar.
// Bottom bar: (delete) [ space ] (return)
HStack(spacing: Spacing.xxs) {
ToolbarIconButton(systemName: "delete.left", label: "delete") {
state.deleteBackward() state.deleteBackward()
} }
Button(action: state.insertSpace) {
ExtL10n.text("keyboard.space") RecordButton(
.font(TypeStyle.body) phase: buttonPhase,
.foregroundStyle(palette.textPrimary) level: state.level,
.frame(maxWidth: .infinity, minHeight: 42) remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) onToggle: state.tapMic
.overlay( )
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) .frame(width: 132, height: 132)
.stroke(palette.divider, lineWidth: 0.5)
) VStack(spacing: Self.sideActionStackSpacing) {
} CircularToolbarButton(systemName: "return", label: "newline") {
.buttonStyle(.plain) state.insertNewline()
.accessibilityLabel(ExtL10n.text("keyboard.space")) }
ToolbarIconButton(systemName: "return", label: "newline") { CircularToolbarButton(spaceStyle: true, label: "space") {
state.insertNewline() state.insertSpace()
}
} }
} }
.padding(.horizontal, Spacing.sm) .frame(maxWidth: .infinity)
} }
private var buttonPhase: RecordButton.Phase { private var buttonPhase: RecordButton.Phase {
@@ -270,26 +264,46 @@ private struct TranscriptLine: View {
} }
} }
// MARK: - Toolbar icon button // MARK: - Circular toolbar button
private struct ToolbarIconButton: View { private struct CircularToolbarButton: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
let systemName: String let systemName: String?
let spaceStyle: Bool
let label: String let label: String
let action: () -> Void let action: () -> Void
init(systemName: String, label: String, action: @escaping () -> Void) {
self.systemName = systemName
self.spaceStyle = false
self.label = label
self.action = action
}
init(spaceStyle: Bool, label: String, action: @escaping () -> Void) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.label = label
self.action = action
}
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Image(systemName: systemName) Group {
.font(.system(size: 16, weight: .medium)) if spaceStyle {
.foregroundStyle(palette.textPrimary) Capsule()
.frame(width: 40, height: 40) .fill(palette.textPrimary)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) .frame(width: 16, height: 3)
.overlay( } else if let systemName {
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) Image(systemName: systemName)
.stroke(palette.divider, lineWidth: 0.5) .font(.system(size: 16, weight: .medium))
) .foregroundStyle(palette.textPrimary)
}
}
.frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize)
.background(palette.surfaceElevated, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text(label)) .accessibilityLabel(Text(label))
@@ -365,7 +379,8 @@ private struct LocalEngineChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4) .padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.accent.opacity(0.15), in: Capsule()) .background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
} }
@@ -402,7 +417,8 @@ private struct ModeChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(mode == .off ? palette.textTertiary : palette.textPrimary) .foregroundStyle(mode == .off ? palette.textTertiary : palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4) .padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule()) .background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
@@ -462,7 +478,8 @@ private struct LocaleChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4) .padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule()) .background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
+7 -3
View File
@@ -85,17 +85,21 @@ struct RecordButton: View {
case .idle: case .idle:
Image(systemName: "mic.fill") Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium)) .font(.system(size: 38, weight: .medium))
.foregroundStyle(palette.textPrimary) .foregroundStyle(.white)
case .recording: case .recording:
VStack(spacing: 4) { VStack(spacing: 4) {
if let remainingSeconds { if let remainingSeconds {
Text(formatRemaining(remainingSeconds)) Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded)) .font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(isUrgent ? .white : palette.textPrimary) .foregroundStyle(.white)
.monospacedDigit() .monospacedDigit()
.contentTransition(.numericText()) .contentTransition(.numericText())
} }
WaveformView(level: level, active: true) WaveformView(
level: level,
color: Color(red: 1.0, green: 0.78, blue: 0.78),
active: true
)
.frame(width: 72, height: 32) .frame(width: 72, height: 32)
} }
.transition(.opacity) .transition(.opacity)
+18 -15
View File
@@ -55,7 +55,7 @@ public enum Palette {
// Semantic // Semantic
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69 public static let success = accent // unified brand green in UI
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18 public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
// Text // Text
@@ -95,25 +95,17 @@ public enum Palette {
recordRed: recordRed recordRed: recordRed
) )
/// Light palette iOS system light mode defaults. Used by the main app /// Light palette warm gray backgrounds for daytime use.
/// when the user is in light mode; the keyboard extension stays dark.
///
/// Accent is the same brand green (#3AA05A) used in the dark palette
/// "one accent" is core to the design system, and the recording-state
/// CTA needs to read as the same brand colour in both modes. The
/// keyboard's record disc also picks up `palette.accent` (see
/// `KeyboardPreviewStub.recordDisc`), so this single token drives
/// every interactive surface.
public static let light = ThemePalette( public static let light = ThemePalette(
background: Color(red: 0.980, green: 0.980, blue: 0.988), // #FAFAFC background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
surface: Color(red: 1.000, green: 1.000, blue: 1.000), // #FFFFFF surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
surfaceElevated: Color(red: 0.941, green: 0.941, blue: 0.961), // #F0F0F5 surfaceElevated: Color(red: 0.922, green: 0.918, blue: 0.906), // #EBEAE7
surfaceMuted: Color(red: 0.953, green: 0.953, blue: 0.965), // #F3F3F6 surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14), accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32), accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30 danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
success: Color(red: 0.157, green: 0.812, blue: 0.412), // #28CF69 success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18 warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118 textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
@@ -169,6 +161,15 @@ public enum Radius {
// MARK: - Typography // MARK: - Typography
public enum SettingsListMetrics {
/// Single-line list rows (provider, footer link, picker).
public static let singleLineMinHeight: CGFloat = 52
/// Two-line rows (engine option, labeled API field).
public static let doubleLineMinHeight: CGFloat = 72
/// Space between a section label and its card.
public static let sectionLabelSpacing: CGFloat = Spacing.sm
}
public enum TypeStyle { public enum TypeStyle {
public static let caption2 = Font.system(size: 11, weight: .medium) public static let caption2 = Font.system(size: 11, weight: .medium)
public static let caption = Font.system(size: 12, weight: .medium) public static let caption = Font.system(size: 12, weight: .medium)
@@ -180,6 +181,8 @@ public enum TypeStyle {
public static let title2 = Font.system(size: 22, weight: .bold) public static let title2 = Font.system(size: 22, weight: .bold)
public static let title = Font.system(size: 28, weight: .bold) public static let title = Font.system(size: 28, weight: .bold)
public static let largeTitle = Font.system(size: 34, weight: .bold) public static let largeTitle = Font.system(size: 34, weight: .bold)
/// Subtle status line under the brand mark (home header).
public static let status = Font.system(size: 13, weight: .regular)
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced) public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced) public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
} }
@@ -18,9 +18,9 @@ public enum EngineServiceLabel {
? "\(prefix)本地引擎 · Apple SpeechAnalyzer" ? "\(prefix)本地引擎 · Apple SpeechAnalyzer"
: "\(prefix)On-device · Apple SpeechAnalyzer" : "\(prefix)On-device · Apple SpeechAnalyzer"
} }
let provider = LLMProvider.provider(id: providerId) let providerName = ProviderDisplayName.name(for: providerId)
let trimmedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedModel = model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedModel.isEmpty { return "\(prefix)\(provider.name)" } if trimmedModel.isEmpty { return "\(prefix)\(providerName)" }
return "\(prefix)\(provider.name) · \(trimmedModel)" return "\(prefix)\(providerName) · \(trimmedModel)"
} }
} }
@@ -0,0 +1,15 @@
// ProviderDisplayName.swift
// OSGKeyboard · Shared
//
// Locale-aware provider labels for settings and status hints.
import Foundation
public enum ProviderDisplayName {
public static func name(for providerId: String) -> String {
let key = "provider.\(providerId)"
let localized = NSLocalizedString(key, comment: "")
if localized != key { return localized }
return LLMProvider.provider(id: providerId).name
}
}
+9
View File
@@ -64,12 +64,14 @@ targets:
- path: OSGKeyboard/Assets.xcassets - path: OSGKeyboard/Assets.xcassets
- path: OSGKeyboard/en.lproj - path: OSGKeyboard/en.lproj
- path: OSGKeyboard/zh-Hans.lproj - path: OSGKeyboard/zh-Hans.lproj
- path: OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf
info: info:
path: OSGKeyboard/Info.plist path: OSGKeyboard/Info.plist
properties: properties:
CFBundleDisplayName: OSGKeyboard CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
LSApplicationCategoryType: public.app-category.utilities
# English is the development language; zh-Hans is the only # English is the development language; zh-Hans is the only
# translation right now. The list also tells iOS to surface # translation right now. The list also tells iOS to surface
# the keyboard in the per-app language picker for both. # the keyboard in the per-app language picker for both.
@@ -98,6 +100,10 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
# Use AppIcon.appiconset only. AppIcon.icon (Icon Composer) must not # Use AppIcon.appiconset only. AppIcon.icon (Icon Composer) must not
# be passed to actool alongside the asset catalog — it crashes actool. # be passed to actool alongside the asset catalog — it crashes actool.
dependencies: dependencies:
@@ -161,6 +167,9 @@ targets:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
dependencies: dependencies:
- target: OSGKeyboardShared - target: OSGKeyboardShared
embed: false embed: false