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"/>
<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="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="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="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="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;"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+1 -4
View File
@@ -1,9 +1,6 @@
{
"fill" : {
"linear-gradient" : [
"extended-gray:0.25000,1.00000",
"display-p3:0.13763,0.15253,0.17418,1.00000"
]
"automatic-gradient" : "display-p3:0.13763,0.15253,0.17418,1.00000"
},
"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>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
+10 -2
View File
@@ -12,13 +12,21 @@ struct OSGKeyboardApp: App {
@StateObject private var dictationCoordinator = DictationSessionCoordinator()
@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 {
WindowGroup {
ThemedRoot {
if AppGroup.isAvailable {
if config.hasCompletedOnboarding {
HomeView()
.onAppear { flowManager.autoStartIfNeeded() }
MainTabView()
} else {
OnboardingView(config: config)
}
+58 -10
View File
@@ -29,6 +29,8 @@ final class FlowSessionManager: ObservableObject {
private var levelTask: Task<Void, Never>?
private var startTask: Task<Void, Never>?
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 asrTask: Task<Void, Never>?
private var currentPartial = ""
@@ -141,14 +143,15 @@ final class FlowSessionManager: ObservableObject {
levelTask = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
asrTask = nil
if isUtteranceRecording {
if isUtteranceRecording || isUtteranceProcessing {
capture.cancelUtterance()
asrTask?.cancel()
asr.cancel()
isUtteranceRecording = false
}
asrTask = nil
isUtteranceRecording = false
isUtteranceProcessing = false
capture.stop()
FlowSessionBridge.markSessionInactive()
@@ -222,7 +225,7 @@ final class FlowSessionManager: ObservableObject {
private func handleKeyboardSignal() {
switch FlowSessionBridge.recordingState() {
case .recording:
guard !isUtteranceRecording else { return }
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
beginUtterance()
case .stopped:
guard isUtteranceRecording else { return }
@@ -239,10 +242,14 @@ final class FlowSessionManager: ObservableObject {
failUtterance(message: NSLocalizedString("flow.error.audioUnavailable", comment: ""))
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 = ""
lastFinal = ""
@@ -271,6 +278,8 @@ final class FlowSessionManager: ObservableObject {
self.debug("asr error: \(message)")
if self.isUtteranceRecording {
self.failUtterance(message: message)
} else if self.isUtteranceProcessing {
self.finishProcessing(withError: message)
}
}
}
@@ -280,9 +289,18 @@ final class FlowSessionManager: ObservableObject {
}
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()
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 = Task { @MainActor [weak self] in
@@ -293,7 +311,9 @@ final class FlowSessionManager: ObservableObject {
private func abortUtterance() {
isUtteranceRecording = false
isUtteranceProcessing = false
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
asr.cancel()
capture.cancelUtterance()
@@ -305,19 +325,40 @@ final class FlowSessionManager: ObservableObject {
private func failUtterance(message: String) {
isUtteranceRecording = false
isUtteranceProcessing = false
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
asr.cancel()
capture.cancelUtterance()
currentPartial = ""
lastFinal = ""
FlowSessionBridge.storeTranscriptionError(message)
FlowSessionBridge.setRecordingState(.idle)
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 {
defer {
isUtteranceProcessing = false
FlowSessionBridge.setRecordingState(.idle)
}
let deadline = Date().addingTimeInterval(30)
while Date() < deadline {
if !lastFinal.isEmpty { break }
if asrTask?.isCancelled == true { break }
try? await Task.sleep(nanoseconds: 100_000_000)
}
@@ -326,8 +367,11 @@ final class FlowSessionManager: ObservableObject {
text = currentPartial.trimmingCharacters(in: .whitespacesAndNewlines)
}
guard !text.isEmpty else {
let key = (asrTask?.isCancelled == true)
? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech"
FlowSessionBridge.storeTranscriptionError(
NSLocalizedString("flow.error.noSpeech", comment: "")
NSLocalizedString(key, comment: "")
)
return
}
@@ -336,9 +380,11 @@ final class FlowSessionManager: ObservableObject {
let modeId = store.modeId
let shouldPolish = engineMode != "local" && modeId == "polish"
var delivered = text
if shouldPolish {
do {
let polished = try await polisher.polish(text)
delivered = polished
FlowSessionBridge.storeTranscriptionResult(polished)
} catch {
FlowSessionBridge.storeTranscriptionResult(text)
@@ -347,6 +393,8 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionResult(text)
}
SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode)
currentPartial = ""
lastFinal = ""
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 {
case idle
case running
case success(String)
case success
case failure(String)
}
@@ -48,16 +48,15 @@ struct APISettingsCard: View {
UIApplication.shared.open(url)
} label: {
HStack {
Image(systemName: "key.fill")
.foregroundStyle(palette.accent)
Text("api.getKey")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -104,7 +103,7 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Generic field
@@ -120,12 +119,6 @@ struct APISettingsCard: View {
Text(title)
.font(TypeStyle.caption)
.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)
.keyboardType(.asciiCapable)
.autocorrectionDisabled(true)
@@ -137,7 +130,7 @@ struct APISettingsCard: View {
and dismissing the sheet unexpectedly */ }
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
// MARK: - Test connection
@@ -146,20 +139,18 @@ struct APISettingsCard: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("api.connection")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Button(action: runTest) {
HStack(spacing: 6) {
Group {
if testStatus == .running {
ProgressView().controlSize(.mini)
} else {
Image(systemName: testIcon)
Text(testButtonLabel)
.font(TypeStyle.body)
.foregroundStyle(testTint)
}
Text(testButtonLabel)
.font(TypeStyle.caption)
.foregroundStyle(testTint)
}
}
.buttonStyle(.plain)
@@ -173,7 +164,8 @@ struct APISettingsCard: View {
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
}
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 {
switch testStatus {
case .idle, .running: return palette.accent
case .success: return palette.success
case .success: return palette.accent
case .failure: return palette.danger
}
}
private var testDetail: String? {
switch testStatus {
case .idle, .running: return nil
case .success(let s): return s
case .failure(let s): return s
case .idle, .running, .success: return nil
case .failure(let message): return message
}
}
@@ -219,15 +202,8 @@ struct APISettingsCard: View {
)
Task {
do {
let reply = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
// Interpolated success message LocalizedStringKey can't
// 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
))
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
testStatus = .success
} catch LLMError.noAPIKey {
testStatus = .failure(NSLocalizedString("api.test.missing", comment: ""))
} 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
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader(
"settings.engine.title",
subtitle: "settings.engine.subtitle"
)
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.engine.title")
VStack(spacing: 0) {
engineOptionRow(
id: "local",
icon: "iphone.badge.checkmark",
assetName: "apple",
title: NSLocalizedString("settings.engine.local.title", comment: ""),
subtitle: localSubtitle
)
Divider().background(palette.divider)
engineOptionRow(
id: "cloud",
icon: "wand.and.stars",
systemIcon: "wand.and.stars",
title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
)
@@ -58,7 +55,13 @@ struct EnginePickerSection: View {
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
return Button {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -67,10 +70,11 @@ struct EnginePickerSection: View {
}
} label: {
HStack(spacing: Spacing.sm) {
Image(systemName: icon)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
.frame(width: 28)
engineMark(
assetName: assetName,
systemIcon: systemIcon,
isSelected: isSelected
)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.body)
@@ -87,23 +91,39 @@ struct EnginePickerSection: View {
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
ZStack {
Circle()
.fill(isSelected ? palette.accentMuted : palette.surfaceElevated)
.frame(width: 32, height: 32)
if let assetName {
Image(assetName)
.resizable()
.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
// OSGKeyboard · Main App
//
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
// (2) give a clear path to the next setup step if anything is missing.
// Minimal home: logo, status capsule, flow hints, inline preview field.
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardExt
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared
@ObservedObject private var config = ProviderConfig.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var showSettings = false
@State private var showKeyboardPreview = false
@FocusState private var previewFocused: Bool
@State private var previewText = ""
private var sessionIsLive: Bool {
flowManager.isActive || flowManager.isStarting
}
var body: some View {
ZStack {
palette.background.ignoresSafeArea()
VStack(spacing: 0) {
statusHeader
.padding(.top, Spacing.xl)
flowSessionCard
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.md)
Spacer()
heroButton
Spacer()
actionStack
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
VStack(spacing: 0) {
logoHeader
.padding(.top, Spacing.xxxl)
.padding(.bottom, Spacing.xl)
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)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showKeyboardPreview) {
KeyboardPreviewSheet()
.background(palette.background)
}
}
// MARK: - Flow session
// MARK: - Top gradient
private var flowSessionCard: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
Circle()
.fill(flowStatusColor)
.frame(width: 8, height: 8)
Text(flowStatusTitle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
Spacer()
if flowManager.isActive, let expires = flowManager.sessionExpiresAt {
Text(expires, style: .timer)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
private func sessionHeaderGradient(height: CGFloat) -> some View {
LinearGradient(
colors: sessionIsLive
? [
palette.accent.opacity(0.28),
palette.accent.opacity(0.10),
palette.background.opacity(0)
]
: [
palette.textTertiary.opacity(0.14),
palette.textTertiary.opacity(0.05),
palette.background.opacity(0)
],
startPoint: .top,
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)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
if let warning = flowManager.sessionWarning {
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(capsuleBackground, in: Capsule())
.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)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
@@ -80,30 +192,28 @@ struct HomeView: View {
.buttonStyle(.plain)
}
}
if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
Text("home.flow.end")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} else if !flowManager.isActive {
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 {
if flowManager.isActive { return palette.success }
if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent }
if flowManager.sessionWarning != nil { return palette.warning }
return palette.warning
return palette.textTertiary
}
private var flowStatusTitle: LocalizedStringKey {
@@ -112,122 +222,35 @@ struct HomeView: View {
return "home.flow.inactive"
}
// MARK: - Header
// MARK: - Preview field
private var statusHeader: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
Circle()
.fill(config.isConfigured ? palette.success : palette.warning)
.frame(width: 8, height: 8)
Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete")
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Text("OSGKeyboard")
.font(TypeStyle.largeTitle)
.foregroundStyle(palette.textPrimary)
Text(providerLine)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
private var previewField: some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.divider, lineWidth: 0.5)
)
}
private var providerLine: String {
let p = LLMProvider.provider(id: config.providerId)
let model = config.model.isEmpty ? "" : config.model
return "\(p.name) · \(model)"
}
// MARK: - Hero
private var heroButton: some View {
Button {
showSettings = true
} label: {
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)
}
}
private var engineStatusLine: some View {
Text(
EngineServiceLabel.summary(
engineMode: config.engineMode,
providerId: config.providerId,
model: config.model
)
)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
}
}
+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
var body: some View {
ZStack {
palette.background.ignoresSafeArea()
VStack(spacing: 0) {
Group {
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
case .welcome: WelcomePage()
case .microphone:
MicPermissionPage(status: $micStatus)
case .speech:
SpeechPermissionPage(status: $speechStatus)
case .keyboard: EnableKeyboardPage()
case .api: APISetupPage(config: config)
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.28 + geo.safeAreaInsets.top
ZStack(alignment: .top) {
palette.background.ignoresSafeArea()
VStack(spacing: 0) {
Group {
switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
case .welcome: WelcomePage()
case .microphone:
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
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
onboardingHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
}
}
.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() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
@@ -76,7 +99,7 @@ struct OnboardingView: View {
HStack(spacing: 6) {
ForEach(0..<OnboardingPage.count, id: \.self) { i in
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)
.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
private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 180, height: 180)
.blur(radius: 30)
Image(systemName: "mic.circle.fill")
.font(.system(size: 96, weight: .light))
.foregroundStyle(palette.accent)
}
VStack(spacing: Spacing.sm) {
Text("OSGKeyboard")
.font(TypeStyle.title)
VStack(spacing: 0) {
Spacer(minLength: Spacing.xl)
Image("osglogo")
.resizable()
.scaledToFit()
.frame(maxWidth: 168, maxHeight: 48)
.accessibilityHidden(true)
VStack(spacing: Spacing.xs) {
Text("onboarding.welcome.tagline")
.font(TypeStyle.title3)
.foregroundStyle(palette.textPrimary)
Text("onboarding.welcome.subtitle")
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.hero)
PrivacyFootnote()
.padding(.top, Spacing.lg)
.padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.hero)
if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) {
Text("legal.privacyPolicy")
.font(TypeStyle.caption)
.foregroundStyle(palette.accent)
}
.padding(.top, Spacing.xs)
.padding(.top, Spacing.xxxl)
}
Spacer()
}
}
@@ -186,35 +233,24 @@ private struct PrivacyFootnote: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
VStack(alignment: .leading, spacing: 8) {
footnoteRow(icon: "lock.fill", title: "privacy.audio.title", body: "privacy.audio.body")
footnoteRow(icon: "wifi", title: "privacy.network.title", body: "privacy.network.body")
footnoteRow(icon: "keyboard", title: "privacy.universal.title", body: "privacy.universal.body")
VStack(alignment: .leading, spacing: Spacing.xl) {
footnoteBlock(title: "privacy.audio.title", body: "privacy.audio.body")
footnoteBlock(title: "privacy.network.title", body: "privacy.network.body")
footnoteBlock(title: "privacy.universal.title", body: "privacy.universal.body")
}
.padding(Spacing.md)
.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)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func footnoteRow(icon: String, title: LocalizedStringKey, body: LocalizedStringKey) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.accent)
.frame(width: 24, height: 24)
.background(palette.accentMuted, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text(body)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
private func footnoteBlock(title: LocalizedStringKey, body: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(title)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(body)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(3)
}
}
}
@@ -256,7 +292,7 @@ private struct MicPermissionPage: View {
private var statusColor: Color {
switch status {
case .granted: return palette.success
case .granted: return palette.accent
case .denied: return palette.warning
case .undetermined: return palette.textTertiary
}
@@ -316,7 +352,7 @@ private struct SpeechPermissionPage: View {
private var statusColor: Color {
switch status {
case .granted: return palette.success
case .granted: return palette.accent
case .denied, .restricted: return palette.warning
case .undetermined: return palette.textTertiary
}
@@ -352,11 +388,11 @@ private struct PermissionPageLayout: View {
var deniedHint: LocalizedStringKey? = nil
var body: some View {
VStack(spacing: Spacing.lg) {
VStack(spacing: Spacing.xl) {
Spacer()
Image(systemName: icon)
.font(.system(size: 64, weight: .light))
.foregroundStyle(palette.accent)
OnboardingHeroIcon(systemName: icon, circleSize: 96, iconSize: 40)
VStack(spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.title2)
@@ -365,28 +401,32 @@ private struct PermissionPageLayout: View {
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
.padding(.horizontal, Spacing.xl)
}
HStack(spacing: 6) {
Circle().fill(statusColor).frame(width: 8, height: 8)
Text(status)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
if let deniedHint {
Text(deniedHint)
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
.padding(.horizontal, Spacing.xl)
}
VStack(spacing: Spacing.xs) {
VStack(spacing: Spacing.sm) {
Button(action: onPrimary) {
Text(primaryTitle)
.primaryButton()
}
.buttonStyle(.plain)
.disabled(primaryDisabled)
if let secondaryTitle, let onSecondary {
Button(action: onSecondary) {
Text(secondaryTitle)
@@ -395,7 +435,8 @@ private struct PermissionPageLayout: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
Spacer()
}
}
@@ -409,9 +450,9 @@ private struct EnableKeyboardPage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
Image(systemName: "keyboard.fill")
.font(.system(size: 64, weight: .light))
.foregroundStyle(palette.accent)
OnboardingHeroIcon(systemName: "keyboard.fill", circleSize: 96, iconSize: 40)
VStack(spacing: Spacing.sm) {
Text("onboarding.enable.title")
.font(TypeStyle.title2)
@@ -422,14 +463,16 @@ private struct EnableKeyboardPage: View {
.multilineTextAlignment(.center)
.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: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
}
.cardSurface()
.padding(.horizontal, Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.xl)
.padding(.top, Spacing.sm)
Button {
AppPermissions.openSystemSettings()
@@ -438,21 +481,25 @@ private struct EnableKeyboardPage: View {
.primaryButton()
}
.buttonStyle(.plain)
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxl)
Spacer()
}
}
private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
HStack(alignment: .top, spacing: Spacing.sm) {
Text("\(num)")
.font(TypeStyle.caption2)
.frame(width: 22, height: 22)
.frame(width: 24, height: 24)
.background(palette.accent, in: Circle())
.foregroundStyle(palette.textOnAccent)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -467,35 +514,38 @@ private struct APISetupPage: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
VStack(alignment: .leading, spacing: Spacing.lg) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text("onboarding.api.title")
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
Text("onboarding.api.subtitle")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.padding(.top, Spacing.xxs)
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.lg)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xxxl)
EnginePickerSection(config: config)
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" {
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
} else {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.sm) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(palette.accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
ZStack {
Circle()
.fill(palette.accentMuted)
.frame(width: 32, height: 32)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.accent)
}
VStack(alignment: .leading, spacing: 4) {
Text("onboarding.api.localReady.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
@@ -505,14 +555,14 @@ private struct APISetupPage: View {
}
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(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
+45 -27
View File
@@ -1,9 +1,5 @@
// ProviderPickerSection.swift
// 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 OSGKeyboardShared
@@ -42,35 +38,57 @@ struct ProviderPickerSection: View {
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
HStack(spacing: Spacing.xs) {
// Provider mark a coloured dot with first letter
ZStack {
Circle()
.fill(selected ? palette.accent : palette.surfaceElevated)
.frame(width: 36, height: 36)
Text(String(provider.name.prefix(1)))
.font(TypeStyle.bodyEmph)
.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()
HStack(spacing: Spacing.sm) {
providerMark(provider, selected: selected)
Text(ProviderDisplayName.name(for: provider.id))
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.xs)
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .medium))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.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 OSGKeyboardShared
enum SettingsPresentation {
case tab
case sheet
}
struct SettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss
@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.
@State private var dynamicLocales: [(id: String, label: String, onDevice: Bool)] = []
@@ -33,23 +45,31 @@ struct SettingsView: View {
if config.engineMode == "cloud" {
promptSection
}
privacySection
if presentation == .tab {
footerLinks
}
resetButton
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
}
}
.navigationTitle(LocalizedStringKey("settings.title"))
.navigationBarTitleDisplayMode(.inline)
.task { await loadDynamicLocales() }
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
.font(TypeStyle.headline)
.foregroundStyle(palette.accent)
if presentation == .sheet {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
.font(TypeStyle.headline)
.foregroundStyle(palette.accent)
}
}
}
.sheet(item: $safariURL) { url in
SafariSheet(url: url)
}
}
.confirmationDialog(
LocalizedStringKey("settings.reset.title"),
@@ -74,8 +94,8 @@ struct SettingsView: View {
// MARK: - Provider
private var providerSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.provider.title", subtitle: "settings.provider.subtitle")
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.provider.title")
ProviderPickerSection(config: config)
}
}
@@ -83,8 +103,8 @@ struct SettingsView: View {
// MARK: - API
private var apiSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.api.title", subtitle: nil)
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.api.title")
APISettingsCard(config: config)
}
}
@@ -92,11 +112,8 @@ struct SettingsView: View {
// MARK: - Language (ASR + mode)
private var languageSection: some View {
let subtitleKey: LocalizedStringKey = config.engineMode == "cloud"
? "settings.language.subtitle.cloud"
: "settings.language.subtitle.local"
return VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.language.title", subtitle: subtitleKey)
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.language.title")
VStack(spacing: 0) {
if config.engineMode == "cloud" {
PickerRow(
@@ -131,7 +148,7 @@ struct SettingsView: View {
HStack(spacing: Spacing.xs) {
Image(systemName: "iphone")
.font(TypeStyle.caption2)
.foregroundStyle(palette.success)
.foregroundStyle(palette.accent)
Text("settings.legend.onDevice")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -161,17 +178,17 @@ struct SettingsView: View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.success)
.foregroundStyle(palette.accent)
Text("settings.engineBadge.ios26")
.font(TypeStyle.caption)
.foregroundStyle(palette.success)
.foregroundStyle(palette.accent)
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(palette.success.opacity(0.12), in: Capsule())
.background(palette.accent.opacity(0.12), in: Capsule())
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
/// Falls back to a static list while dynamic locales are loading.
@@ -230,9 +247,9 @@ struct SettingsView: View {
// MARK: - Prompt
private var promptSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
HStack {
sectionHeader("settings.systemPrompt.title", subtitle: nil)
sectionHeader("settings.systemPrompt.title")
Spacer()
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2)
@@ -254,39 +271,48 @@ struct SettingsView: View {
}
}
// MARK: - Privacy
// MARK: - Footer links (tab settings only)
private var privacySection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.privacy.title", subtitle: nil)
VStack(alignment: .leading, spacing: Spacing.sm) {
private var footerLinks: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.about.title")
VStack(spacing: 0) {
if let url = LegalLinks.privacyPolicyURL {
Link(destination: url) {
HStack {
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.privacy.policy", url: url)
Divider().background(palette.divider)
}
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
private var resetButton: some View {
@@ -296,26 +322,20 @@ struct SettingsView: View {
Text("settings.reset.confirm")
.font(TypeStyle.caption)
.foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, minHeight: 40)
.frame(maxWidth: .infinity, minHeight: 36)
}
.buttonStyle(.plain)
.padding(.top, presentation == .tab ? Spacing.xs : Spacing.sm)
}
// MARK: - Header
private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -358,7 +378,7 @@ private struct PickerRow: View {
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
private var currentLabel: String {
@@ -402,7 +422,7 @@ private struct LocalePickerRow: View {
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
Image(systemName: "iphone")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(palette.success)
.foregroundStyle(palette.accent)
}
Text(currentLabel)
.font(TypeStyle.body)
@@ -414,7 +434,7 @@ private struct LocalePickerRow: View {
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
private var currentLabel: String {
+30 -1
View File
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard";
/* 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.permission.mic.title" = "Microphone access";
"onboarding.permission.mic.body" = "Required for voice input. Audio is processed on your device for transcription.";
@@ -36,6 +37,7 @@
"common.continue" = "Continue";
"common.reset" = "Reset";
"common.cancel" = "Cancel";
"common.clear" = "Clear";
"common.delete" = "Delete";
"common.space" = "Space";
"common.newline" = "Return";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required.";
"settings.provider.title" = "Provider";
"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.language.title" = "Language";
"settings.language.subtitle.cloud" = "Recognition language and text processing mode.";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "Transcribe";
"settings.mode.polish" = "Polish";
"settings.systemPrompt.title" = "System Prompt";
"settings.about.title" = "About";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "Privacy Policy";
"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.link.github" = "GitHub";
/* App group error */
"appGroup.error.title" = "App Group not configured";
@@ -168,17 +178,36 @@
/* Flow session */
"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.speech" = "Speech recognition denied";
/* Home · Flow session */
"home.flow.active" = "Voice session active";
"home.flow.label" = "Voice session";
"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.starting" = "Starting voice session…";
"home.flow.openSettings" = "Open Settings to grant permissions";
"home.flow.start" = "Start 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.micRequired" = "Microphone access is required for background voice sessions.";
"flow.error.micUnavailable" = "Microphone is unavailable on this device.";
+30 -1
View File
@@ -3,7 +3,8 @@
"OSGKeyboard" = "OSGKeyboard";
/* Onboarding */
"onboarding.welcome.subtitle" = "点按说话,再点结束 — 任意 App 里都能获得润色文字。";
"onboarding.welcome.tagline" = "能说就不打字";
"onboarding.welcome.subtitle" = "你的开源语音输入法";
"onboarding.enable.fullAccessNote" = "完全访问用于麦克风与 API 配置读取。我们不会记录或上传你的击键内容。";
"onboarding.permission.mic.title" = "麦克风权限";
"onboarding.permission.mic.body" = "语音输入需要麦克风。音频在设备端转录,不会上传原始录音。";
@@ -36,6 +37,7 @@
"common.continue" = "继续";
"common.reset" = "重置";
"common.cancel" = "取消";
"common.clear" = "清空";
"common.delete" = "删除";
"common.space" = "空格";
"common.newline" = "换行";
@@ -92,6 +94,12 @@
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
"settings.provider.title" = "提供商";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问";
"provider.zhipu" = "智谱 GLM";
"provider.moonshot" = "月之暗面";
"provider.custom" = "自定义";
"settings.api.title" = "接口";
"settings.language.title" = "语言";
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
@@ -101,6 +109,7 @@
"settings.mode.transcribe" = "转写";
"settings.mode.polish" = "润色";
"settings.systemPrompt.title" = "系统提示";
"settings.about.title" = "关于";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -113,6 +122,7 @@
"settings.privacy.policy" = "隐私政策";
"settings.privacy.fullAccess.title" = "关于完全访问";
"settings.privacy.fullAccess.body" = "完全访问用于麦克风与读取 API Key。OSGKeyboard 不会记录或上传你在键盘上的击键内容。";
"settings.link.github" = "GitHub";
/* App group error */
"appGroup.error.title" = "App Group 未配置";
@@ -167,17 +177,36 @@
/* Flow session */
"flow.error.noSpeech" = "未检测到语音,请重试。";
"flow.error.recognitionInterrupted" = "识别未完成,请再试一次。";
"keyboard.denied.mic" = "麦克风权限被拒绝";
"keyboard.denied.speech" = "语音识别权限被拒绝";
/* Home · Flow session */
"home.flow.active" = "语音会话进行中";
"home.flow.label" = "语音会话";
"home.flow.inactive" = "语音会话未启动";
"home.flow.hint" = "语音会话会自动启动。切到任意 App,点按键盘麦克风即可说话。";
"home.flow.starting" = "正在启动语音会话…";
"home.flow.openSettings" = "前往设置授予权限";
"home.flow.start" = "启动语音会话";
"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.micRequired" = "需要麦克风权限才能维持后台语音会话。";
"flow.error.micUnavailable" = "当前设备无法使用麦克风。";
+89 -72
View File
@@ -8,19 +8,20 @@
// the system Spotlight / home-indicator chrome never clips our controls.
//
//
// [polish] [] top: 32 pt (incl. safe top)
// (transcript preview) 24 pt
// [polish] [] top: ~38 pt (+20%)
// (transcript preview)
//
// centre: 96 pt disc +
// breathing ring
//
//
// 🌐 [ space ] bottom: 60 pt (incl. safe bottom)
// () mic () action row: circular
// (space) flanking buttons
//
import SwiftUI
import OSGKeyboardShared
private enum KeyboardLayoutMetrics {
static let sideActionButtonSize: CGFloat = 44
}
public struct KeyboardRootView: View {
@Environment(\.colorScheme) private var colorScheme
@@ -34,6 +35,8 @@ public struct KeyboardRootView: View {
/// constraint in the view controller so the host UIInputView picks
/// it up.
static let totalHeight: CGFloat = 280
private static let topBarHeight: CGFloat = 38
private static let sideActionStackSpacing: CGFloat = 10
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
@@ -42,13 +45,10 @@ public struct KeyboardRootView: View {
public var body: some View {
VStack(spacing: 0) {
topBar
.frame(height: 32)
.frame(height: Self.topBarHeight)
centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
bottomBar
.frame(height: 56)
}
.padding(.top, 4)
.padding(.bottom, 6)
@@ -79,9 +79,9 @@ public struct KeyboardRootView: View {
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 28, height: 28)
.frame(width: 34, height: 34)
.background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
@@ -94,58 +94,52 @@ public struct KeyboardRootView: View {
// MARK: - Centre area
private var centreArea: some View {
ZStack {
VStack(spacing: Spacing.xxs) {
TranscriptLine(
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
openSettings: state.openSettings
)
.frame(height: 22)
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 140, height: 140)
}
VStack(spacing: Spacing.xxs) {
TranscriptLine(
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
openSettings: state.openSettings
)
.frame(height: 22)
Spacer(minLength: 0)
micActionRow
.padding(.bottom, Spacing.xs)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
}
// MARK: - Bottom bar
private var bottomBar: some View {
// The globe / "next keyboard" button used to live here so the
// user could long-press to call up the system keyboard picker.
// In practice the user already has a "globe" key in the iOS
// 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") {
/// Delete (left), mic (centre), return + space stacked on the right.
/// HStack vertical alignment keeps delete, mic centre, and the gap
/// between return/space on one horizontal axis.
private var micActionRow: some View {
HStack(alignment: .center, spacing: 20) {
CircularToolbarButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Button(action: state.insertSpace) {
ExtL10n.text("keyboard.space")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.space"))
ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline()
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 132, height: 132)
VStack(spacing: Self.sideActionStackSpacing) {
CircularToolbarButton(systemName: "return", label: "newline") {
state.insertNewline()
}
CircularToolbarButton(spaceStyle: true, label: "space") {
state.insertSpace()
}
}
}
.padding(.horizontal, Spacing.sm)
.frame(maxWidth: .infinity)
}
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
let systemName: String
let systemName: String?
let spaceStyle: Bool
let label: String
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 {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(palette.textPrimary)
.frame(width: 40, height: 40)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: 16, height: 3)
} else if let systemName {
Image(systemName: systemName)
.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)
.accessibilityLabel(Text(label))
@@ -365,7 +379,8 @@ private struct LocalEngineChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4)
.padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
@@ -402,7 +417,8 @@ private struct ModeChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(mode == .off ? palette.textTertiary : palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4)
.padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
@@ -462,7 +478,8 @@ private struct LocaleChip: View {
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4)
.padding(.vertical, 5)
.frame(minHeight: 26)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
+7 -3
View File
@@ -85,17 +85,21 @@ struct RecordButton: View {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
.foregroundStyle(palette.textPrimary)
.foregroundStyle(.white)
case .recording:
VStack(spacing: 4) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(isUrgent ? .white : palette.textPrimary)
.foregroundStyle(.white)
.monospacedDigit()
.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)
}
.transition(.opacity)
+18 -15
View File
@@ -55,7 +55,7 @@ public enum Palette {
// Semantic
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
// Text
@@ -95,25 +95,17 @@ public enum Palette {
recordRed: recordRed
)
/// Light palette iOS system light mode defaults. Used by the main app
/// 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.
/// Light palette warm gray backgrounds for daytime use.
public static let light = ThemePalette(
background: Color(red: 0.980, green: 0.980, blue: 0.988), // #FAFAFC
surface: Color(red: 1.000, green: 1.000, blue: 1.000), // #FFFFFF
surfaceElevated: Color(red: 0.941, green: 0.941, blue: 0.961), // #F0F0F5
surfaceMuted: Color(red: 0.953, green: 0.953, blue: 0.965), // #F3F3F6
background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
surfaceElevated: Color(red: 0.922, green: 0.918, blue: 0.906), // #EBEAE7
surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
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),
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
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
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
@@ -169,6 +161,15 @@ public enum Radius {
// 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 static let caption2 = Font.system(size: 11, 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 title = Font.system(size: 28, 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 monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
}
@@ -18,9 +18,9 @@ public enum EngineServiceLabel {
? "\(prefix)本地引擎 · 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)
if trimmedModel.isEmpty { return "\(prefix)\(provider.name)" }
return "\(prefix)\(provider.name) · \(trimmedModel)"
if trimmedModel.isEmpty { return "\(prefix)\(providerName)" }
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/en.lproj
- path: OSGKeyboard/zh-Hans.lproj
- path: OSGKeyboard/Resources/Fonts/MaterialIcons-Regular.ttf
info:
path: OSGKeyboard/Info.plist
properties:
CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
LSApplicationCategoryType: public.app-category.utilities
# English is the development language; zh-Hans is the only
# translation right now. The list also tells iOS to surface
# the keyboard in the per-app language picker for both.
@@ -98,6 +100,10 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
TARGETED_DEVICE_FAMILY: "1"
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
# be passed to actool alongside the asset catalog — it crashes actool.
dependencies:
@@ -161,6 +167,9 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard
TARGETED_DEVICE_FAMILY: "1"
SUPPORTS_MACCATALYST: NO
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
dependencies:
- target: OSGKeyboardShared
embed: false