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