mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-10 14:20:56 +00:00
Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 792e55b2ca | |||
| 5462bce9c3 | |||
| ede54d10cd | |||
| e7a99db03b | |||
| e5a119b6bd | |||
| 315db3fa5a | |||
| 19ef38a377 | |||
| 7797a9a562 | |||
| 55e4bfa11e | |||
| ad0fa41bb5 | |||
| 7c91a609da | |||
| 545eb650ca | |||
| 43f86f4559 | |||
| 25f2cd8192 | |||
| 7ae9198a43 | |||
| 8f3a8bd670 | |||
| e0c3642874 | |||
| 25091dbd60 | |||
| fe7847fc08 | |||
| 77b9252577 | |||
| 3364c0d131 | |||
| deabaf7398 | |||
| 9b780d6b7e | |||
| 93727084ec | |||
| ed178f3b5e | |||
| f46f6f38fc | |||
| ae082c1fd0 | |||
| 6b8eb939c3 | |||
| 89f7bd2e2f | |||
| 2fe487154f | |||
| 71d014af95 | |||
| b1037dec62 | |||
| 3466556849 | |||
| b7951baf23 | |||
| f81fe8cb17 | |||
| 7e8e342e29 | |||
| 445fb173fb | |||
| b317877c41 | |||
| 27c19b5ff5 | |||
| ffd848e5dc | |||
| 45361c276c | |||
| 1b24e3dcef | |||
| 69d88ffefd | |||
| 92fc8ef28f | |||
| 2aa25e3fe7 | |||
| f6bcdb8e6f | |||
| 867a52bc0d | |||
| ec87f280c3 | |||
| 6e362de3da | |||
| 654900bc50 | |||
| 75d0a6181b | |||
| 6de15a92a4 | |||
| b15dcd9730 | |||
| 821f23aed4 | |||
| 79420cd945 | |||
| e3541535e2 |
@@ -161,6 +161,19 @@ jobs:
|
||||
# development profile, which needs a registered device), so client/fastlane/Fastfile
|
||||
# imports the stored distribution certificate and creates the App Store profile
|
||||
# through the API key.
|
||||
# The tag's message, past its subject line, is what testers read under
|
||||
# "What to Test": one short line per change worth trying.
|
||||
- name: TestFlight notes from the tag
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
git fetch --force --depth=1 origin "+${GITHUB_REF}:${GITHUB_REF}"
|
||||
{
|
||||
echo 'TESTFLIGHT_NOTES<<TREK_NOTES_END'
|
||||
# A signed tag carries its signature at the end of the message.
|
||||
git for-each-ref "$GITHUB_REF" --format='%(contents:body)' | sed '/-----BEGIN .*SIGNATURE-----/,$d'
|
||||
echo 'TREK_NOTES_END'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: client
|
||||
env:
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
xcodebuild -version
|
||||
|
||||
- name: Check the property lists
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements
|
||||
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@@ -130,3 +130,32 @@ jobs:
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
# The widgets drawn to PNGs (client/ios/WidgetPreviews): every size, light and
|
||||
# dark, for a trip ahead, a trip under way and none, plus a home screen and
|
||||
# a lock screen of them. Nobody has to install a build to see a widget change.
|
||||
widget-previews:
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
|
||||
test -n "$XCODE"
|
||||
sudo xcode-select -s "$XCODE"
|
||||
|
||||
- name: Render the widgets
|
||||
working-directory: client/ios/WidgetPreviews
|
||||
run: |
|
||||
mkdir -p Sources/WidgetPreviews/Widgets
|
||||
cp ../App/TREKWidgets/WidgetSnapshot.swift ../App/TREKWidgets/TrekStyle.swift ../App/TREKWidgets/TrekFamily.swift ../App/TREKWidgets/WorldGrid.swift \
|
||||
../App/TREKWidgets/*Content.swift Sources/WidgetPreviews/Widgets/
|
||||
swift run -c release WidgetPreviews out "$PWD/../App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png"
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: widget-previews
|
||||
path: client/ios/WidgetPreviews/out
|
||||
retention-days: 14
|
||||
|
||||
@@ -32,8 +32,8 @@ async function swipe(page: Page, from: { x: number; y: number }, to: { x: number
|
||||
}
|
||||
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
||||
await cdp.detach()
|
||||
// The out leg is 150ms and the in leg 220ms.
|
||||
await page.waitForTimeout(500)
|
||||
// The strip snaps natively; give the snap and the selection that follows it time to land.
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
|
||||
const activeChip = (page: Page) => page.locator('button[aria-current="true"]')
|
||||
@@ -72,19 +72,23 @@ test('#2051 phone: the day details area swipes between days', async ({ page }) =
|
||||
|
||||
const day1 = (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// The days are a scroll-snap pager: like a native pager it turns once the
|
||||
// finger has carried the page past its middle, so the swipe covers most of it.
|
||||
const reach = Math.round(env.width * 0.4)
|
||||
const chipText = async () => (await activeChip(page).textContent())!.trim()
|
||||
|
||||
// 1. A swipe left steps forward.
|
||||
await swipe(page, { x: mid + 90, y }, { x: mid - 90, y })
|
||||
const day2 = (await activeChip(page).textContent())!.trim()
|
||||
expect(day2, 'swiping left moved to the next day').not.toBe(day1)
|
||||
await swipe(page, { x: mid + reach, y }, { x: mid - reach, y })
|
||||
await expect.poll(chipText, { message: 'swiping left moved to the next day' }).not.toBe(day1)
|
||||
|
||||
// 2. A swipe right steps back to where it started.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
await expect.poll(chipText).toBe(day1)
|
||||
|
||||
// 3. The first day has nowhere to go back to — it must stay put, and the
|
||||
// browser must not have been handed a page-back either.
|
||||
await swipe(page, { x: mid - 90, y }, { x: mid + 90, y })
|
||||
expect((await activeChip(page).textContent())!.trim()).toBe(day1)
|
||||
await swipe(page, { x: mid - reach, y }, { x: mid + reach, y })
|
||||
expect(await chipText()).toBe(day1)
|
||||
expect(page.url()).toContain(`/trips/${tripId}`)
|
||||
|
||||
// 4. A vertical drag belongs to the card's own scroller, never to the day.
|
||||
|
||||
@@ -103,9 +103,13 @@ platform :ios do
|
||||
|
||||
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
|
||||
# Internal testing needs no App Review, so nothing here submits the app for review.
|
||||
# "What to Test" comes from the release tag's message (native-release.yml). Apple only
|
||||
# takes it once the build is processed, so with notes the lane waits for that.
|
||||
notes = ENV.fetch("TESTFLIGHT_NOTES", "").strip
|
||||
upload_to_testflight(
|
||||
api_key: key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
changelog: notes.empty? ? nil : notes,
|
||||
skip_waiting_for_build_processing: notes.empty?,
|
||||
skip_submission: true,
|
||||
)
|
||||
end
|
||||
|
||||
@@ -26,12 +26,24 @@
|
||||
7A1E00052C0000000000A005 /* TrekBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00042C0000000000A004 /* TrekBridgeViewController.swift */; };
|
||||
7A1E00072C0000000000A007 /* TrekShellPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E00062C0000000000A006 /* TrekShellPlugin.swift */; };
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */; };
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */; };
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D004544572B0E85DE29A7B8 /* AtlasContent.swift */; };
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0771C8582246A26E0C98E /* PackingContent.swift */; };
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D5041DDDAFF5B491B053999 /* CollabContent.swift */; };
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0AC751335E0136865E08990 /* WorldGrid.swift */; };
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */; };
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */ = {isa = PBXBuildFile; fileRef = A030A06726542A2D7F61C54B /* TrekFamily.swift */; };
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */; };
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24B175DE91E1E6E850DB0285 /* TodayContent.swift */; };
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */; };
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */; };
|
||||
84EB79E4B677229481809933 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A03A384FE54FAD1ED68E12DC /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
9582B6832FE993A70072D4E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9582B6822FE993A50072D4E8 /* SceneDelegate.swift */; };
|
||||
A8FDD4DBE3F078BF935CC4B9 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA8802204810ED580DE46F3 /* NotificationService.swift */; };
|
||||
B4C0FC386F71D849E141C91E /* NextTripWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */; };
|
||||
B65B7CFC62EEFF443491EA64 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 192E69C7E66646BC39566925 /* ActivityKit.framework */; };
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */; };
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 248A72D1C511B4969D89333A /* Localizable.strings */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -97,7 +109,46 @@
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NextTripWidget.swift; sourceTree = "<group>"; };
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = "<group>"; };
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripIntent.swift; sourceTree = "<group>"; };
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AtlasContent.swift; sourceTree = "<group>"; };
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PackingContent.swift; sourceTree = "<group>"; };
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CollabContent.swift; sourceTree = "<group>"; };
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WorldGrid.swift; sourceTree = "<group>"; };
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekStyle.swift; sourceTree = "<group>"; };
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrekFamily.swift; sourceTree = "<group>"; };
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TripContent.swift; sourceTree = "<group>"; };
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodayContent.swift; sourceTree = "<group>"; };
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookingsContent.swift; sourceTree = "<group>"; };
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TodosContent.swift; sourceTree = "<group>"; };
|
||||
FFA8802204810ED580DE46F3 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
EE30A6DF943052C6ADE92D63 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
ECC80E1314B31ED2D2347411 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
7328217E23761C22814FEC25 /* az */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = az; path = az.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt-BR; path = pt-BR.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
405B04DFF3A978D1046147C6 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
DD341749E47B7DF89BA9A46D /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
60EB00F3670B41B898765DF9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B680CBF1268D2EE1982B165C /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
12DE24E0B7E2E5F3564D952B /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = he.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
15884CA899CAD3FFD98BA927 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A2F3670F49C3D74C3D98E457 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1947CC62E5A6FF3483BEB578 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
8A0289CB321AE008D8815A19 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
683725459244601B71D92E2D /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
037DA05706AE051CEFCA7D75 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
090D74588DA8436DEC275567 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
9F756414EFBB3542F65C14CE /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5C0109EA628FA897B7BFDD3E /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5800E4ECECED54A47CDABAA /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hans; path = zh-Hans.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh-Hant; path = zh-Hant.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -206,12 +257,24 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BE41D7212DCB0C78E00A8462 /* Info.plist */,
|
||||
248A72D1C511B4969D89333A /* Localizable.strings */,
|
||||
C7064A9DA92FCC8ACAA4F22A /* NextTripWidget.swift */,
|
||||
BBB2E19CD540AB30B44BECCA /* TREKWidgets.entitlements */,
|
||||
8C2E47D0B005674C2B4ADA56 /* TREKWidgetsBundle.swift */,
|
||||
337775CD9091673D0ADDF325 /* TripDayAttributes.swift */,
|
||||
3259F6C40216CECEBAC6F307 /* TripDayLiveActivity.swift */,
|
||||
DBF6D74B7888714BF264E0E6 /* WidgetSnapshot.swift */,
|
||||
8442B4B8988AF5D1BFA3ADE3 /* TripIntent.swift */,
|
||||
4D004544572B0E85DE29A7B8 /* AtlasContent.swift */,
|
||||
04C0771C8582246A26E0C98E /* PackingContent.swift */,
|
||||
7D5041DDDAFF5B491B053999 /* CollabContent.swift */,
|
||||
D0AC751335E0136865E08990 /* WorldGrid.swift */,
|
||||
15CAD7E43FE81EA6E2DD2585 /* TrekStyle.swift */,
|
||||
A030A06726542A2D7F61C54B /* TrekFamily.swift */,
|
||||
DE6873D246AC6B8E2CB6C8B4 /* TripContent.swift */,
|
||||
24B175DE91E1E6E850DB0285 /* TodayContent.swift */,
|
||||
E6D2F4930E5F152C8090C4F8 /* BookingsContent.swift */,
|
||||
AEF7DCAA1AEE67F1291668D1 /* TodosContent.swift */,
|
||||
);
|
||||
name = TREKWidgets;
|
||||
path = TREKWidgets;
|
||||
@@ -305,6 +368,33 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
ar,
|
||||
az,
|
||||
"pt-BR",
|
||||
ca,
|
||||
cs,
|
||||
es,
|
||||
et,
|
||||
fr,
|
||||
el,
|
||||
he,
|
||||
hu,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
nl,
|
||||
pl,
|
||||
ru,
|
||||
sk,
|
||||
sv,
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
vi,
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
@@ -326,6 +416,7 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A0E8821CA7CF51AF2D6D8422 /* Localizable.strings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -382,6 +473,17 @@
|
||||
492FA24FBB57059B0B5007CE /* TripDayAttributes.swift in Sources */,
|
||||
DF633789F2F667C4CF264C9E /* TripDayLiveActivity.swift in Sources */,
|
||||
7FB730DC35C234816EAE65AF /* WidgetSnapshot.swift in Sources */,
|
||||
D3C699E2258FBA6A11C1DDE4 /* TripIntent.swift in Sources */,
|
||||
157A7B737E3FFB610A218D47 /* AtlasContent.swift in Sources */,
|
||||
FEB91385917AC44D5CDAA59C /* PackingContent.swift in Sources */,
|
||||
EA5EDDD4E5F9BC9BC46C563F /* CollabContent.swift in Sources */,
|
||||
45B990B86488D5CC2B5222B5 /* WorldGrid.swift in Sources */,
|
||||
1A0A6E71365C0A1178B85CB8 /* TrekStyle.swift in Sources */,
|
||||
612BB9680DC27BB8BF553060 /* TrekFamily.swift in Sources */,
|
||||
8FC5873BE6A8CB2AFB31A46A /* TripContent.swift in Sources */,
|
||||
9A175689CFCC630BE9BB9EE1 /* TodayContent.swift in Sources */,
|
||||
5A4D344328829E0F0B4D2B94 /* BookingsContent.swift in Sources */,
|
||||
F1E29B18C5B1E75A5BC01B57 /* TodosContent.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -403,6 +505,41 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
248A72D1C511B4969D89333A /* Localizable.strings */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
84CB5C3B6BD4E1DE1A8BD2B4 /* en */,
|
||||
EE30A6DF943052C6ADE92D63 /* de */,
|
||||
ECC80E1314B31ED2D2347411 /* ar */,
|
||||
7328217E23761C22814FEC25 /* az */,
|
||||
CC077989456FB9DB5F4FF676 /* pt-BR */,
|
||||
405B04DFF3A978D1046147C6 /* ca */,
|
||||
DD341749E47B7DF89BA9A46D /* cs */,
|
||||
9A3FAA9DBC67D12389EEF0CF /* es */,
|
||||
8D361F7B14AE9F5ACE3BFE78 /* et */,
|
||||
60EB00F3670B41B898765DF9 /* fr */,
|
||||
B680CBF1268D2EE1982B165C /* el */,
|
||||
12DE24E0B7E2E5F3564D952B /* he */,
|
||||
15884CA899CAD3FFD98BA927 /* hu */,
|
||||
A2F3670F49C3D74C3D98E457 /* id */,
|
||||
1947CC62E5A6FF3483BEB578 /* it */,
|
||||
8A0289CB321AE008D8815A19 /* ja */,
|
||||
B7F33B7A5C27C126DF29DCA4 /* ko */,
|
||||
683725459244601B71D92E2D /* nl */,
|
||||
32A62EE30F8AD514D7DB2CAA /* pl */,
|
||||
037DA05706AE051CEFCA7D75 /* ru */,
|
||||
090D74588DA8436DEC275567 /* sk */,
|
||||
9F756414EFBB3542F65C14CE /* sv */,
|
||||
5C0109EA628FA897B7BFDD3E /* th */,
|
||||
E5800E4ECECED54A47CDABAA /* tr */,
|
||||
A06BBDA4CF70465F2E3F21D3 /* uk */,
|
||||
3CB565FC61AB5AEA87A3F615 /* vi */,
|
||||
608F00CBD96957EEA888E50A /* zh-Hans */,
|
||||
1ED347C91E03E51FDF8ABC1A /* zh-Hant */,
|
||||
);
|
||||
name = Localizable.strings;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
504EC30B1FED79650016851F /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
@@ -431,7 +568,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -662,7 +799,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = TREKWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Kept alive here: WKWebView holds its navigation delegate weakly.
|
||||
private var navigationProxy: TrekNavigationProxy?
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
TrekTileStore.shared.prune()
|
||||
// Capacitor shows its error page for every failed navigation, a replaced
|
||||
// one included: the UI moving to /login while the first page still loads
|
||||
// cancels that load (-999), and a first-time user landed on "Server not
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
static func isReplaced(_ error: Error) -> Bool {
|
||||
let error = error as NSError
|
||||
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|
||||
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
|
||||
}
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Capacitor
|
||||
import CoreLocation
|
||||
import CryptoKit
|
||||
import Security
|
||||
import StoreKit
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
#if canImport(ActivityKit)
|
||||
@@ -39,6 +40,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
CAPPluginMethod(name: "setWidgetSnapshot", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "syncTripDay", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "endTripDay", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeProducts", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storePurchase", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeFinish", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "storeRestore", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// What this binary offers the server's UI, raised with every native feature
|
||||
@@ -46,7 +51,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
/// and a server can require a minimum (minNativeShellApi in /api/health/features).
|
||||
static let shellApi = 2
|
||||
/// The native features by name, so the UI can turn each on by itself.
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity"]
|
||||
static let features: [String] = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit"]
|
||||
|
||||
// MARK: - Shared booking documents
|
||||
|
||||
@@ -57,18 +62,26 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
TrekPush.shared.onOpen = { [weak self] url in
|
||||
DispatchQueue.main.async { self?.notifyListeners("pushOpened", data: ["url": url]) }
|
||||
}
|
||||
TrekStore.watchUpdates()
|
||||
}
|
||||
|
||||
// MARK: - Widgets and the travel-day Live Activity
|
||||
|
||||
/// Stores what the home-screen widget shows (native/widgets.ts builds it, texts
|
||||
/// already translated) where the widget extension reads it, and redraws it.
|
||||
/// `key` (feature widgetTrips) stores one trip's snapshot ("trip.<id>") or the
|
||||
/// list of trips a widget can be set to ("trips"); without it, the dashboard's.
|
||||
@objc func setWidgetSnapshot(_ call: CAPPluginCall) {
|
||||
guard let json = call.getString("json"), let data = json.data(using: .utf8) else {
|
||||
call.reject("json is required")
|
||||
return
|
||||
}
|
||||
TrekSharedKeychain.write("widget.snapshot", data)
|
||||
let key = call.getString("key") ?? "snapshot"
|
||||
guard key == "snapshot" || key == "trips" || key.range(of: "^trip\\.[0-9]{1,12}$", options: .regularExpression) != nil else {
|
||||
call.reject("key must be snapshot, trips or trip.<id>")
|
||||
return
|
||||
}
|
||||
TrekSharedKeychain.write("widget.\(key)", data)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
call.resolve()
|
||||
}
|
||||
@@ -368,6 +381,134 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
|
||||
|
||||
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
|
||||
/// When-In-Use permission asked the first time.
|
||||
// MARK: - In-app purchase
|
||||
|
||||
extension TrekShellPlugin {
|
||||
/// Feature storeKit: the App Store's own price strings for the given products,
|
||||
/// already in the buyer's currency and format. Products the store does not
|
||||
/// know are left out, so the shell offers only what can really be bought.
|
||||
@objc func storeProducts(_ call: CAPPluginCall) {
|
||||
let ids = call.getArray("ids", String.self) ?? []
|
||||
Task {
|
||||
guard AppStore.canMakePayments, let products = try? await Product.products(for: ids) else {
|
||||
call.resolve(["products": []])
|
||||
return
|
||||
}
|
||||
call.resolve(["products": products.map { ["id": $0.id, "displayPrice": $0.displayPrice] }])
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: runs the App Store purchase sheet. Resolves with `kind`
|
||||
/// bought (with the signed transaction the control plane verifies), cancelled,
|
||||
/// pending (Ask to Buy, a payment the bank still has to approve) or failed.
|
||||
/// The transaction stays unfinished until storeFinish, so a purchase whose
|
||||
/// instance was never set up is delivered again rather than lost.
|
||||
@objc func storePurchase(_ call: CAPPluginCall) {
|
||||
guard let id = call.getString("productId") else {
|
||||
call.reject("productId is required")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
guard let product = try await Product.products(for: [id]).first else {
|
||||
call.resolve(["kind": "failed"])
|
||||
return
|
||||
}
|
||||
switch try await product.purchase() {
|
||||
case .success(let verification):
|
||||
guard case .verified(let transaction) = verification else {
|
||||
call.resolve(["kind": "failed"])
|
||||
return
|
||||
}
|
||||
call.resolve([
|
||||
"kind": "bought",
|
||||
"jws": verification.jwsRepresentation,
|
||||
"transactionId": String(transaction.id),
|
||||
])
|
||||
case .userCancelled:
|
||||
call.resolve(["kind": "cancelled"])
|
||||
case .pending:
|
||||
call.resolve(["kind": "pending"])
|
||||
@unknown default:
|
||||
call.resolve(["kind": "failed"])
|
||||
}
|
||||
} catch {
|
||||
call.resolve(["kind": "failed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: marks a purchase as delivered, once its instance exists.
|
||||
@objc func storeFinish(_ call: CAPPluginCall) {
|
||||
guard let raw = call.getString("transactionId"), let id = UInt64(raw) else {
|
||||
call.reject("transactionId is required")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await TrekStore.finish(id)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature storeKit: the newest active subscription on this Apple ID among
|
||||
/// products starting with `prefix`, after syncing with the App Store. Resolves
|
||||
/// without `jws` when there is none.
|
||||
@objc func storeRestore(_ call: CAPPluginCall) {
|
||||
let prefix = call.getString("prefix") ?? "cloud."
|
||||
Task {
|
||||
// Asks for the Apple ID password when needed; a cancel still leaves the local entitlements to check.
|
||||
try? await AppStore.sync()
|
||||
guard let found = await TrekStore.currentEntitlement(prefix: prefix) else {
|
||||
call.resolve([:])
|
||||
return
|
||||
}
|
||||
call.resolve(["jws": found.jws, "transactionId": String(found.id)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TrekStore {
|
||||
private static var updates: Task<Void, Never>?
|
||||
|
||||
/// Listens for transactions that arrive outside a purchase sheet. Renewals are
|
||||
/// finished at once: the control plane learns of them from Apple's server
|
||||
/// notifications. A first purchase that lands here (Ask to Buy approved later,
|
||||
/// a purchase interrupted by the app closing) stays unfinished for the shell
|
||||
/// to redeem through a restore.
|
||||
static func watchUpdates() {
|
||||
guard updates == nil else { return }
|
||||
updates = Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
if transaction.originalID != transaction.id {
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func finish(_ id: UInt64) async {
|
||||
for await result in Transaction.unfinished {
|
||||
if case .verified(let transaction) = result, transaction.id == id {
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64)?
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productID.hasPrefix(prefix),
|
||||
transaction.revocationDate == nil else { continue }
|
||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id)
|
||||
}
|
||||
}
|
||||
return newest.map { ($0.jws, $0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
final class TrekLocation: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private let emit: ([String: Any]) -> Void
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The Atlas on the home screen: the world as dots, the visited countries
|
||||
/// glowing, the latest one ringed, and how many there are.
|
||||
struct AtlasContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let atlas = snapshot?.atlas
|
||||
let labels = snapshot?.labels
|
||||
let visited = Set(atlas?.codes ?? [])
|
||||
let latest = atlas?.recent.first?.code
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: min(1, Double(atlas?.count ?? 0) / 195))
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "globe.europe.africa.fill").font(.system(size: 9, weight: .bold))
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(19, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "globe.europe.africa.fill").font(.system(size: 11, weight: .bold))
|
||||
Text("\(atlas?.count ?? 0) \(labels?.countries ?? "")").font(.system(size: 14, weight: .heavy))
|
||||
}
|
||||
DotWorld(visited: visited, latest: latest, accent: [.white], rest: Color.white.opacity(0.3), dot: 1.3)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
case .inline:
|
||||
Text("\(Image(systemName: "globe.europe.africa.fill")) \(atlas?.count ?? 0) \(labels?.countries ?? "")")
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(38, .heavy)).foregroundStyle(ink)
|
||||
Text(labels?.countries ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(muted)
|
||||
}
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
Spacer(minLength: 6)
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 1.35)
|
||||
.frame(height: 64)
|
||||
}
|
||||
case .medium:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(maxHeight: .infinity)
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(30, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 12, weight: .bold)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 10.5, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let recent = atlas?.recent.first {
|
||||
CountryPill(country: recent, highlight: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline, spacing: 6) {
|
||||
Text("\(atlas?.count ?? 0)").font(Trek.number(40, .heavy)).foregroundStyle(ink)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels?.countries ?? "").font(.system(size: 14, weight: .heavy)).foregroundStyle(ink)
|
||||
Text(share(atlas?.count ?? 0, labels)).font(.system(size: 11, weight: .semibold)).foregroundStyle(muted)
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
if let trips = atlas?.trips { Stat(value: "\(trips)", symbol: "airplane", scheme: scheme) }
|
||||
if let days = atlas?.days { Stat(value: "\(days)", symbol: "calendar", scheme: scheme) }
|
||||
}
|
||||
DotWorld(visited: visited, latest: latest, accent: glow, rest: rest, dot: 2.2)
|
||||
.frame(height: 150)
|
||||
ContinentBars(continents: atlas?.continents ?? [], scheme: scheme)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array((atlas?.recent ?? []).prefix(3).enumerated()), id: \.offset) { index, country in
|
||||
CountryPill(country: country, highlight: index == 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ink: Color { Trek.ink(scheme) }
|
||||
private var muted: Color { Trek.muted(scheme) }
|
||||
private var glow: [Color] { [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)] }
|
||||
private var rest: Color { scheme == .dark ? Color.white.opacity(0.13) : Color(hex: 0x101013).opacity(0.11) }
|
||||
|
||||
/// "12 % of the world", of the 195 countries there are.
|
||||
private func share(_ count: Int, _ labels: WidgetSnapshot.Labels?) -> String {
|
||||
let percent = Int((Double(count) / 195 * 100).rounded())
|
||||
return "\(percent) % \(labels?.world ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
/// The world map as a grid of dots (WorldGrid): visited countries in the
|
||||
/// accent from west to east, the rest faint, the latest one ringed.
|
||||
struct DotWorld: View {
|
||||
let visited: Set<String>
|
||||
let latest: String?
|
||||
let accent: [Color]
|
||||
let rest: Color
|
||||
/// The dot radius at a 364 pt wide map; scaled with the width.
|
||||
var dot: CGFloat = 2
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let columns = CGFloat(WorldGrid.columns)
|
||||
let rows = CGFloat(WorldGrid.rows.count)
|
||||
let step = min(size.width / columns, size.height / rows)
|
||||
let originX = (size.width - step * columns) / 2
|
||||
let originY = (size.height - step * rows) / 2
|
||||
let radius = max(0.6, step * 0.36 * dot / 2)
|
||||
var ringed: CGPoint?
|
||||
var ringCount = 0
|
||||
var ringSum = CGPoint.zero
|
||||
for row in 0..<WorldGrid.rows.count {
|
||||
for column in 0..<WorldGrid.columns {
|
||||
guard let code = WorldGrid.code(row: row, column: column) else { continue }
|
||||
let center = CGPoint(x: originX + (CGFloat(column) + 0.5) * step, y: originY + (CGFloat(row) + 0.5) * step)
|
||||
let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
|
||||
if visited.contains(code) {
|
||||
let t = CGFloat(column) / columns
|
||||
context.fill(Path(ellipseIn: rect.insetBy(dx: -radius * 0.25, dy: -radius * 0.25)), with: .color(blend(t)))
|
||||
if code == latest {
|
||||
ringSum.x += center.x
|
||||
ringSum.y += center.y
|
||||
ringCount += 1
|
||||
}
|
||||
} else {
|
||||
context.fill(Path(ellipseIn: rect), with: .color(rest))
|
||||
}
|
||||
}
|
||||
}
|
||||
if ringCount > 0 { ringed = CGPoint(x: ringSum.x / CGFloat(ringCount), y: ringSum.y / CGFloat(ringCount)) }
|
||||
if let ringed {
|
||||
let color = accent.last ?? .white
|
||||
for (scale, alpha) in [(5.5, 0.18), (3.6, 0.35)] as [(CGFloat, Double)] {
|
||||
let r = step * scale / 2
|
||||
context.stroke(
|
||||
Path(ellipseIn: CGRect(x: ringed.x - r, y: ringed.y - r, width: r * 2, height: r * 2)),
|
||||
with: .color(color.opacity(alpha)), lineWidth: max(1, step * 0.22)
|
||||
)
|
||||
}
|
||||
let core = step * 0.55
|
||||
context.fill(Path(ellipseIn: CGRect(x: ringed.x - core, y: ringed.y - core, width: core * 2, height: core * 2)), with: .color(.white))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The accent at a point from west (0) to east (1), blended between its colours.
|
||||
private func blend(_ t: CGFloat) -> Color {
|
||||
guard accent.count > 1, let from = rgb(accent[0]), let to = rgb(accent[accent.count - 1]) else { return accent.first ?? .white }
|
||||
let k = Double(max(0, min(1, t)))
|
||||
return Color(.sRGB, red: from.0 + (to.0 - from.0) * k, green: from.1 + (to.1 - from.1) * k, blue: from.2 + (to.2 - from.2) * k)
|
||||
}
|
||||
|
||||
private func rgb(_ color: Color) -> (Double, Double, Double)? {
|
||||
#if canImport(UIKit)
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
guard UIColor(color).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
|
||||
return (Double(r), Double(g), Double(b))
|
||||
#elseif canImport(AppKit)
|
||||
guard let c = NSColor(color).usingColorSpace(.sRGB) else { return nil }
|
||||
return (Double(c.redComponent), Double(c.greenComponent), Double(c.blueComponent))
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A country with its flag, the latest one tinted.
|
||||
struct CountryPill: View {
|
||||
let country: WidgetSnapshot.Atlas.Country
|
||||
var highlight = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(flag(country.code)).font(.system(size: 13))
|
||||
Text(country.name).font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(highlight ? Color(hex: 0x3DDC97).opacity(scheme == .dark ? 0.2 : 0.16) : Trek.card(scheme)))
|
||||
.overlay(Capsule().stroke(highlight ? Color(hex: 0x3DDC97).opacity(0.45) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
private func flag(_ code: String) -> String {
|
||||
code.uppercased().unicodeScalars.compactMap { UnicodeScalar(127_397 + $0.value) }.map { String($0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
/// The continents as bars, longest first.
|
||||
struct ContinentBars: View {
|
||||
let continents: [WidgetSnapshot.Atlas.Continent]
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let top = continents.prefix(4)
|
||||
let most = max(top.map(\.count).max() ?? 1, 1)
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(top.enumerated()), id: \.offset) { index, continent in
|
||||
HStack(spacing: 8) {
|
||||
Text(continent.name).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
.frame(width: 92, alignment: .leading).lineLimit(1)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(hex: 0x3DDC97), Color(hex: 0x4A7DDB)], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, geo.size.width * CGFloat(continent.count) / CGFloat(most)))
|
||||
.opacity(1 - Double(index) * 0.15)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
Text("\(continent.count)").font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme)).frame(width: 20, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure with its symbol, for the large atlas header.
|
||||
private struct Stat: View {
|
||||
let value: String
|
||||
let symbol: String
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
|
||||
Text(value).font(Trek.number(12, .heavy))
|
||||
}
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Trek.card(scheme)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import SwiftUI
|
||||
|
||||
/// What is booked next: flights, trains, the hotel, the table for tonight,
|
||||
/// each with its kind as a coloured tile and when it is due.
|
||||
struct BookingsContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let bookings = snapshot?.bookings ?? []
|
||||
let labels = snapshot?.labels
|
||||
if family == .rectangular {
|
||||
accessory(bookings.first, labels)
|
||||
} else if family == .circular {
|
||||
ZStack {
|
||||
Circle().fill(Color.white.opacity(0.18))
|
||||
Image(systemName: bookings.first.map { Trek.booking($0.kind).symbol } ?? "bookmark.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
}
|
||||
} else if family == .inline {
|
||||
if let first = bookings.first {
|
||||
Text("\(Image(systemName: Trek.booking(first.kind).symbol)) \(first.time ?? "") \(first.title)")
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "")
|
||||
}
|
||||
} else if let labels, let first = bookings.first {
|
||||
switch family {
|
||||
case .small: hero(first, labels)
|
||||
case .medium: list(Array(bookings.prefix(2)), labels, featureFirst: true)
|
||||
default: list(Array(bookings.prefix(5)), labels, featureFirst: true)
|
||||
}
|
||||
} else {
|
||||
empty(labels)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Small: the next one, large
|
||||
|
||||
private func hero(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
KindBadge(kind: booking.kind, size: 34)
|
||||
Spacer(minLength: 0)
|
||||
StatusDot(confirmed: booking.confirmed)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day)
|
||||
.font(.system(size: 11, weight: .heavy))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time {
|
||||
Text(time).font(Trek.number(30, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.background(alignment: .topTrailing) {
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: 92, weight: .bold))
|
||||
.foregroundStyle(look.color.opacity(scheme == .dark ? 0.1 : 0.08))
|
||||
.rotationEffect(.degrees(booking.kind == "flight" ? -20 : 0))
|
||||
.offset(x: 28, y: 12)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Medium and large: a list, the first one featured
|
||||
|
||||
private func list(_ bookings: [WidgetSnapshot.Booking], _ labels: WidgetSnapshot.Labels, featureFirst: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: family == .large ? 8 : 7) {
|
||||
HStack {
|
||||
Text(labels.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let trip = snapshot?.trip { Text(trip.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(Trek.faint(scheme)).lineLimit(1) }
|
||||
}
|
||||
ForEach(Array(bookings.enumerated()), id: \.offset) { index, booking in
|
||||
RowLink(path: booking.path) {
|
||||
if index == 0 && featureFirst {
|
||||
featured(booking, labels)
|
||||
} else {
|
||||
row(booking, labels)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large { Spacer(minLength: 0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func featured(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
let look = Trek.booking(booking.kind)
|
||||
return HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 38)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(look.color)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color.opacity(scheme == .dark ? 0.24 : 0.14), look.color.opacity(scheme == .dark ? 0.08 : 0.04)], startPoint: .leading, endPoint: .trailing))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(look.color.opacity(0.25), lineWidth: 0.8))
|
||||
}
|
||||
|
||||
private func row(_ booking: WidgetSnapshot.Booking, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
.padding(.leading, 5)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 5) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
if !booking.confirmed { StatusDot(confirmed: false, size: 6) }
|
||||
}
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).foregroundStyle(Trek.muted(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(13, .heavy)).foregroundStyle(Trek.ink(scheme)) }
|
||||
}
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private func empty(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.bookings ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: -8) {
|
||||
ForEach(["flight", "train", "hotel"], id: \.self) { kind in
|
||||
KindBadge(kind: kind, size: 30).opacity(0.35)
|
||||
}
|
||||
}
|
||||
Text(labels?.noBookings ?? "")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ booking: WidgetSnapshot.Booking?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
if let booking, let labels {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.booking(booking.kind).symbol).font(.system(size: 11, weight: .bold))
|
||||
Text([RelativeDay.label(booking.date, now: now, labels: labels), booking.time].compactMap { $0 }.joined(separator: " "))
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(booking.title).font(.system(size: 14, weight: .heavy)).lineLimit(1)
|
||||
if let detail = booking.detail { Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1) }
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels?.noBookings ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirmed in green, pending in amber, with a soft halo.
|
||||
struct StatusDot: View {
|
||||
let confirmed: Bool
|
||||
var size: CGFloat = 9
|
||||
|
||||
var body: some View {
|
||||
let color = confirmed ? Trek.confirmed : Trek.pending
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Circle().stroke(color.opacity(0.3), lineWidth: size * 0.45).frame(width: size * 1.7, height: size * 1.7))
|
||||
.frame(width: size * 1.8, height: size * 1.8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The travel group: who comes along, the latest messages in the trip chat
|
||||
/// as bubbles, and the open poll with its votes as bars.
|
||||
struct CollabContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let collab = snapshot?.collab
|
||||
let labels = snapshot?.labels
|
||||
if let collab, let labels {
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(labels.group ?? "").trekEyebrow(scheme)
|
||||
AvatarStack(members: collab.members, size: 30, scheme: scheme).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let poll = collab.poll {
|
||||
Label(labels.poll ?? "", systemImage: "chart.bar.fill")
|
||||
.font(.system(size: 10.5, weight: .heavy))
|
||||
.foregroundStyle(Trek.info)
|
||||
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
} else if let last = collab.messages.last {
|
||||
Text(last.author).font(.system(size: 10.5, weight: .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
Text(last.text).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(3)
|
||||
} else {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 12.5, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
case .medium:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(snapshot?.trip?.title ?? labels.group ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 4)
|
||||
AvatarStack(members: collab.members, size: 22, scheme: scheme)
|
||||
}
|
||||
bubbles(Array(collab.messages.suffix(2)), labels)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
case .large:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(labels.group ?? "").trekEyebrow(scheme)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 16, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
AvatarStack(members: collab.members, size: 28, scheme: scheme)
|
||||
}
|
||||
bubbles(Array(collab.messages.suffix(3)), labels)
|
||||
Spacer(minLength: 0)
|
||||
if let poll = collab.poll { PollCard(poll: poll, labels: labels, scheme: scheme) }
|
||||
}
|
||||
default:
|
||||
accessory(collab, labels)
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.group ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "person.3.fill").font(.system(size: 28)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
|
||||
Text(labels?.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bubbles(_ messages: [WidgetSnapshot.Collab.Message], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
if messages.isEmpty {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
ForEach(Array(messages.enumerated()), id: \.offset) { _, message in
|
||||
ChatBubble(message: message, scheme: scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ collab: WidgetSnapshot.Collab, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
if let last = collab.messages.last {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill").font(.system(size: 11, weight: .bold))
|
||||
Text(last.author).font(.system(size: 12, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
Text(last.text).font(.system(size: 13, weight: .semibold)).lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Text(labels.noMessages ?? "").font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Trek {
|
||||
/// A colour of a person's own, from their name.
|
||||
static func personColor(_ name: String) -> Color {
|
||||
let palette: [UInt32] = [0x4A7DDB, 0x2FA37A, 0xE8A13A, 0x8E6CD9, 0xD6408B, 0x2BA6A6, 0xD6673B, 0x5A6CDE]
|
||||
return Color(hex: palette[Int(seed(name) % UInt64(palette.count))])
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlapping initials in each person's colour, with a count for the rest.
|
||||
struct AvatarStack: View {
|
||||
let members: [WidgetSnapshot.Collab.Member]
|
||||
let size: CGFloat
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: -size * 0.18) {
|
||||
ForEach(Array(members.prefix(4).enumerated()), id: \.offset) { _, member in
|
||||
Circle()
|
||||
.fill(LinearGradient(colors: [Trek.personColor(member.name), Trek.personColor(member.name).opacity(0.75)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Text(member.initials).font(.system(size: size * 0.34, weight: .heavy)).minimumScaleFactor(0.7).lineLimit(1).foregroundStyle(.white).padding(.horizontal, size * 0.1))
|
||||
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
|
||||
}
|
||||
if members.count > 4 {
|
||||
Circle()
|
||||
.fill(Trek.card(scheme))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(Text("+\(members.count - 4)").font(.system(size: size * 0.34, weight: .heavy)).foregroundStyle(Trek.muted(scheme)))
|
||||
.overlay(Circle().stroke(scheme == .dark ? Color(hex: 0x111115) : Color(hex: 0xF4F2EE), lineWidth: 2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat message: the others on the left with their initials, mine on the right in ink.
|
||||
struct ChatBubble: View {
|
||||
let message: WidgetSnapshot.Collab.Message
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 6) {
|
||||
if message.mine { Spacer(minLength: 36) }
|
||||
if !message.mine {
|
||||
Circle()
|
||||
.fill(Trek.personColor(message.author))
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Text(message.initials).font(.system(size: 8, weight: .heavy)).foregroundStyle(.white))
|
||||
}
|
||||
Text(message.text)
|
||||
.font(.system(size: 12.5, weight: .medium))
|
||||
.foregroundStyle(message.mine ? (scheme == .dark ? Color(hex: 0x101013) : .white) : Trek.ink(scheme))
|
||||
.lineLimit(2)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(message.mine ? Trek.ink(scheme) : Trek.card(scheme))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(message.mine ? Color.clear : Trek.cardBorder(scheme), lineWidth: 0.7)
|
||||
)
|
||||
if !message.mine { Spacer(minLength: 36) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The open poll: the question and each option as a bar of its votes.
|
||||
struct PollCard: View {
|
||||
let poll: WidgetSnapshot.Collab.Poll
|
||||
let labels: WidgetSnapshot.Labels
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let most = max(poll.options.map(\.votes).max() ?? 1, 1)
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "chart.bar.fill").font(.system(size: 10, weight: .bold)).foregroundStyle(Trek.info)
|
||||
Text(labels.poll ?? "").font(.system(size: 10.5, weight: .heavy)).textCase(.uppercase).foregroundStyle(Trek.info)
|
||||
Spacer(minLength: 0)
|
||||
Text("\(poll.voters) \(labels.votes ?? "")").font(.system(size: 10.5, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
Text(poll.question).font(.system(size: 13.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
ForEach(Array(poll.options.prefix(3).enumerated()), id: \.offset) { _, option in
|
||||
let leading = option.votes == most && option.votes > 0
|
||||
ZStack(alignment: .leading) {
|
||||
GeometryReader { geo in
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(leading ? Trek.info.opacity(scheme == .dark ? 0.35 : 0.22) : Trek.track(scheme))
|
||||
.frame(width: max(8, geo.size.width * CGFloat(option.votes) / CGFloat(most)))
|
||||
}
|
||||
HStack {
|
||||
Text(option.label).font(.system(size: 12, weight: leading ? .heavy : .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text("\(option.votes)").font(Trek.number(12, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
}
|
||||
.frame(height: 24)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 15, style: .continuous).fill(Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,249 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// "Next trip" on the home screen: the trip and how far off it is, or while it
|
||||
/// runs, today's day and the next stops (medium size).
|
||||
struct NextTripEntry: TimelineEntry {
|
||||
/// The home-screen and lock-screen widgets; all but the Atlas show one trip,
|
||||
/// the dashboard's or the one picked in Edit Widget (TripIntent.swift). Their gallery names and
|
||||
/// descriptions are in <language>.lproj/Localizable.strings. Each reads the snapshot the app
|
||||
/// last wrote (WidgetSnapshot) and draws it with the views in *Content.swift,
|
||||
/// which know nothing of WidgetKit so the preview renderer can draw them too.
|
||||
struct TrekEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct NextTripProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> NextTripEntry {
|
||||
NextTripEntry(date: Date(), snapshot: nil)
|
||||
struct TrekProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TrekEntry {
|
||||
TrekEntry(date: Date(), snapshot: .sample(running: false))
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (NextTripEntry) -> Void) {
|
||||
completion(NextTripEntry(date: Date(), snapshot: WidgetSnapshot.load()))
|
||||
/// The gallery shows the user's own trip when the app has written one, a
|
||||
/// sample trip before that.
|
||||
func getSnapshot(in context: Context, completion: @escaping (TrekEntry) -> Void) {
|
||||
let stored = WidgetSnapshot.load()
|
||||
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
|
||||
completion(TrekEntry(date: Date(), snapshot: snapshot))
|
||||
}
|
||||
|
||||
/// The app reloads the timeline whenever it writes a new snapshot; past
|
||||
/// midnight the countdown moves on by itself.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<NextTripEntry>) -> Void) {
|
||||
/// An entry every quarter hour for the next six hours, so the now line and
|
||||
/// the next stop move on without the app; the app reloads the timeline
|
||||
/// whenever it writes a new snapshot.
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TrekEntry>) -> Void) {
|
||||
completion(TrekProvider.timeline(WidgetSnapshot.load()))
|
||||
}
|
||||
|
||||
static func timeline(_ snapshot: WidgetSnapshot?) -> Timeline<TrekEntry> {
|
||||
let now = Date()
|
||||
let midnight = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
|
||||
let entry = NextTripEntry(date: now, snapshot: WidgetSnapshot.load())
|
||||
completion(Timeline(entries: [entry], policy: .after(midnight)))
|
||||
let calendar = Calendar.current
|
||||
let minute = calendar.component(.minute, from: now)
|
||||
let firstStep = calendar.date(byAdding: .minute, value: 15 - minute % 15, to: now) ?? now
|
||||
var entries = [TrekEntry(date: now, snapshot: snapshot)]
|
||||
for step in 0..<24 {
|
||||
if let date = calendar.date(byAdding: .minute, value: step * 15, to: firstStep) {
|
||||
entries.append(TrekEntry(date: calendar.date(bySetting: .second, value: 0, of: date) ?? date, snapshot: snapshot))
|
||||
}
|
||||
}
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
||||
struct NextTripWidgetView: View {
|
||||
extension TrekFamily {
|
||||
init(_ family: WidgetFamily) {
|
||||
switch family {
|
||||
case .systemSmall: self = .small
|
||||
case .systemMedium: self = .medium
|
||||
case .accessoryCircular: self = .circular
|
||||
case .accessoryRectangular: self = .rectangular
|
||||
case .accessoryInline: self = .inline
|
||||
default: self = .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The widget's ground: the content on its background, through the container
|
||||
/// background on iOS 17 (which the system can tint and remove), the plain way before.
|
||||
struct TrekContainer<Content: View, Background: View>: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: NextTripEntry
|
||||
let link: URL?
|
||||
@ViewBuilder var content: () -> Content
|
||||
@ViewBuilder var background: () -> Background
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.widgetURL(entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) })
|
||||
.modifier(WidgetBackground())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let snapshot = entry.snapshot, let trip = snapshot.trip {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.today == nil ? snapshot.labels.nextTrip : snapshot.labels.today)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
Text(trip.title)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
if let today = snapshot.today {
|
||||
Text(today.dayLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
if family != .systemSmall {
|
||||
stops(today.stops, nothing: snapshot.labels.nothingPlanned)
|
||||
}
|
||||
} else if let days = snapshot.daysUntilStart(from: entry.date), days > 0 {
|
||||
Spacer(minLength: 0)
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: String(days)))
|
||||
.font(.title3.weight(.bold))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("TREK").font(.headline)
|
||||
Text(entry.snapshot?.labels.noTrip ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func stops(_ stops: [WidgetSnapshot.Stop], nothing: String) -> some View {
|
||||
if stops.isEmpty {
|
||||
Text(nothing).font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(Array(stops.prefix(3).enumerated()), id: \.offset) { _, stop in
|
||||
HStack(spacing: 6) {
|
||||
Text(stop.time ?? "")
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 36, alignment: .leading)
|
||||
Text(stop.name).font(.caption).lineLimit(1)
|
||||
}
|
||||
let accessory = TrekFamily(family).isAccessory
|
||||
Group {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content().containerBackground(for: .widget) {
|
||||
if accessory { Color.clear } else { background() }
|
||||
}
|
||||
} else {
|
||||
content().padding(accessory ? 0 : 16).background(accessory ? nil : background())
|
||||
}
|
||||
}
|
||||
.widgetURL(link)
|
||||
}
|
||||
}
|
||||
|
||||
/// The system background on iOS 17 and later, the plain one before.
|
||||
struct WidgetBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content.containerBackground(.fill.tertiary, for: .widget)
|
||||
} else {
|
||||
content.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: - Trip
|
||||
|
||||
struct NextTripWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "NextTrip", provider: NextTripProvider()) { entry in
|
||||
NextTripWidgetView(entry: entry)
|
||||
// The kind stays "NextTrip" so widgets placed before the redesign keep working.
|
||||
AppIntentConfiguration(kind: "NextTrip", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TripWidgetView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("TREK")
|
||||
.description("Your next trip, and today's plan while you travel.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
.configurationDisplayName("widget.trip.name")
|
||||
.description("widget.trip.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
struct TripWidgetView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
|
||||
var body: some View {
|
||||
let snapshot = entry.snapshot
|
||||
TrekContainer(link: snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TripContent(snapshot: snapshot, family: TrekFamily(family), now: entry.date)
|
||||
} background: {
|
||||
if snapshot?.trip != nil { TripBackdrop(trip: snapshot?.trip) } else { TrekPaper() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Today
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripToday", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.trip.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodayFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.today.name")
|
||||
.description("widget.today.description")
|
||||
.supportedFamilies([.systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodayFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodayContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Bookings
|
||||
|
||||
struct BookingsWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripBookings", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.bookings?.first.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
BookingsFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.bookings.name")
|
||||
.description("widget.bookings.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
private struct BookingsFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { BookingsContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - To-dos
|
||||
|
||||
struct TodosWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripTodos", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.todos.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
TodosFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.todos.name")
|
||||
.description("widget.todos.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct TodosFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { TodosContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Packing
|
||||
|
||||
struct PackingWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripPacking", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.packing.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
PackingFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.packing.name")
|
||||
.description("widget.packing.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct PackingFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { PackingContent(snapshot: entry.snapshot, family: TrekFamily(family), now: entry.date) }
|
||||
}
|
||||
|
||||
// MARK: - Atlas
|
||||
|
||||
struct AtlasWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: "TripAtlas", provider: TrekProvider()) { entry in
|
||||
TrekContainer(link: WidgetSnapshot.link("/atlas")) {
|
||||
AtlasFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper(contours: false)
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.atlas.name")
|
||||
.description("widget.atlas.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
private struct AtlasFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { AtlasContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
|
||||
}
|
||||
|
||||
// MARK: - Travel group
|
||||
|
||||
struct CollabWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: "TripCollab", intent: SelectTripIntent.self, provider: TrekTripProvider()) { entry in
|
||||
TrekContainer(link: entry.snapshot?.collab.flatMap { WidgetSnapshot.link($0.path) }) {
|
||||
CollabFamilyView(entry: entry)
|
||||
} background: {
|
||||
TrekPaper()
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("widget.collab.name")
|
||||
.description("widget.collab.description")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
private struct CollabFamilyView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
let entry: TrekEntry
|
||||
var body: some View { CollabContent(snapshot: entry.snapshot, family: TrekFamily(family)) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The packing list: how much is in the bags, each bag against its weight
|
||||
/// limit, and what is still missing, with a nudge when departure is close.
|
||||
struct PackingContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let packing = snapshot?.packing
|
||||
let labels = snapshot?.labels
|
||||
if family.isAccessory {
|
||||
accessory(packing, labels)
|
||||
} else if let packing, let labels, packing.total > 0 {
|
||||
let missing = packing.total - packing.packed
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(labels)
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
ring(packing, size: 88)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text("\(missing) \(labels.missing ?? "")")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
ring(packing, size: 104)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header(labels)
|
||||
missingList(Array((packing.missing ?? []).prefix(4)))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ring(packing, size: 86)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
header(labels)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
Text("\(missing) \(labels.missing ?? "")")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
VStack(spacing: 7) {
|
||||
ForEach(Array((packing.bags ?? []).prefix(3).enumerated()), id: \.offset) { _, bag in
|
||||
BagCard(bag: bag, scheme: scheme)
|
||||
}
|
||||
}
|
||||
missingGrid(Array((packing.missing ?? []).prefix(8)))
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
header(labels)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 32)).foregroundStyle(Trek.muted(scheme).opacity(0.5))
|
||||
Text(labels?.noTodos ?? "").font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// The days to departure while it is two or fewer away.
|
||||
private var departureSoon: Int? {
|
||||
guard snapshot?.today == nil, let days = snapshot?.daysUntilStart(from: now), (0...2).contains(days) else { return nil }
|
||||
return days
|
||||
}
|
||||
|
||||
private func header(_ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(labels?.packing ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
if let days = departureSoon, let labels {
|
||||
Text(days == 0 ? (labels.todayShort ?? labels.today) : days == 1 ? labels.tomorrow : labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
|
||||
.font(.system(size: 10, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Trek.pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ring(_ packing: WidgetSnapshot.Packing, size: CGFloat) -> some View {
|
||||
let share = Double(packing.packed) / Double(max(packing.total, 1))
|
||||
return ProgressRing(value: share, lineWidth: size * 0.1, colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], trackColor: Trek.track(scheme)) {
|
||||
VStack(spacing: -1) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: size * 0.16, weight: .semibold)).foregroundStyle(Trek.confirmed)
|
||||
Text("\(Int((share * 100).rounded()))%").font(Trek.number(size * 0.22, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
private func missingList(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.7)
|
||||
.frame(width: 13, height: 13)
|
||||
Text(item.name).font(.system(size: 13, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func missingGrid(_ items: [WidgetSnapshot.Packing.Item]) -> some View {
|
||||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)], alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 7) {
|
||||
RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.stroke(Color(css: item.color) ?? Trek.faint(scheme), lineWidth: 1.6)
|
||||
.frame(width: 12, height: 12)
|
||||
Text(item.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ packing: WidgetSnapshot.Packing?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
let total = packing?.total ?? 0
|
||||
let packed = packing?.packed ?? 0
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: total > 0 ? Double(packed) / Double(total) : 0)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 9, weight: .bold))
|
||||
Text("\(total - packed)").font(Trek.number(18, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold))
|
||||
Text("\(labels?.packing ?? "") \(packed)/\(total)").font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
ForEach(Array((packing?.missing ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
|
||||
Text("\(Image(systemName: "square")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A bag in its colour: how full, and its weight against the limit.
|
||||
struct BagCard: View {
|
||||
let bag: WidgetSnapshot.Packing.Bag
|
||||
let scheme: ColorScheme
|
||||
|
||||
var body: some View {
|
||||
let color = Color(css: bag.color) ?? Trek.info
|
||||
let share = bag.limit.map { Double(bag.grams) / Double(max($0, 1)) }
|
||||
HStack(spacing: 10) {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(LinearGradient(colors: [color, color.opacity(0.75)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay(Image(systemName: "bag.fill").font(.system(size: 13, weight: .semibold)).foregroundStyle(.white))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(bag.name).font(.system(size: 12.5, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(weight(bag)).font(Trek.number(11.5, .heavy)).foregroundStyle((share ?? 0) > 1 ? Trek.danger : Trek.muted(scheme))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill((share ?? 0) > 1 ? Trek.danger : color)
|
||||
.frame(width: max(5, geo.size.width * CGFloat(min(share ?? Double(bag.packed) / Double(max(bag.total, 1)), 1))))
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(RoundedRectangle(cornerRadius: 13, style: .continuous).fill(Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
/// "14.2 / 23 kg", or the packed count when the bag has no limit.
|
||||
private func weight(_ bag: WidgetSnapshot.Packing.Bag) -> String {
|
||||
let kg = { (grams: Int) in String(format: "%.1f", Double(grams) / 1000) }
|
||||
if let limit = bag.limit { return "\(kg(bag.grams)) / \(kg(limit)) kg" }
|
||||
return "\(bag.packed)/\(bag.total)"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// The TREK widgets: the next trip on the home screen, and the travel day as a
|
||||
/// Live Activity.
|
||||
/// The TREK widgets: the trip, today's plan, the bookings ahead, the to-dos,
|
||||
/// the packing list, the Atlas and the travel group on the home and lock
|
||||
/// screen, and the travel day as a Live Activity.
|
||||
@main
|
||||
struct TREKWidgetsBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
NextTripWidget()
|
||||
TodayWidget()
|
||||
BookingsWidget()
|
||||
TodosWidget()
|
||||
PackingWidget()
|
||||
AtlasWidget()
|
||||
CollabWidget()
|
||||
TripDayLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Today's plan on paper: the day, the weather, and the stops as a timeline
|
||||
/// with a now line, so a glance says what is next and what is behind.
|
||||
struct TodayContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
if let snapshot, let trip = snapshot.trip, let today = snapshot.today {
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
let timed = today.stops.filter { $0.minutes != nil }
|
||||
let done = timed.filter { ($0.minutes ?? 0) < nowMinutes }.count
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
DayBadge(index: today.index ?? 1, total: total)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(trip.title).trekEyebrow(scheme)
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: family == .large ? 19 : 16, weight: .heavy))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
if family == .large {
|
||||
PaperWeather(weather: weather)
|
||||
} else {
|
||||
WeatherChip(weather: weather)
|
||||
}
|
||||
}
|
||||
}
|
||||
if family == .large {
|
||||
DayProgress(done: done, total: timed.count, scheme: scheme, label: snapshot.labels.allDone)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 14)
|
||||
} else {
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
StopList(
|
||||
stops: today.stops, nowMinutes: nowMinutes, limit: family == .large ? 7 : 3,
|
||||
nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now
|
||||
)
|
||||
if family == .large {
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now, onPhoto: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
// Before the trip there is no day yet: count down to it instead.
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrow(scheme)
|
||||
Text(trip.title).font(.system(size: 17, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(40, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 13, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
RouteArc(progress: 1 - min(Double(days), 60) / 60, color: Trek.ink(scheme)).frame(height: 24)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's number in a dark tile with the trip's length under it.
|
||||
struct DayBadge: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(index)").font(Trek.number(19, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.7)
|
||||
}
|
||||
.foregroundStyle(scheme == .dark ? Color(hex: 0x101013) : .white)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Trek.ink(scheme))
|
||||
)
|
||||
.shadow(color: .black.opacity(scheme == .dark ? 0 : 0.18), radius: 5, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather on paper: symbol, temperature, and the day's range and rain.
|
||||
struct PaperWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(20, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
HStack(spacing: 4) {
|
||||
if let high = weather.high, let low = weather.low { Text("\(high)°/\(low)°") }
|
||||
if let rain = weather.rain, rain > 0 {
|
||||
Text("\(Image(systemName: "drop.fill"))\(rain)%")
|
||||
}
|
||||
}
|
||||
.font(Trek.number(10, .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the day is behind: a bar of the stops passed.
|
||||
struct DayProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
let scheme: ColorScheme
|
||||
let label: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Trek.confirmed.opacity(0.8), Trek.confirmed], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: total > 0 ? max(6, geo.size.width * CGFloat(done) / CGFloat(total)) : 0)
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
Text(done >= total && total > 0 ? (label ?? "\(done)/\(total)") : "\(done)/\(total)")
|
||||
.font(Trek.number(11, .bold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip's open to-dos and the packing list: how far along both are, as
|
||||
/// rings and a bar, and the to-dos that matter most, by due date and priority.
|
||||
struct TodosContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let todos = snapshot?.todos
|
||||
let labels = snapshot?.labels
|
||||
if family.isAccessory {
|
||||
accessory(todos, labels)
|
||||
} else if let labels, let todos, todos.total > 0 {
|
||||
switch family {
|
||||
case .small: small(todos, labels)
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
rings(todos, labels, size: 104)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(labels, todos)
|
||||
items(Array(todos.items.prefix(3)), labels).padding(.top, 6)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
rings(todos, labels, size: 96)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
header(labels, todos)
|
||||
}
|
||||
}
|
||||
Divider().overlay(Trek.track(scheme)).padding(.vertical, 10)
|
||||
items(Array(todos.items.prefix(7)), labels)
|
||||
Spacer(minLength: 8)
|
||||
legend(todos, labels)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(labels?.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 34))
|
||||
.foregroundStyle(Trek.confirmed.opacity(0.85))
|
||||
Text(labels?.noTodos ?? "").font(.system(size: 14, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func small(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
rings(todos, labels, size: 92)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let first = todos.items.first {
|
||||
HStack(spacing: 5) {
|
||||
Circle().stroke(Trek.priority(first.priority) ?? Trek.faint(scheme), lineWidth: 1.6).frame(width: 10, height: 10)
|
||||
Text(first.name).font(.system(size: 12, weight: .semibold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of the to-dos done as a ring, the open ones in the middle.
|
||||
private func rings(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
let done = todos.total > 0 ? Double(todos.total - todos.open) / Double(todos.total) : 1
|
||||
let width = size * 0.1
|
||||
return ZStack {
|
||||
ProgressRing(value: done, lineWidth: width, colors: [Color(hex: 0x4A7DDB), Color(hex: 0x6A5BE0)], trackColor: Trek.track(scheme)) { EmptyView() }
|
||||
VStack(spacing: -2) {
|
||||
Text("\(todos.open)").font(Trek.number(size * 0.3, .heavy)).foregroundStyle(Trek.ink(scheme))
|
||||
Image(systemName: "checklist").font(.system(size: size * 0.11, weight: .bold)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
|
||||
/// What the two rings stand for, with their counts.
|
||||
private func legend(_ todos: WidgetSnapshot.Todos, _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
LegendPill(color: Color(hex: 0x5A6CDE), symbol: "checklist", text: labels.todos ?? "", count: "\(todos.total - todos.open)/\(todos.total)")
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ labels: WidgetSnapshot.Labels, _ todos: WidgetSnapshot.Todos) -> some View {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(labels.todos ?? "").trekEyebrow(scheme)
|
||||
if let trip = snapshot?.trip {
|
||||
Text(trip.title).font(.system(size: 15, weight: .heavy)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func items(_ items: [WidgetSnapshot.Todo], _ labels: WidgetSnapshot.Labels) -> some View {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.stroke(Trek.priority(item.priority) ?? Trek.faint(scheme), lineWidth: 1.7)
|
||||
.frame(width: 14, height: 14)
|
||||
Text(item.name)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let due = RelativeDay.label(item.due, now: now, labels: labels) {
|
||||
Text(due)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(overdue(item.due) ? Trek.danger : Trek.muted(scheme))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(overdue(item.due) ? Trek.danger.opacity(0.12) : Trek.card(scheme)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overdue(_ due: String?) -> Bool {
|
||||
guard let due, let date = WidgetSnapshot.day(due), let days = WidgetSnapshot.daysBetween(now, date) else { return false }
|
||||
return days <= 0
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accessory(_ todos: WidgetSnapshot.Todos?, _ labels: WidgetSnapshot.Labels?) -> some View {
|
||||
let open = todos?.open ?? 0
|
||||
let total = todos?.total ?? 0
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: total > 0 ? Double(total - open) / Double(total) : 1)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -2) {
|
||||
Image(systemName: "checklist").font(.system(size: 9, weight: .bold))
|
||||
Text("\(open)").font(Trek.number(18, .heavy))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checklist").font(.system(size: 11, weight: .bold))
|
||||
Text("\(labels?.todos ?? "") \(open)/\(total)").font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
ForEach(Array((todos?.items ?? []).prefix(2).enumerated()), id: \.offset) { _, item in
|
||||
Text("\(Image(systemName: "circle")) \(item.name)").font(.system(size: 12, weight: .medium)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
Text("\(Image(systemName: "checklist")) \(open) \(labels?.todos ?? "")")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring's colour, what it counts, and how far it is.
|
||||
struct LegendPill: View {
|
||||
let color: Color
|
||||
let symbol: String
|
||||
let text: String
|
||||
let count: String
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(color).frame(width: 7, height: 7)
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).foregroundStyle(color)
|
||||
Text(text).font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.ink(scheme)).lineLimit(1)
|
||||
Text(count).font(Trek.number(11, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background(Capsule().fill(Trek.card(scheme)))
|
||||
.overlay(Capsule().stroke(Trek.cardBorder(scheme), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// The packing list as a bar with the suitcase and the count.
|
||||
struct PackingBar: View {
|
||||
let packing: WidgetSnapshot.Packing
|
||||
let labels: WidgetSnapshot.Labels
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let share = packing.total > 0 ? Double(packing.packed) / Double(packing.total) : 1
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "suitcase.rolling.fill").font(.system(size: 11, weight: .bold)).foregroundStyle(Trek.confirmed)
|
||||
Text(labels.packing ?? "").font(.system(size: 11.5, weight: .bold)).foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Text("\(packing.packed)/\(packing.total)").font(Trek.number(11.5, .heavy)).foregroundStyle(Trek.muted(scheme))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Trek.track(scheme))
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [Color(hex: 0x2FA37A), Color(hex: 0x7BC96F)], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(width: max(6, geo.size.width * CGFloat(share)))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
|
||||
/// The sizes the widget views draw for. Their own enum rather than WidgetKit's,
|
||||
/// so the views also render outside a widget (the preview renderer in
|
||||
/// client/ios/WidgetPreviews draws every size to a PNG on the CI's Mac).
|
||||
enum TrekFamily: String, CaseIterable {
|
||||
case small, medium, large, circular, rectangular, inline
|
||||
|
||||
var isAccessory: Bool { self == .circular || self == .rectangular || self == .inline }
|
||||
|
||||
/// The point size of the family on a 6.1" iPhone, for the renderer.
|
||||
var previewSize: CGSize {
|
||||
switch self {
|
||||
case .small: return CGSize(width: 170, height: 170)
|
||||
case .medium: return CGSize(width: 364, height: 170)
|
||||
case .large: return CGSize(width: 364, height: 382)
|
||||
case .circular: return CGSize(width: 76, height: 76)
|
||||
case .rectangular: return CGSize(width: 172, height: 76)
|
||||
case .inline: return CGSize(width: 250, height: 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WidgetSnapshot {
|
||||
/// What the widget gallery shows before the app ever wrote a snapshot, and
|
||||
/// what the renderer draws: a trip to Kyoto with a full day, bookings and to-dos.
|
||||
static func sample(running: Bool, now: Date = Date()) -> WidgetSnapshot {
|
||||
let calendar = Calendar.current
|
||||
let iso: (Int) -> String = { offset in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: calendar.date(byAdding: .day, value: offset, to: now) ?? now)
|
||||
}
|
||||
let start = running ? -2 : 12
|
||||
var snapshot = WidgetSnapshot(
|
||||
trip: Trip(
|
||||
title: "Autumn in Kyoto", start: iso(start), end: iso(start + 8), path: "/trips/1",
|
||||
place: "Kyoto, Japan", cover: nil, dayCount: 9, placeCount: 34, memberCount: 3
|
||||
),
|
||||
today: running
|
||||
? Today(
|
||||
dayLabel: "Day 3 of 9",
|
||||
stops: [
|
||||
Stop(time: "08:30", name: "Fushimi Inari Taisha", minutes: 510, color: "#D6273B", symbol: "building.columns.fill"),
|
||||
Stop(time: "11:00", name: "Nishiki Market", minutes: 660, color: "#E8A13A", symbol: "basket.fill"),
|
||||
Stop(time: "13:30", name: "Kiyomizu-dera", minutes: 810, color: "#2FA37A", symbol: "leaf.fill"),
|
||||
Stop(time: "16:00", name: "Gion walk", minutes: 960, color: "#4A7DDB", symbol: "figure.walk"),
|
||||
Stop(time: "19:30", name: "Dinner at Pontocho", minutes: 1170, color: "#8E6CD9", symbol: "fork.knife"),
|
||||
],
|
||||
index: 3, total: 9
|
||||
)
|
||||
: nil,
|
||||
weather: Weather(temp: 18, high: 21, low: 11, main: running ? "Clear" : "Clouds", label: "Mostly sunny", place: "Kyoto", rain: 10),
|
||||
bookings: [
|
||||
Booking(title: "LH 716", kind: "flight", date: iso(running ? 6 : 12), time: "13:05", detail: "FRA → HND", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Shinkansen Nozomi 21", kind: "train", date: iso(running ? 0 : 13), time: "09:12", detail: "Tokyo → Kyoto", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Hotel Kanra Kyoto", kind: "hotel", date: iso(running ? 0 : 13), time: "15:00", detail: "6 nights", confirmed: true, path: "/trips/1"),
|
||||
Booking(title: "Kaiseki at Kikunoi", kind: "restaurant", date: iso(running ? 1 : 15), time: "19:00", detail: "Higashiyama", confirmed: false, path: "/trips/1"),
|
||||
Booking(title: "Tea ceremony", kind: "event", date: iso(running ? 2 : 16), time: "10:30", detail: "Camellia Flower", confirmed: true, path: "/trips/1"),
|
||||
],
|
||||
todos: Todos(
|
||||
open: 4, total: 11,
|
||||
items: [
|
||||
Todo(name: "Order JR Pass", due: iso(running ? 0 : 3), priority: 3),
|
||||
Todo(name: "Book Ghibli Museum tickets", due: iso(running ? 1 : 5), priority: 2),
|
||||
Todo(name: "Get yen in cash", due: nil, priority: 1),
|
||||
Todo(name: "Download offline maps", due: nil, priority: 0),
|
||||
],
|
||||
path: "/trips/1/todos"
|
||||
),
|
||||
packing: Packing(packed: 23, total: 31, path: "/trips/1/packing"),
|
||||
labels: Labels(
|
||||
nextTrip: "Next trip", today: "Today", noTrip: "No trip coming up. Plan the next one in TREK.",
|
||||
inDays: "in {n} days", tomorrow: "Tomorrow", nothingPlanned: "Nothing planned today",
|
||||
days: "days", dayOf: "Day {n} of {m}", bookings: "Coming up", noBookings: "No bookings ahead",
|
||||
todos: "To-dos", noTodos: "All done", packing: "Packing", packed: "packed",
|
||||
confirmed: "Confirmed", pending: "Pending", todayShort: "Today", tomorrowShort: "Tomorrow",
|
||||
now: "Now", places: "places", travellers: "travellers", allDone: "Day done", openTrek: "Open TREK",
|
||||
missing: "still to pack", countries: "countries", world: "of the world", group: "Travel group",
|
||||
poll: "Poll", votes: "votes", noMessages: "No messages yet", departure: "Departure"
|
||||
)
|
||||
)
|
||||
snapshot.packing?.bags = [
|
||||
.init(name: "Suitcase", color: "#4A7DDB", grams: 14_200, limit: 23_000, packed: 15, total: 19),
|
||||
.init(name: "Daypack", color: "#2FA37A", grams: 4_100, limit: 8_000, packed: 8, total: 12),
|
||||
]
|
||||
snapshot.packing?.missing = [
|
||||
.init(name: "Rain jacket", category: "Clothes", color: "#4A7DDB"),
|
||||
.init(name: "Power bank", category: "Tech", color: "#2FA37A"),
|
||||
.init(name: "Passport", category: "Documents", color: "#2FA37A"),
|
||||
.init(name: "Sandals", category: "Shoes", color: "#4A7DDB"),
|
||||
.init(name: "Travel adapter", category: "Tech", color: "#2FA37A"),
|
||||
.init(name: "Sunscreen", category: "Toiletries", color: nil),
|
||||
.init(name: "Earplugs", category: nil, color: nil),
|
||||
.init(name: "Book", category: nil, color: nil),
|
||||
]
|
||||
snapshot.atlas = Atlas(
|
||||
count: 23,
|
||||
codes: ["DE", "AT", "CH", "FR", "IT", "ES", "PT", "NL", "BE", "DK", "NO", "SE", "GB", "IE", "GR", "HR",
|
||||
"JP", "TH", "VN", "US", "CA", "MA", "AU"],
|
||||
recent: [
|
||||
.init(code: "JP", name: "Japan"), .init(code: "NO", name: "Norway"),
|
||||
.init(code: "PT", name: "Portugal"), .init(code: "MA", name: "Morocco"),
|
||||
],
|
||||
continents: [
|
||||
.init(name: "Europe", count: 16), .init(name: "Asia", count: 3), .init(name: "North America", count: 2),
|
||||
.init(name: "Africa", count: 1), .init(name: "Oceania", count: 1),
|
||||
],
|
||||
trips: 41, days: 318
|
||||
)
|
||||
snapshot.collab = Collab(
|
||||
members: [
|
||||
.init(name: "Maurice", initials: "MA"), .init(name: "Julien", initials: "JU"),
|
||||
.init(name: "Elmo", initials: "EL"), .init(name: "Sara", initials: "SA"),
|
||||
],
|
||||
messages: [
|
||||
.init(author: "Julien", initials: "JU", text: "Got the JR Pass vouchers, all four of us", at: nil, mine: false),
|
||||
.init(author: "Sara", initials: "SA", text: "Can we push dinner to 20:00? Train is late", at: nil, mine: false),
|
||||
.init(author: "Maurice", initials: "MA", text: "Sure, I moved the table at Pontocho", at: nil, mine: true),
|
||||
],
|
||||
poll: .init(
|
||||
question: "Day trip on Saturday?",
|
||||
options: [.init(label: "Nara", votes: 3), .init(label: "Uji", votes: 1), .init(label: "Kurama", votes: 0)],
|
||||
voters: 4
|
||||
),
|
||||
path: "/trips/1?tab=collab"
|
||||
)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// An empty state: no trip at all.
|
||||
static var sampleEmpty: WidgetSnapshot {
|
||||
var snapshot = sample(running: false)
|
||||
snapshot.trip = nil
|
||||
snapshot.today = nil
|
||||
snapshot.weather = nil
|
||||
snapshot.bookings = []
|
||||
snapshot.todos = Todos(open: 0, total: 0, items: [], path: "/")
|
||||
snapshot.packing = nil
|
||||
snapshot.atlas = Atlas(count: 0, codes: [], recent: [], continents: [])
|
||||
snapshot.collab = nil
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// The widgets' look, from the app's phone tokens (client/src/mobile/mobile.css):
|
||||
/// warm paper by day, near black by night, one ink colour, the status colours,
|
||||
/// and the topographic lines and route arcs that stand for travel throughout.
|
||||
enum Trek {
|
||||
static func ink(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0xF5F5F7) : Color(hex: 0x101013) }
|
||||
static func muted(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x9C9CA3) : Color(hex: 0x68686F) }
|
||||
static func faint(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color(hex: 0x6E6E76) : Color(hex: 0x9A9AA1) }
|
||||
static func card(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.07) : Color.white.opacity(0.62) }
|
||||
static func cardBorder(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.09) : Color.white.opacity(0.9) }
|
||||
static func track(_ scheme: ColorScheme) -> Color { scheme == .dark ? Color.white.opacity(0.12) : Color(hex: 0x101013).opacity(0.09) }
|
||||
|
||||
static let confirmed = Color(hex: 0x2FA37A)
|
||||
static let pending = Color(hex: 0xE8A13A)
|
||||
static let info = Color(hex: 0x4A7DDB)
|
||||
static let danger = Color(hex: 0xD6273B)
|
||||
|
||||
/// A stable number for a text (FNV-1a), unlike hashValue, which changes per launch.
|
||||
static func seed(_ text: String) -> UInt64 {
|
||||
var hash: UInt64 = 1469598103934665603
|
||||
for byte in text.utf8 { hash = (hash ^ UInt64(byte)) &* 1099511628211 }
|
||||
return hash
|
||||
}
|
||||
|
||||
/// Two colours of the trip's own, from its title: the same trip always gets
|
||||
/// the same sky, and two trips side by side rarely the same one.
|
||||
static func tripHues(_ title: String) -> (Color, Color) {
|
||||
let base = Double(seed(title) % 360) / 360
|
||||
let first = Color(hue: base, saturation: 0.62, brightness: 0.78)
|
||||
let second = Color(hue: (base + 0.11).truncatingRemainder(dividingBy: 1), saturation: 0.7, brightness: 0.42)
|
||||
return (first, second)
|
||||
}
|
||||
|
||||
static func number(_ size: CGFloat, _ weight: Font.Weight = .bold) -> Font {
|
||||
.system(size: size, weight: weight, design: .rounded).monospacedDigit()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct Eyebrow: ViewModifier {
|
||||
let scheme: ColorScheme
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func trekEyebrow(_ scheme: ColorScheme) -> some View { modifier(Eyebrow(scheme: scheme)) }
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
|
||||
/// "#rrggbb" or "rrggbb"; nil for anything else.
|
||||
init?(css: String?) {
|
||||
guard let css else { return nil }
|
||||
let hex = css.hasPrefix("#") ? String(css.dropFirst()) : css
|
||||
guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil }
|
||||
self.init(hex: value)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Backgrounds
|
||||
|
||||
/// The paper the widgets sit on: the app's screen gradient, with a faint
|
||||
/// contour map over it so even an empty widget reads as TREK.
|
||||
struct TrekPaper: View {
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
var contours = true
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if scheme == .dark {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0x1B1B21), Color(hex: 0x0C0C0F), Color(hex: 0x070709)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
} else {
|
||||
RadialGradient(
|
||||
colors: [Color(hex: 0xFDFCF9), Color(hex: 0xF1EFEA), Color(hex: 0xE7E5DF)],
|
||||
center: UnitPoint(x: 0.5, y: -0.1), startRadius: 0, endRadius: 360
|
||||
)
|
||||
}
|
||||
if contours {
|
||||
ContourLines(seed: 7, rings: 9)
|
||||
.stroke(Trek.ink(scheme).opacity(scheme == .dark ? 0.05 : 0.045), lineWidth: 0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trip's own backdrop: its cover photo when the app sent one, else its two
|
||||
/// colours as a sky with contour lines, both darkened towards the text.
|
||||
struct TripBackdrop: View {
|
||||
let trip: WidgetSnapshot.Trip?
|
||||
var shade: Double = 0.62
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if let image = coverImage {
|
||||
image.resizable().scaledToFill()
|
||||
} else {
|
||||
let (first, second) = Trek.tripHues(trip?.title ?? "TREK")
|
||||
LinearGradient(colors: [first, second], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
RadialGradient(colors: [Color.white.opacity(0.28), .clear], center: UnitPoint(x: 0.85, y: 0.05), startRadius: 0, endRadius: 220)
|
||||
ContourLines(seed: Trek.seed(trip?.title ?? "") % 997, rings: 11)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 0.9)
|
||||
}
|
||||
LinearGradient(
|
||||
colors: [Color.black.opacity(shade * 0.35), Color.black.opacity(0.05), Color.black.opacity(shade)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var coverImage: Image? {
|
||||
guard let base64 = trip?.cover, let data = Data(base64Encoded: base64) else { return nil }
|
||||
#if canImport(UIKit)
|
||||
guard let image = UIImage(data: data) else { return nil }
|
||||
return Image(uiImage: image)
|
||||
#elseif canImport(AppKit)
|
||||
guard let image = NSImage(data: data) else { return nil }
|
||||
return Image(nsImage: image)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Wobbly closed rings around an off-centre summit: a contour map in a few
|
||||
/// lines, the same for the same seed.
|
||||
struct ContourLines: Shape {
|
||||
var seed: UInt64
|
||||
var rings: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let phaseA = CGFloat(seed % 13) * 0.7
|
||||
let phaseB = CGFloat(seed % 7) * 1.3
|
||||
let offset: CGFloat = 0.62 + CGFloat(seed % 5) * 0.04
|
||||
let centerX: CGFloat = rect.minX + rect.width * offset
|
||||
let centerY: CGFloat = rect.minY + rect.height * 0.32
|
||||
let maxRadius: CGFloat = max(rect.width, rect.height) * 1.05
|
||||
let count = max(rings, 1)
|
||||
let steps = 72
|
||||
for ring in 1...count {
|
||||
let base: CGFloat = maxRadius * CGFloat(ring) / CGFloat(count)
|
||||
let twist: CGFloat = CGFloat(ring) * 0.35
|
||||
for step in 0...steps {
|
||||
let angle: CGFloat = CGFloat(step) / CGFloat(steps) * 2 * .pi
|
||||
let first: CGFloat = 0.09 * sin(angle * 3 + phaseA + twist)
|
||||
let second: CGFloat = 0.05 * sin(angle * 5 + phaseB)
|
||||
let radius: CGFloat = base * (1 + first + second)
|
||||
let point = CGPoint(x: centerX + cos(angle) * radius * 1.15, y: centerY + sin(angle) * radius * 0.82)
|
||||
if step == 0 { path.move(to: point) } else { path.addLine(to: point) }
|
||||
}
|
||||
path.closeSubpath()
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// A dotted flight path from home to the trip, with the plane on it as far as
|
||||
/// the countdown has come.
|
||||
struct RouteArc: View {
|
||||
/// 0 at home, 1 arrived.
|
||||
let progress: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let start = CGPoint(x: 6, y: geo.size.height - 6)
|
||||
let end = CGPoint(x: geo.size.width - 6, y: geo.size.height - 6)
|
||||
let control = CGPoint(x: geo.size.width / 2, y: geo.size.height * -0.55)
|
||||
ZStack {
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.stroke(color.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round, dash: [1.5, 4.5]))
|
||||
Path { path in
|
||||
path.move(to: start)
|
||||
path.addQuadCurve(to: end, control: control)
|
||||
}
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: 1.8, lineCap: .round))
|
||||
Circle().fill(color).frame(width: 6, height: 6).position(start)
|
||||
Circle().stroke(color, lineWidth: 1.6).frame(width: 9, height: 9).position(end)
|
||||
let point = quadPoint(start, control, end, progress)
|
||||
let tangent = quadTangent(start, control, end, progress)
|
||||
Image(systemName: "airplane")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
.rotationEffect(.radians(Double(atan2(tangent.y, tangent.x))))
|
||||
.position(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func quadPoint(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let u: CGFloat = 1 - t
|
||||
let x: CGFloat = u * u * a.x + 2 * u * t * c.x + t * t * b.x
|
||||
let y: CGFloat = u * u * a.y + 2 * u * t * c.y + t * t * b.y
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
|
||||
private func quadTangent(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ progress: Double) -> CGPoint {
|
||||
let t = CGFloat(progress)
|
||||
let x: CGFloat = 2 * (1 - t) * (c.x - a.x) + 2 * t * (b.x - c.x)
|
||||
let y: CGFloat = 2 * (1 - t) * (c.y - a.y) + 2 * t * (b.y - c.y)
|
||||
return CGPoint(x: x, y: y)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Components
|
||||
|
||||
private struct StaticPreviewKey: EnvironmentKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Set by the preview renderer, which cannot draw a Link.
|
||||
var trekStaticPreview: Bool {
|
||||
get { self[StaticPreviewKey.self] }
|
||||
set { self[StaticPreviewKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that opens its own place in the app, or just the row in a preview.
|
||||
struct RowLink<Label: View>: View {
|
||||
let path: String
|
||||
@ViewBuilder var label: () -> Label
|
||||
@Environment(\.trekStaticPreview) private var staticPreview
|
||||
|
||||
var body: some View {
|
||||
if staticPreview {
|
||||
label()
|
||||
} else if let url = WidgetSnapshot.link(path) {
|
||||
Link(destination: url) { label() }
|
||||
} else {
|
||||
label()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring that fills with progress, a soft track under it.
|
||||
struct ProgressRing<Label: View>: View {
|
||||
let value: Double
|
||||
var lineWidth: CGFloat = 6
|
||||
var colors: [Color]
|
||||
var trackColor: Color
|
||||
@ViewBuilder var label: () -> Label
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle().stroke(trackColor, lineWidth: lineWidth)
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.001, min(1, value)))
|
||||
.stroke(
|
||||
AngularGradient(colors: colors + [colors.first ?? .white], center: .center, startAngle: .degrees(-90), endAngle: .degrees(270)),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
label()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip's days as a row of segments, those behind filled, today glowing.
|
||||
struct DaySegments: View {
|
||||
let index: Int
|
||||
let total: Int
|
||||
let color: Color
|
||||
let track: Color
|
||||
|
||||
var body: some View {
|
||||
let count = min(max(total, 1), 21)
|
||||
let current = total > 21 ? Int((Double(index) / Double(total) * 21).rounded(.up)) : index
|
||||
HStack(spacing: count > 12 ? 2 : 3) {
|
||||
ForEach(1...count, id: \.self) { day in
|
||||
Capsule()
|
||||
.fill(day < current ? color : day == current ? color : track)
|
||||
.opacity(day < current ? 0.55 : 1)
|
||||
.shadow(color: day == current ? color.opacity(0.7) : .clear, radius: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's weather in a pill: the multicolour symbol and the temperature.
|
||||
struct WeatherChip: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
var onPhoto = false
|
||||
var compact = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: compact ? 12 : 14, weight: .semibold))
|
||||
Text("\(weather.temp)°")
|
||||
.font(Trek.number(compact ? 12 : 14, .semibold))
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
}
|
||||
.padding(.horizontal, compact ? 7 : 9)
|
||||
.padding(.vertical, compact ? 3 : 4)
|
||||
.background(
|
||||
Capsule().fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme))
|
||||
)
|
||||
.overlay(Capsule().stroke(onPhoto ? Color.white.opacity(0.22) : Trek.cardBorder(scheme), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
extension Trek {
|
||||
static func weatherSymbol(_ main: String) -> String {
|
||||
switch main.lowercased() {
|
||||
case "clear": return "sun.max.fill"
|
||||
case "clouds": return "cloud.sun.fill"
|
||||
case "rain", "drizzle": return "cloud.rain.fill"
|
||||
case "thunderstorm": return "cloud.bolt.rain.fill"
|
||||
case "snow": return "cloud.snow.fill"
|
||||
case "mist", "fog", "haze": return "cloud.fog.fill"
|
||||
default: return "cloud.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol and colour of a booking's kind.
|
||||
static func booking(_ kind: String) -> (symbol: String, color: Color) {
|
||||
switch kind {
|
||||
case "flight": return ("airplane", Color(hex: 0x4A7DDB))
|
||||
case "train": return ("tram.fill", Color(hex: 0x2FA37A))
|
||||
case "bus": return ("bus.fill", Color(hex: 0x2BA6A6))
|
||||
case "car": return ("car.fill", Color(hex: 0xD6673B))
|
||||
case "cruise", "ferry": return ("ferry.fill", Color(hex: 0x2E8BC0))
|
||||
case "hotel": return ("bed.double.fill", Color(hex: 0x8E6CD9))
|
||||
case "restaurant": return ("fork.knife", Color(hex: 0xE8A13A))
|
||||
case "event": return ("ticket.fill", Color(hex: 0xD6408B))
|
||||
case "tour": return ("figure.hiking", Color(hex: 0x4E9F3D))
|
||||
default: return ("bookmark.fill", Color(hex: 0x8A8A93))
|
||||
}
|
||||
}
|
||||
|
||||
static func priority(_ value: Int) -> Color? {
|
||||
switch value {
|
||||
case 3: return danger
|
||||
case 2: return pending
|
||||
case 1: return info
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A booking's kind as a tinted rounded square.
|
||||
struct KindBadge: View {
|
||||
let kind: String
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
let look = Trek.booking(kind)
|
||||
RoundedRectangle(cornerRadius: size * 0.32, style: .continuous)
|
||||
.fill(LinearGradient(colors: [look.color, look.color.opacity(0.78)], startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Image(systemName: look.symbol)
|
||||
.font(.system(size: size * 0.46, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
)
|
||||
.shadow(color: look.color.opacity(0.35), radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// "Today", "Tomorrow" or the short weekday and date of a yyyy-MM-dd.
|
||||
enum RelativeDay {
|
||||
static func label(_ iso: String?, now: Date, labels: WidgetSnapshot.Labels) -> String? {
|
||||
guard let iso, let date = WidgetSnapshot.day(iso), let days = WidgetSnapshot.daysBetween(now, date) else { return nil }
|
||||
if days == 0 { return labels.todayShort ?? labels.today }
|
||||
if days == 1 { return labels.tomorrowShort ?? labels.tomorrow }
|
||||
let formatter = DateFormatter()
|
||||
formatter.setLocalizedDateFormatFromTemplate(days < 7 ? "EEE" : "EEEdMMM")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
static func range(_ start: String, _ end: String) -> String? {
|
||||
guard let from = WidgetSnapshot.day(start), let to = WidgetSnapshot.day(end) else { return nil }
|
||||
let formatter = DateIntervalFormatter()
|
||||
formatter.dateTemplate = "dMMM"
|
||||
return formatter.string(from: from, to: to)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The trip widget: a countdown on the trip's own cover before it starts, the
|
||||
/// day and what comes next while it runs, an invitation when there is none.
|
||||
struct TripContent: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
private var labels: WidgetSnapshot.Labels? { snapshot?.labels }
|
||||
|
||||
var body: some View {
|
||||
if family.isAccessory {
|
||||
TripAccessory(snapshot: snapshot, family: family, now: now)
|
||||
} else if let snapshot, let trip = snapshot.trip {
|
||||
if let today = snapshot.today {
|
||||
running(snapshot, trip, today)
|
||||
} else {
|
||||
upcoming(snapshot, trip)
|
||||
}
|
||||
} else {
|
||||
EmptyTrip(text: snapshot?.labels.noTrip ?? "", family: family)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Before the trip
|
||||
|
||||
@ViewBuilder
|
||||
private func upcoming(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip) -> some View {
|
||||
let days = max(snapshot.daysUntilStart(from: now) ?? 0, 0)
|
||||
// The ring and the plane cover the last 60 days before departure.
|
||||
let progress = 1 - min(Double(days), 60) / 60
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
PlaneMark()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 46)
|
||||
Text(trip.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.padding(.top, 1)
|
||||
if let range = RelativeDay.range(trip.start, trip.end) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.75)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Text(trip.title)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 26)
|
||||
.padding(.bottom, 8)
|
||||
HStack(spacing: 6) {
|
||||
if let range = RelativeDay.range(trip.start, trip.end) { PhotoPill(symbol: "calendar", text: range) }
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
}
|
||||
ProgressRing(value: progress, lineWidth: 7, colors: [.white.opacity(0.75), .white], trackColor: .white.opacity(0.2)) {
|
||||
VStack(spacing: -2) {
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 26 : 32, .heavy)).foregroundStyle(.white)
|
||||
Text(snapshot.labels.days ?? "").font(.system(size: 10, weight: .bold)).foregroundStyle(.white.opacity(0.75)).textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
.frame(width: 96, height: 96)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(snapshot.labels.nextTrip).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(trip.title)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
if let place = trip.place {
|
||||
Label(place, systemImage: "location.fill")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
countdown(days, snapshot.labels, size: 52)
|
||||
}
|
||||
RouteArc(progress: progress, color: .white)
|
||||
.frame(height: 34)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
TripFacts(snapshot: snapshot, onPhoto: true)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func countdown(_ days: Int, _ labels: WidgetSnapshot.Labels, size: CGFloat) -> some View {
|
||||
Group {
|
||||
if days == 0 {
|
||||
Text(labels.todayShort ?? labels.today)
|
||||
.font(.system(size: size * 0.62, weight: .heavy, design: .rounded))
|
||||
} else if days == 1 {
|
||||
Text(labels.tomorrow)
|
||||
.font(.system(size: size * 0.52, weight: .heavy, design: .rounded))
|
||||
.minimumScaleFactor(0.6)
|
||||
} else {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(days)").font(Trek.number(size, .heavy))
|
||||
Text(labels.days ?? "").font(.system(size: size * 0.28, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.shadow(color: .black.opacity(0.25), radius: 6, y: 2)
|
||||
}
|
||||
|
||||
// MARK: While travelling
|
||||
|
||||
@ViewBuilder
|
||||
private func running(_ snapshot: WidgetSnapshot, _ trip: WidgetSnapshot.Trip, _ today: WidgetSnapshot.Today) -> some View {
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today.stops.first { ($0.minutes ?? -1) >= nowMinutes } ?? today.stops.last
|
||||
let index = today.index ?? 1
|
||||
let total = today.total ?? snapshot.tripLength ?? 1
|
||||
switch family {
|
||||
case .small:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
Text(today.dayLabel).trekEyebrowOnPhoto()
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { WeatherChip(weather: weather, onPhoto: true, compact: true) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 7)
|
||||
Spacer(minLength: 0)
|
||||
if let next {
|
||||
Text(next.time ?? snapshot.labels.today)
|
||||
.font(Trek.number(28, .heavy))
|
||||
.foregroundStyle(.white)
|
||||
Text(next.name)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text(snapshot.labels.nothingPlanned).font(.system(size: 14, weight: .bold)).foregroundStyle(.white).lineLimit(3)
|
||||
}
|
||||
Text(trip.title).font(.system(size: 11, weight: .medium)).foregroundStyle(.white.opacity(0.7)).lineLimit(1).padding(.top, 2)
|
||||
}
|
||||
case .medium:
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel)
|
||||
.font(.system(size: 21, weight: .heavy))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.padding(.top, 3)
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 8)
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather {
|
||||
HStack(spacing: 6) {
|
||||
WeatherChip(weather: weather, onPhoto: true)
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(11.5, .semibold)).foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 3, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.frame(width: 176)
|
||||
}
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(trip.title).trekEyebrowOnPhoto()
|
||||
Text(today.dayLabel).font(.system(size: 24, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let weather = snapshot.weather { BigWeather(weather: weather) }
|
||||
}
|
||||
DaySegments(index: index, total: total, color: .white, track: .white.opacity(0.25)).padding(.top, 10)
|
||||
GlassPanel {
|
||||
StopList(stops: today.stops, nowMinutes: nowMinutes, limit: 6, onPhoto: true, nothing: snapshot.labels.nothingPlanned, nowLabel: snapshot.labels.now)
|
||||
}
|
||||
.padding(.top, 14)
|
||||
Spacer(minLength: 8)
|
||||
if let booking = snapshot.bookings?.first {
|
||||
NextBookingStrip(booking: booking, labels: snapshot.labels, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pieces
|
||||
|
||||
private extension Text {
|
||||
func trekEyebrowOnPhoto() -> some View {
|
||||
self.font(.system(size: 10.5, weight: .bold))
|
||||
.tracking(0.7)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.78))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane in a frosted circle that marks a trip widget.
|
||||
struct PlaneMark: View {
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.22))
|
||||
.overlay(Circle().stroke(.white.opacity(0.35), lineWidth: 0.6))
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(Image(systemName: "airplane").font(.system(size: 12, weight: .bold)).foregroundStyle(.white).rotationEffect(.degrees(-35)))
|
||||
}
|
||||
}
|
||||
|
||||
struct PhotoPill: View {
|
||||
let symbol: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold))
|
||||
Text(text).font(.system(size: 11, weight: .semibold)).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Capsule().fill(Color.black.opacity(0.28)))
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.22), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
/// Frosted glass over the photo, for lists that need a calm ground.
|
||||
struct GlassPanel<Content: View>: View {
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.32))
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(Color.white.opacity(0.16), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// Days, places and travellers as three small columns.
|
||||
struct TripFacts: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
var onPhoto = false
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
let labels = snapshot.labels
|
||||
HStack(spacing: 0) {
|
||||
if let length = snapshot.tripLength {
|
||||
fact("\(length)", labels.days ?? "", "calendar")
|
||||
}
|
||||
if let places = snapshot.trip?.placeCount, places > 0 {
|
||||
divider
|
||||
fact("\(places)", labels.places ?? "", "mappin")
|
||||
}
|
||||
if let members = snapshot.trip?.memberCount, members > 0 {
|
||||
divider
|
||||
fact("\(members)", labels.travellers ?? "", "person.2.fill")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.28) : Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.18) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
|
||||
private var divider: some View {
|
||||
Rectangle().fill(onPhoto ? Color.white.opacity(0.2) : Trek.track(scheme)).frame(width: 0.7, height: 26)
|
||||
}
|
||||
|
||||
private func fact(_ value: String, _ label: String, _ symbol: String) -> some View {
|
||||
VStack(spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .bold)).opacity(0.75)
|
||||
Text(value).font(Trek.number(17, .heavy))
|
||||
}
|
||||
Text(label).font(.system(size: 9.5, weight: .bold)).textCase(.uppercase).tracking(0.4).opacity(0.7).lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// The first booking ahead, as a strip under the trip.
|
||||
struct NextBookingStrip: View {
|
||||
let booking: WidgetSnapshot.Booking
|
||||
let labels: WidgetSnapshot.Labels
|
||||
let now: Date
|
||||
var onPhoto = true
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
KindBadge(kind: booking.kind, size: 28)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(booking.title).font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
if let detail = booking.detail {
|
||||
Text(detail).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
if let day = RelativeDay.label(booking.date, now: now, labels: labels) {
|
||||
Text(day).font(.system(size: 11, weight: .bold)).opacity(0.8)
|
||||
}
|
||||
if let time = booking.time { Text(time).font(Trek.number(14, .heavy)) }
|
||||
}
|
||||
}
|
||||
.foregroundStyle(onPhoto ? .white : Trek.ink(scheme))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(RoundedRectangle(cornerRadius: 14, style: .continuous).fill(onPhoto ? Color.black.opacity(0.3) : Trek.card(scheme)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(onPhoto ? Color.white.opacity(0.16) : Trek.cardBorder(scheme), lineWidth: 0.7))
|
||||
}
|
||||
}
|
||||
|
||||
/// The weather as the large widget shows it: symbol, temperature, high and low.
|
||||
struct BigWeather: View {
|
||||
let weather: WidgetSnapshot.Weather
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: Trek.weatherSymbol(weather.main))
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.font(.system(size: 26))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(weather.temp)°").font(Trek.number(22, .heavy))
|
||||
if let high = weather.high, let low = weather.low {
|
||||
Text("\(high)° / \(low)°").font(Trek.number(10.5, .semibold)).opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's stops as a timeline: a dot in each stop's colour on a thread, the
|
||||
/// ones behind faded, a now marker between the past and the next.
|
||||
struct StopList: View {
|
||||
let stops: [WidgetSnapshot.Stop]
|
||||
let nowMinutes: Int
|
||||
let limit: Int
|
||||
var onPhoto = false
|
||||
let nothing: String
|
||||
var nowLabel: String?
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
if stops.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sparkles").font(.system(size: 15, weight: .semibold))
|
||||
Text(nothing).font(.system(size: 13, weight: .semibold)).lineLimit(2)
|
||||
}
|
||||
.foregroundStyle(ink.opacity(0.85))
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
} else {
|
||||
let window = visible
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(window.enumerated()), id: \.offset) { offset, stop in
|
||||
let past = (stop.minutes ?? Int.max) < nowMinutes
|
||||
let isNext = !past && (offset == 0 || (window[offset - 1].minutes ?? Int.max) < nowMinutes)
|
||||
if isNext && offset > 0, let nowLabel { nowLine(nowLabel) }
|
||||
row(stop, past: past, isNext: isNext, last: offset == window.count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stops around now: the last one behind, then the ones ahead.
|
||||
private var visible: [WidgetSnapshot.Stop] {
|
||||
let nextIndex = stops.firstIndex { ($0.minutes ?? Int.max) >= nowMinutes } ?? max(stops.count - limit, 0)
|
||||
let first = max(0, min(nextIndex - 1, stops.count - limit))
|
||||
return Array(stops[first..<min(stops.count, first + limit)])
|
||||
}
|
||||
|
||||
private var ink: Color { onPhoto ? .white : Trek.ink(scheme) }
|
||||
|
||||
private func row(_ stop: WidgetSnapshot.Stop, past: Bool, isNext: Bool, last: Bool) -> some View {
|
||||
let color = Color(css: stop.color) ?? (onPhoto ? .white : Trek.info)
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
Text(stop.time ?? "–")
|
||||
.font(Trek.number(11.5, isNext ? .heavy : .semibold))
|
||||
.frame(width: 38, alignment: .leading)
|
||||
.padding(.top, 1)
|
||||
VStack(spacing: 0) {
|
||||
ZStack {
|
||||
Circle().fill(color).frame(width: isNext ? 11 : 8, height: isNext ? 11 : 8)
|
||||
if isNext { Circle().stroke(color.opacity(0.4), lineWidth: 3).frame(width: 15, height: 15) }
|
||||
}
|
||||
.frame(width: 15, height: 15)
|
||||
if !last {
|
||||
Rectangle().fill(ink.opacity(0.22)).frame(width: 1.2).frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 15)
|
||||
HStack(spacing: 5) {
|
||||
if let symbol = stop.symbol {
|
||||
Image(systemName: symbol).font(.system(size: 10, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
Text(stop.name)
|
||||
.font(.system(size: 12.5, weight: isNext ? .bold : .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.top, 0.5)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.foregroundStyle(ink)
|
||||
.opacity(past ? 0.5 : 1)
|
||||
.frame(height: 24, alignment: .top)
|
||||
}
|
||||
|
||||
private func nowLine(_ label: String) -> some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(label.uppercased()).font(.system(size: 8.5, weight: .heavy)).tracking(0.5).foregroundStyle(Trek.danger)
|
||||
.frame(width: 38, alignment: .leading)
|
||||
Circle().fill(Trek.danger).frame(width: 5, height: 5).frame(width: 15)
|
||||
Rectangle().fill(Trek.danger).frame(height: 1.2)
|
||||
}
|
||||
.frame(height: 9)
|
||||
.padding(.bottom, 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// No trip: the paper, the contour lines, and an invitation.
|
||||
struct EmptyTrip: View {
|
||||
let text: String
|
||||
let family: TrekFamily
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "map.fill").font(.system(size: 13, weight: .bold))
|
||||
Text("TREK").font(.system(size: 15, weight: .heavy)).tracking(1.2)
|
||||
}
|
||||
.foregroundStyle(Trek.ink(scheme))
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "globe.europe.africa.fill")
|
||||
.font(.system(size: family == .small ? 30 : 38, weight: .light))
|
||||
.foregroundStyle(Trek.muted(scheme).opacity(0.6))
|
||||
Text(text)
|
||||
.font(.system(size: family == .small ? 12.5 : 14, weight: .semibold))
|
||||
.foregroundStyle(Trek.muted(scheme))
|
||||
.lineLimit(3)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
/// The trip on the lock screen: a ring with the days left or the day of the
|
||||
/// trip, a line with the next stop, a word in the status row.
|
||||
struct TripAccessory: View {
|
||||
let snapshot: WidgetSnapshot?
|
||||
let family: TrekFamily
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let trip = snapshot?.trip
|
||||
let today = snapshot?.today
|
||||
let days = max(snapshot?.daysUntilStart(from: now) ?? 0, 0)
|
||||
let nowMinutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let next = today?.stops.first { ($0.minutes ?? -1) >= nowMinutes }
|
||||
switch family {
|
||||
case .circular:
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 5)
|
||||
if let today, let index = today.index, let total = today.total ?? snapshot?.tripLength {
|
||||
Circle().trim(from: 0, to: Double(index) / Double(max(total, 1)))
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Text("\(index)").font(Trek.number(20, .heavy))
|
||||
Text("/\(total)").font(Trek.number(9, .bold)).opacity(0.75)
|
||||
}
|
||||
} else if trip != nil {
|
||||
Circle().trim(from: 0, to: 1 - min(Double(days), 60) / 60)
|
||||
.stroke(Color.white, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
VStack(spacing: -3) {
|
||||
Image(systemName: "airplane").font(.system(size: 9, weight: .bold))
|
||||
Text("\(days)").font(Trek.number(days > 99 ? 15 : 19, .heavy))
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "map.fill").font(.system(size: 20))
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
case .rectangular:
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: today == nil ? "airplane" : "mappin.circle.fill").font(.system(size: 11, weight: .bold))
|
||||
Text(trip?.title ?? "TREK").font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
if let today {
|
||||
Text(next.map { "\($0.time ?? "") \($0.name)" } ?? snapshot?.labels.allDone ?? today.dayLabel)
|
||||
.font(.system(size: 13, weight: .semibold)).lineLimit(1)
|
||||
Text(today.dayLabel).font(.system(size: 11, weight: .medium)).opacity(0.75).lineLimit(1)
|
||||
} else if let snapshot, trip != nil {
|
||||
Text(days == 1 ? snapshot.labels.tomorrow : snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))
|
||||
.font(.system(size: 15, weight: .heavy)).lineLimit(1)
|
||||
if let range = trip.flatMap({ RelativeDay.range($0.start, $0.end) }) {
|
||||
Text(range).font(.system(size: 11, weight: .medium)).opacity(0.75)
|
||||
}
|
||||
} else {
|
||||
Text(snapshot?.labels.noTrip ?? "").font(.system(size: 12, weight: .medium)).lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
default:
|
||||
if let trip, let snapshot {
|
||||
if today != nil, let next {
|
||||
Text("\(Image(systemName: "mappin.circle.fill")) \(next.time ?? "") \(next.name)")
|
||||
} else {
|
||||
Text("\(Image(systemName: "airplane")) \(trip.title), \(snapshot.labels.inDays.replacingOccurrences(of: "{n}", with: "\(days)"))")
|
||||
}
|
||||
} else {
|
||||
Text("\(Image(systemName: "map.fill")) TREK")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,53 +3,110 @@ import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// A travel day on the lock screen and in the Dynamic Island: the next stop and
|
||||
/// its time, and how much of the day is behind.
|
||||
/// its time, and how much of the day is behind, in the widgets' look.
|
||||
struct TripDayLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TripDayAttributes.self) { context in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(context.attributes.tripTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(context.state.dayLabel).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
Text(context.state.nextName).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
if let time = context.state.nextTime {
|
||||
Text(time).font(.headline.monospacedDigit())
|
||||
}
|
||||
}
|
||||
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
|
||||
}
|
||||
.padding()
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
TripDayBanner(attributes: context.attributes, state: context.state)
|
||||
.activityBackgroundTint(Color(hex: 0x0C0C0F).opacity(0.92))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
let state = context.state
|
||||
return DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label(context.state.dayLabel, systemImage: "map").font(.caption)
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Trek.confirmed)
|
||||
Text(state.dayLabel).font(.system(size: 13, weight: .bold)).lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let time = context.state.nextTime {
|
||||
Text(time).font(.caption.monospacedDigit())
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(20, .heavy)).padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(context.state.nextName).font(.headline).lineLimit(1)
|
||||
ProgressView(value: Double(context.state.done), total: Double(max(context.state.total, 1)))
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(state.nextName).font(.system(size: 17, weight: .heavy)).lineLimit(1)
|
||||
StopProgress(done: state.done, total: state.total)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
Image(systemName: "mappin.circle.fill").foregroundStyle(Trek.confirmed)
|
||||
} compactTrailing: {
|
||||
Text(context.state.nextTime ?? "\(context.state.done)/\(context.state.total)")
|
||||
.font(.caption2.monospacedDigit())
|
||||
Text(state.nextTime ?? "\(state.done)/\(state.total)")
|
||||
.font(Trek.number(13, .heavy))
|
||||
} minimal: {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.25), lineWidth: 2.5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? Double(state.done) / Double(state.total) : 0)
|
||||
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.padding(2)
|
||||
}
|
||||
.widgetURL(WidgetSnapshot.link(context.attributes.path))
|
||||
.keylineTint(Trek.confirmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen banner: trip and day on top, the next stop large, the
|
||||
/// day's stops as segments.
|
||||
private struct TripDayBanner: View {
|
||||
let attributes: TripDayAttributes
|
||||
let state: TripDayAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle()
|
||||
.trim(from: 0, to: state.total > 0 ? max(0.02, Double(state.done) / Double(state.total)) : 0)
|
||||
.stroke(
|
||||
AngularGradient(colors: [Trek.confirmed.opacity(0.7), Trek.confirmed], center: .center),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(attributes.tripTitle)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.tracking(0.5)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(state.dayLabel).font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(state.nextName).font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
if let time = state.nextTime {
|
||||
Text(time).font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
StopProgress(done: state.done, total: state.total).padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's timed stops as segments, those behind filled.
|
||||
private struct StopProgress: View {
|
||||
let done: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
DaySegments(index: done + 1, total: max(total, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
/// The trip a widget shows, picked in "Edit Widget": the dashboard's trip by
|
||||
/// default ("Automatic"), or any trip the app listed in widget.trips.
|
||||
struct TripChoice: AppEntity {
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "widget.intent.trip"
|
||||
static var defaultQuery = TripChoiceQuery()
|
||||
|
||||
/// The id of the automatic choice, which follows the dashboard's trip.
|
||||
static let automaticID = "auto"
|
||||
|
||||
var id: String
|
||||
var title: String
|
||||
var dates: String?
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
if id == TripChoice.automaticID {
|
||||
return DisplayRepresentation(title: "widget.intent.auto", subtitle: "widget.intent.autoHint")
|
||||
}
|
||||
return DisplayRepresentation(title: "\(title)", subtitle: dates.map { "\($0)" })
|
||||
}
|
||||
|
||||
static var automatic: TripChoice { TripChoice(id: automaticID, title: "", dates: nil) }
|
||||
}
|
||||
|
||||
struct TripChoiceQuery: EntityQuery {
|
||||
func entities(for identifiers: [TripChoice.ID]) async throws -> [TripChoice] {
|
||||
all().filter { identifiers.contains($0.id) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [TripChoice] {
|
||||
all()
|
||||
}
|
||||
|
||||
func defaultResult() async -> TripChoice? {
|
||||
.automatic
|
||||
}
|
||||
|
||||
/// Automatic first, then the trips the app sent, soonest first.
|
||||
private func all() -> [TripChoice] {
|
||||
[.automatic] + WidgetSnapshot.tripIndex().map {
|
||||
TripChoice(id: String($0.id), title: $0.title, dates: RelativeDay.range($0.start, $0.end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectTripIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "widget.intent.title"
|
||||
static var description = IntentDescription("widget.intent.description")
|
||||
|
||||
@Parameter(title: "widget.intent.trip")
|
||||
var trip: TripChoice?
|
||||
}
|
||||
|
||||
/// Like TrekProvider, for the widgets that show one trip: the snapshot of the
|
||||
/// trip picked for the widget, or the dashboard's.
|
||||
struct TrekTripProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> TrekEntry {
|
||||
TrekEntry(date: Date(), snapshot: .sample(running: false))
|
||||
}
|
||||
|
||||
func snapshot(for configuration: SelectTripIntent, in context: Context) async -> TrekEntry {
|
||||
let stored = WidgetSnapshot.load(trip: configuration.trip?.id)
|
||||
let snapshot: WidgetSnapshot? = (stored?.trip != nil || !context.isPreview) ? stored : WidgetSnapshot.sample(running: false)
|
||||
return TrekEntry(date: Date(), snapshot: snapshot)
|
||||
}
|
||||
|
||||
func timeline(for configuration: SelectTripIntent, in context: Context) async -> Timeline<TrekEntry> {
|
||||
TrekProvider.timeline(WidgetSnapshot.load(trip: configuration.trip?.id))
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,159 @@ import Security
|
||||
/// TrekShell.setWidgetSnapshot into the keychain group the app and its
|
||||
/// extensions share). Texts arrive already translated, so the widget needs no
|
||||
/// localisation of its own and always speaks the language the app does.
|
||||
///
|
||||
/// Everything past `trip`, `today` and `labels` is optional: a binary that is
|
||||
/// newer than the server's UI shows what it gets, an older one ignores the rest.
|
||||
struct WidgetSnapshot: Codable {
|
||||
struct Trip: Codable {
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
var path: String
|
||||
/// The destination in a few words ("Tokyo, Japan").
|
||||
var place: String?
|
||||
/// The trip's cover, a small JPEG as base64, already cropped for a widget.
|
||||
var cover: String?
|
||||
var dayCount: Int?
|
||||
var placeCount: Int?
|
||||
var memberCount: Int?
|
||||
}
|
||||
|
||||
struct Stop: Codable, Hashable {
|
||||
var time: String?
|
||||
var name: String
|
||||
/// Minutes after midnight, for the now line.
|
||||
var minutes: Int?
|
||||
/// The category colour as #rrggbb.
|
||||
var color: String?
|
||||
/// An SF Symbol name for the stop's kind.
|
||||
var symbol: String?
|
||||
}
|
||||
|
||||
struct Today: Codable {
|
||||
var dayLabel: String
|
||||
var stops: [Stop]
|
||||
/// 1-based position of today in the trip.
|
||||
var index: Int?
|
||||
var total: Int?
|
||||
}
|
||||
|
||||
struct Weather: Codable {
|
||||
var temp: Int
|
||||
var high: Int?
|
||||
var low: Int?
|
||||
/// The provider's main condition ("Clear", "Clouds", "Rain", ...).
|
||||
var main: String
|
||||
var label: String
|
||||
var place: String?
|
||||
/// Chance of rain in percent.
|
||||
var rain: Int?
|
||||
}
|
||||
|
||||
struct Booking: Codable, Hashable {
|
||||
var title: String
|
||||
/// flight, train, bus, car, cruise, hotel, restaurant, event, tour, other
|
||||
var kind: String
|
||||
/// yyyy-MM-dd, for the relative day.
|
||||
var date: String?
|
||||
var time: String?
|
||||
/// "FRA → HND", the location or the hotel's nights.
|
||||
var detail: String?
|
||||
var confirmed: Bool
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Todo: Codable, Hashable {
|
||||
var name: String
|
||||
var due: String?
|
||||
/// 0 none, 1 low, 2 medium, 3 high.
|
||||
var priority: Int
|
||||
}
|
||||
|
||||
struct Todos: Codable {
|
||||
var open: Int
|
||||
var total: Int
|
||||
var items: [Todo]
|
||||
var path: String
|
||||
}
|
||||
|
||||
struct Packing: Codable {
|
||||
var packed: Int
|
||||
var total: Int
|
||||
var path: String
|
||||
var bags: [Bag]?
|
||||
var missing: [Item]?
|
||||
|
||||
struct Bag: Codable, Hashable {
|
||||
var name: String
|
||||
/// #rrggbb
|
||||
var color: String
|
||||
var grams: Int
|
||||
/// The bag's weight limit in grams, if it has one.
|
||||
var limit: Int?
|
||||
var packed: Int
|
||||
var total: Int
|
||||
}
|
||||
|
||||
struct Item: Codable, Hashable {
|
||||
var name: String
|
||||
var category: String?
|
||||
/// The colour of the bag it goes in.
|
||||
var color: String?
|
||||
}
|
||||
}
|
||||
|
||||
/// The countries of the Atlas, independent of any trip.
|
||||
struct Atlas: Codable {
|
||||
var count: Int
|
||||
/// ISO 3166-1 alpha-2 codes.
|
||||
var codes: [String]
|
||||
var recent: [Country]
|
||||
var continents: [Continent]
|
||||
var trips: Int?
|
||||
var days: Int?
|
||||
|
||||
struct Country: Codable, Hashable {
|
||||
var code: String
|
||||
var name: String
|
||||
}
|
||||
|
||||
struct Continent: Codable, Hashable {
|
||||
var name: String
|
||||
var count: Int
|
||||
}
|
||||
}
|
||||
|
||||
/// Who travels along, the trip chat and its open poll.
|
||||
struct Collab: Codable {
|
||||
var members: [Member]
|
||||
var messages: [Message]
|
||||
var poll: Poll?
|
||||
var path: String
|
||||
|
||||
struct Member: Codable, Hashable {
|
||||
var name: String
|
||||
var initials: String
|
||||
}
|
||||
|
||||
struct Message: Codable, Hashable {
|
||||
var author: String
|
||||
var initials: String
|
||||
var text: String
|
||||
var at: String?
|
||||
var mine: Bool
|
||||
}
|
||||
|
||||
struct Poll: Codable {
|
||||
var question: String
|
||||
var options: [Option]
|
||||
var voters: Int
|
||||
|
||||
struct Option: Codable, Hashable {
|
||||
var label: String
|
||||
var votes: Int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Labels: Codable {
|
||||
@@ -31,29 +168,98 @@ struct WidgetSnapshot: Codable {
|
||||
var inDays: String
|
||||
var tomorrow: String
|
||||
var nothingPlanned: String
|
||||
var days: String?
|
||||
var dayOf: String?
|
||||
var bookings: String?
|
||||
var noBookings: String?
|
||||
var todos: String?
|
||||
var noTodos: String?
|
||||
var packing: String?
|
||||
var packed: String?
|
||||
var confirmed: String?
|
||||
var pending: String?
|
||||
var todayShort: String?
|
||||
var tomorrowShort: String?
|
||||
var now: String?
|
||||
var places: String?
|
||||
var travellers: String?
|
||||
var allDone: String?
|
||||
var openTrek: String?
|
||||
var missing: String?
|
||||
var countries: String?
|
||||
var world: String?
|
||||
var group: String?
|
||||
var poll: String?
|
||||
var votes: String?
|
||||
var noMessages: String?
|
||||
var departure: String?
|
||||
}
|
||||
|
||||
var trip: Trip?
|
||||
var today: Today?
|
||||
var weather: Weather?
|
||||
var bookings: [Booking]?
|
||||
var todos: Todos?
|
||||
var packing: Packing?
|
||||
var atlas: Atlas?
|
||||
var collab: Collab?
|
||||
var labels: Labels
|
||||
|
||||
/// The dashboard's snapshot.
|
||||
static func load() -> WidgetSnapshot? {
|
||||
read("widget.snapshot").flatMap { try? JSONDecoder().decode(WidgetSnapshot.self, from: $0) }
|
||||
}
|
||||
|
||||
/// The snapshot of the trip a widget is set to, the dashboard's for none or
|
||||
/// "auto", and the dashboard's too while the app has not written that trip yet.
|
||||
static func load(trip id: String?) -> WidgetSnapshot? {
|
||||
guard let id, id != "auto", let data = read("widget.trip.\(id)"),
|
||||
var snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { return load() }
|
||||
if snapshot.atlas == nil { snapshot.atlas = load()?.atlas }
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/// A trip a widget can be set to, as the app lists them.
|
||||
struct TripRef: Codable {
|
||||
var id: Int
|
||||
var title: String
|
||||
var start: String
|
||||
var end: String
|
||||
}
|
||||
|
||||
static func tripIndex() -> [TripRef] {
|
||||
read("widget.trips").flatMap { try? JSONDecoder().decode([TripRef].self, from: $0) } ?? []
|
||||
}
|
||||
|
||||
private static func read(_ account: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "com.liketrek.trek.push",
|
||||
kSecAttrAccount as String: "widget.snapshot",
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return try? JSONDecoder().decode(WidgetSnapshot.self, from: data)
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Whole days from today to the trip's start, in the device's calendar.
|
||||
func daysUntilStart(from now: Date = Date()) -> Int? {
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start) else { return nil }
|
||||
return WidgetSnapshot.daysBetween(now, start)
|
||||
}
|
||||
|
||||
/// The trip's length in days, from its dates when the app sent no count.
|
||||
var tripLength: Int? {
|
||||
if let count = trip?.dayCount, count > 0 { return count }
|
||||
guard let trip, let start = WidgetSnapshot.day(trip.start), let end = WidgetSnapshot.day(trip.end),
|
||||
let days = WidgetSnapshot.daysBetween(start, end) else { return nil }
|
||||
return days + 1
|
||||
}
|
||||
|
||||
static func daysBetween(_ from: Date, _ to: Date) -> Int? {
|
||||
let calendar = Calendar.current
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: start).day
|
||||
return calendar.dateComponents([.day], from: calendar.startOfDay(for: from), to: calendar.startOfDay(for: to)).day
|
||||
}
|
||||
|
||||
static func day(_ iso: String) -> Date? {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Generated by client/ios/WidgetPreviews/make-world-grid.py from
|
||||
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
|
||||
import Foundation
|
||||
|
||||
/// The world as 76 by 34 dots from 80° north to 58° south,
|
||||
/// each the ISO code of the country under it, ".." for sea (977 land dots).
|
||||
enum WorldGrid {
|
||||
static let columns = 76
|
||||
static let rows: [String] = [
|
||||
"........................CACACA..CACACACACACA..GLGLGLGLGLGLGLGLGLGLGL............NONONO..................RU..........RURURU..............................",
|
||||
"......................CACACACACA..CACACACACA........GLGLGLGLGLGLGLGL..............................RURU....RURURURURURURURURURURURURU..RURURU............",
|
||||
"......USUSUSUSUSCACACACACACACACACACACACACACACACA....GLGLGLGLGLGLGL................NONONORURU....RURU..RURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"RURU..USUSUSUSUSCACACACACACACACACACACACACACACACACA....GLGLGLGL....ISIS..........NOSESEFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"......USUSUSUSUSCACACACACACACACACACACA....CACACACA....GLGL....................NONOSEFIFIRURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURURU",
|
||||
"........USUS......USCACACACACACACACACACA..CACACACA........................GB..NOSESELVLVRURURURURURURURURURURURURURURURURURURURURURURURU......RURU......",
|
||||
"......US............CACACACACACACACACACACACACACACACA....................IEGBGBNLDEPLPLBYBYRURURURURURUKZKZRURURURURURURURURURURURURURURURU....RU........",
|
||||
"......................CACACACACACACACACACACACACACACACA....................FRFRDEDECZPLUAUAUARURUKZKZKZKZKZKZKZKZRUMNMNMNMNRUMNCNCNRURURURU..............",
|
||||
"........................USUSUSUSUSUSUSUSCACACAUSCA........................FRFRITITHRROROMDUARURURUKZKZKZKZKZKZCNCNMNMNMNMNMNCNCNCNCNRU..................",
|
||||
"......................USUSUSUSUSUSUSUSUSUSUSUS..........................ESESES..ITITALBGTRTRTRGEAZTMTMUZKZKGKGCNCNCNCNCNCNCNCNCNCNCN..JPJP..............",
|
||||
"........................USUSUSUSUSUSUSUSUSUS............................ESESDZDZ....GRTRTRTRTRIRIRIRIRTMTJTJCNCNCNCNCNCNCNCNCNCNKPKR..JP................",
|
||||
"..........................USUSUSUSUSUSUSUSUS............................MAMADZDZTN..LY....LBIQIQIRIRIRAFAFPKINCNCNCNCNCNCNCNCNCNKRJPJP..................",
|
||||
"..........................MXMXMXMXUSUS..US............................MADZDZDZDZLYLYLYEGEGSASAIQIRIRIRPKPKININNPCNCNCNCNCNCNCNCN........................",
|
||||
"............................MXMXMXUS....US..........................EHEHMRDZDZDZLYLYLYEGEGSASASASAAEIRPKPKINININININMMCNCNCNCNCN........................",
|
||||
"..............................MXMXMX..MXCUCUBS......................MRMRMRMLDZDZNETDLYSDSDSDSASASASAOM..INININININMMMMLAVNCN............................",
|
||||
"................................MXMXMXMX..JM........................MRMRMRMLMLNENETDTDSDSDSDERYEYEOM......INININ....MMTHVN....PH........................",
|
||||
"....................................GTSVNI....VE....................SNSNMLBFNENENGTDTDSDSDSDETYEYE........ININ......MMTHKH....PH........................",
|
||||
"........................................CRPACOVEVEVE..................SLCIGHBJNGNGTDCFSSSSETETSOSO..........IN....IN....VN......PH......................",
|
||||
"..........................................COCOCOVEGYSR..................CICI..NGCMCFCFCDSSKEETSOSO..................IDMY....MY..ID......................",
|
||||
"..........................................ECCOBRBRBRBRBR........................GACGCDCDUGKEKESO......................ID..IDIDID........................",
|
||||
"..........................................ECPEBRBRBRBRBRBRBR....................CGCDCDCDBITZKE........................IDIDIDIDID..IDIDIDPG..PG..........",
|
||||
"..........................................PEBRBRBRBRBRBRBRBRBR..................AOAOCDCDTZTZTZ..........................IDIDID........IDPGPGPG..........",
|
||||
"..........................................PEPEBOBRBRBRBRBRBRBR....................AOAOCDZMTZMZKM..................................AUAU....PG............",
|
||||
"............................................PEBOBOBRBRBRBRBR....................AOAOAOZMMZMZMZMGMG..............................AUAUAU..AU..............",
|
||||
"..............................................CLBOPYBRBRBRBR....................NANABWBWZWMZ..MGMG............................AUAUAUAUAUAUAU....FR......",
|
||||
"..............................................CLARPYBRBRBR........................NABWBWZAMZ..MG............................AUAUAUAUAUAUAUAU............",
|
||||
"..............................................ARARARBRBR..........................NAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"..............................................ARARARUY............................ZAZAZAZA..................................AUAUAUAUAUAUAUAUAU..........",
|
||||
"............................................CLARARARUY..............................ZA......................................AU........AUAUAU............",
|
||||
"............................................CLARARAR....................................................................................AU............NZ",
|
||||
"............................................CLARAR......................................................................................AU........NZNZ..",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"............................................CLAR........................................................................................................",
|
||||
"..............................................CL........................................................................................................",
|
||||
]
|
||||
|
||||
/// The country code of a dot, nil for sea.
|
||||
static func code(row: Int, column: Int) -> String? {
|
||||
let line = rows[row]
|
||||
let start = line.index(line.startIndex, offsetBy: column * 2)
|
||||
let code = String(line[start..<line.index(start, offsetBy: 2)])
|
||||
return code == ".." ? nil : code
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "رحلة";
|
||||
"widget.trip.description" = "عدّ تنازلي لرحلتك القادمة، ثم اليوم وما يليه أثناء السفر.";
|
||||
"widget.today.name" = "اليوم";
|
||||
"widget.today.description" = "خطة اليوم كخط زمني، مع الطقس والمحطة التالية.";
|
||||
"widget.bookings.name" = "الحجوزات";
|
||||
"widget.bookings.description" = "رحلاتك وقطاراتك وفنادقك وحجوزات المطاعم القادمة ومواعيدها.";
|
||||
"widget.todos.name" = "المهام";
|
||||
"widget.todos.description" = "ما تبقى للرحلة، ومدى تقدم تجهيز الأمتعة.";
|
||||
"widget.packing.name" = "قائمة الأمتعة";
|
||||
"widget.packing.description" = "مدى امتلاء الحقائب ووزنها وما الذي ما زال ناقصًا.";
|
||||
"widget.atlas.name" = "خريطة العالم";
|
||||
"widget.atlas.description" = "الدول التي زرتها مضيئة على الخريطة.";
|
||||
"widget.collab.name" = "مجموعة السفر";
|
||||
"widget.collab.description" = "من يرافقك وأحدث الرسائل والتصويت المفتوح.";
|
||||
"widget.intent.title" = "اختر الرحلة";
|
||||
"widget.intent.description" = "الرحلة التي تعرضها هذه الأداة.";
|
||||
"widget.intent.trip" = "الرحلة";
|
||||
"widget.intent.auto" = "تلقائي";
|
||||
"widget.intent.autoHint" = "نفس رحلة لوحة التحكم";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Səyahət";
|
||||
"widget.trip.description" = "Növbəti səyahətə qədər günləri sayır, yolda isə günü və sonrakını göstərir.";
|
||||
"widget.today.name" = "Bu gün";
|
||||
"widget.today.description" = "Günün planı zaman xətti kimi, hava və növbəti dayanacaqla.";
|
||||
"widget.bookings.name" = "Bronlar";
|
||||
"widget.bookings.description" = "Növbəti uçuşlar, qatarlar, otellər və restoranlar, nə vaxt olduqları ilə.";
|
||||
"widget.todos.name" = "Tapşırıqlar";
|
||||
"widget.todos.description" = "Səyahət üçün hələ nəyin qaldığı və yığışmağın nə yerdə olduğu.";
|
||||
"widget.packing.name" = "Yük siyahısı";
|
||||
"widget.packing.description" = "Çantaların doluluğu, çəkisi və hələ nəyin çatışmadığı.";
|
||||
"widget.atlas.name" = "Dünya xəritəsi";
|
||||
"widget.atlas.description" = "Olduğun ölkələr xəritədə parlayır.";
|
||||
"widget.collab.name" = "Səyahət qrupu";
|
||||
"widget.collab.description" = "Kim gəlir, son mesajlar və açıq səsvermə.";
|
||||
"widget.intent.title" = "Səyahəti seç";
|
||||
"widget.intent.description" = "Bu vidcetin göstərdiyi səyahət.";
|
||||
"widget.intent.trip" = "Səyahət";
|
||||
"widget.intent.auto" = "Avtomatik";
|
||||
"widget.intent.autoHint" = "İdarə panelindəki səyahət";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viatge";
|
||||
"widget.trip.description" = "Compta els dies fins al proper viatge i, durant el viatge, mostra el dia i el que ve després.";
|
||||
"widget.today.name" = "Avui";
|
||||
"widget.today.description" = "El pla del dia com a línia de temps, amb el temps i la propera parada.";
|
||||
"widget.bookings.name" = "Reserves";
|
||||
"widget.bookings.description" = "Els teus propers vols, trens, hotels i taules, i quan toquen.";
|
||||
"widget.todos.name" = "Tasques";
|
||||
"widget.todos.description" = "El que encara queda per al viatge i com va l'equipatge.";
|
||||
"widget.packing.name" = "Equipatge";
|
||||
"widget.packing.description" = "Com de plenes són les maletes, el pes i el que encara falta.";
|
||||
"widget.atlas.name" = "Mapa del món";
|
||||
"widget.atlas.description" = "Els països on has estat, brillant al mapa.";
|
||||
"widget.collab.name" = "Grup de viatge";
|
||||
"widget.collab.description" = "Qui ve, els darrers missatges i la votació oberta.";
|
||||
"widget.intent.title" = "Tria el viatge";
|
||||
"widget.intent.description" = "El viatge que mostra aquest giny.";
|
||||
"widget.intent.trip" = "Viatge";
|
||||
"widget.intent.auto" = "Automàtic";
|
||||
"widget.intent.autoHint" = "El mateix viatge que el tauler";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Cesta";
|
||||
"widget.trip.description" = "Odpočítává dny do další cesty a během ní ukazuje den a co přijde dál.";
|
||||
"widget.today.name" = "Dnes";
|
||||
"widget.today.description" = "Plán dne jako časová osa, s počasím a další zastávkou.";
|
||||
"widget.bookings.name" = "Rezervace";
|
||||
"widget.bookings.description" = "Tvoje další lety, vlaky, hotely a stoly, a kdy tě čekají.";
|
||||
"widget.todos.name" = "Úkoly";
|
||||
"widget.todos.description" = "Co ještě zbývá k cestě a jak daleko je balení.";
|
||||
"widget.packing.name" = "Balení";
|
||||
"widget.packing.description" = "Jak plná jsou zavazadla, jejich váha a co ještě chybí.";
|
||||
"widget.atlas.name" = "Mapa světa";
|
||||
"widget.atlas.description" = "Země, kde jsi byl, zářící na mapě.";
|
||||
"widget.collab.name" = "Cestovní skupina";
|
||||
"widget.collab.description" = "Kdo jede s tebou, nejnovější zprávy a otevřené hlasování.";
|
||||
"widget.intent.title" = "Vybrat cestu";
|
||||
"widget.intent.description" = "Cesta, kterou tento widget zobrazuje.";
|
||||
"widget.intent.trip" = "Cesta";
|
||||
"widget.intent.auto" = "Automaticky";
|
||||
"widget.intent.autoHint" = "Stejná cesta jako na nástěnce";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reise";
|
||||
"widget.trip.description" = "Zählt die Tage bis zur nächsten Reise und zeigt unterwegs den Tag und was als Nächstes kommt.";
|
||||
"widget.today.name" = "Heute";
|
||||
"widget.today.description" = "Der Tagesplan als Zeitleiste, mit dem Wetter und dem nächsten Stopp.";
|
||||
"widget.bookings.name" = "Buchungen";
|
||||
"widget.bookings.description" = "Deine nächsten Flüge, Züge, Hotels und Tische, und wann sie anstehen.";
|
||||
"widget.todos.name" = "To-dos";
|
||||
"widget.todos.description" = "Was für die Reise noch offen ist und wie weit das Packen ist.";
|
||||
"widget.packing.name" = "Packliste";
|
||||
"widget.packing.description" = "Wie voll die Taschen sind, ihr Gewicht und was noch fehlt.";
|
||||
"widget.atlas.name" = "Weltkarte";
|
||||
"widget.atlas.description" = "Die Länder, in denen du warst, leuchtend auf der Karte.";
|
||||
"widget.collab.name" = "Reisegruppe";
|
||||
"widget.collab.description" = "Wer mitreist, die neuesten Nachrichten und die offene Abstimmung.";
|
||||
"widget.intent.title" = "Reise wählen";
|
||||
"widget.intent.description" = "Die Reise, die dieses Widget zeigt.";
|
||||
"widget.intent.trip" = "Reise";
|
||||
"widget.intent.auto" = "Automatisch";
|
||||
"widget.intent.autoHint" = "Dieselbe Reise wie im Dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Ταξίδι";
|
||||
"widget.trip.description" = "Μετρά αντίστροφα ως το επόμενο ταξίδι και στη διάρκειά του δείχνει τη μέρα και τι ακολουθεί.";
|
||||
"widget.today.name" = "Σήμερα";
|
||||
"widget.today.description" = "Το πρόγραμμα της ημέρας ως χρονολόγιο, με τον καιρό και την επόμενη στάση.";
|
||||
"widget.bookings.name" = "Κρατήσεις";
|
||||
"widget.bookings.description" = "Οι επόμενες πτήσεις, τα τρένα, τα ξενοδοχεία και τα τραπέζια σου, και πότε είναι.";
|
||||
"widget.todos.name" = "Εκκρεμότητες";
|
||||
"widget.todos.description" = "Τι μένει ακόμα για το ταξίδι και πόσο έχει προχωρήσει το πακετάρισμα.";
|
||||
"widget.packing.name" = "Αποσκευές";
|
||||
"widget.packing.description" = "Πόσο γεμάτες είναι οι τσάντες, το βάρος τους και τι λείπει ακόμα.";
|
||||
"widget.atlas.name" = "Παγκόσμιος χάρτης";
|
||||
"widget.atlas.description" = "Οι χώρες που έχεις επισκεφθεί, φωτεινές στον χάρτη.";
|
||||
"widget.collab.name" = "Ομάδα ταξιδιού";
|
||||
"widget.collab.description" = "Ποιος έρχεται, τα τελευταία μηνύματα και η ανοιχτή ψηφοφορία.";
|
||||
"widget.intent.title" = "Επιλογή ταξιδιού";
|
||||
"widget.intent.description" = "Το ταξίδι που δείχνει αυτό το widget.";
|
||||
"widget.intent.trip" = "Ταξίδι";
|
||||
"widget.intent.auto" = "Αυτόματα";
|
||||
"widget.intent.autoHint" = "Το ίδιο ταξίδι με τον πίνακα";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Trip";
|
||||
"widget.trip.description" = "Counts down to your next trip, then shows the day and what comes next.";
|
||||
"widget.today.name" = "Today";
|
||||
"widget.today.description" = "Today's plan as a timeline, with the weather and what is next.";
|
||||
"widget.bookings.name" = "Bookings";
|
||||
"widget.bookings.description" = "Your next flights, trains, hotels and tables, and when they are due.";
|
||||
"widget.todos.name" = "To-dos";
|
||||
"widget.todos.description" = "What is still open for the trip, and how far the packing has come.";
|
||||
"widget.packing.name" = "Packing";
|
||||
"widget.packing.description" = "How full the bags are, their weight, and what is still missing.";
|
||||
"widget.atlas.name" = "World map";
|
||||
"widget.atlas.description" = "The countries you have been to, glowing on the map.";
|
||||
"widget.collab.name" = "Travel group";
|
||||
"widget.collab.description" = "Who comes along, the latest messages and the open poll.";
|
||||
"widget.intent.title" = "Choose trip";
|
||||
"widget.intent.description" = "The trip this widget shows.";
|
||||
"widget.intent.trip" = "Trip";
|
||||
"widget.intent.auto" = "Automatic";
|
||||
"widget.intent.autoHint" = "Same trip as the dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viaje";
|
||||
"widget.trip.description" = "Cuenta los días hasta tu próximo viaje y, durante él, muestra el día y lo que sigue.";
|
||||
"widget.today.name" = "Hoy";
|
||||
"widget.today.description" = "El plan del día como línea de tiempo, con el tiempo y la próxima parada.";
|
||||
"widget.bookings.name" = "Reservas";
|
||||
"widget.bookings.description" = "Tus próximos vuelos, trenes, hoteles y mesas, y cuándo tocan.";
|
||||
"widget.todos.name" = "Tareas";
|
||||
"widget.todos.description" = "Lo que aún queda para el viaje y cómo va la maleta.";
|
||||
"widget.packing.name" = "Equipaje";
|
||||
"widget.packing.description" = "Lo llenas que van las maletas, su peso y lo que aún falta.";
|
||||
"widget.atlas.name" = "Mapa del mundo";
|
||||
"widget.atlas.description" = "Los países donde has estado, brillando en el mapa.";
|
||||
"widget.collab.name" = "Grupo de viaje";
|
||||
"widget.collab.description" = "Quién viene, los últimos mensajes y la votación abierta.";
|
||||
"widget.intent.title" = "Elegir viaje";
|
||||
"widget.intent.description" = "El viaje que muestra este widget.";
|
||||
"widget.intent.trip" = "Viaje";
|
||||
"widget.intent.auto" = "Automático";
|
||||
"widget.intent.autoHint" = "El mismo viaje que el panel";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reis";
|
||||
"widget.trip.description" = "Loeb päevi järgmise reisini ja näitab reisi ajal päeva ning seda, mis järgmisena tuleb.";
|
||||
"widget.today.name" = "Täna";
|
||||
"widget.today.description" = "Päeva plaan ajajoonena, koos ilma ja järgmise peatusega.";
|
||||
"widget.bookings.name" = "Broneeringud";
|
||||
"widget.bookings.description" = "Sinu järgmised lennud, rongid, hotellid ja lauad ning millal need on.";
|
||||
"widget.todos.name" = "Ülesanded";
|
||||
"widget.todos.description" = "Mis reisi jaoks veel tegemata on ja kui kaugel pakkimine on.";
|
||||
"widget.packing.name" = "Pakkimine";
|
||||
"widget.packing.description" = "Kui täis kotid on, nende kaal ja mis veel puudu on.";
|
||||
"widget.atlas.name" = "Maailmakaart";
|
||||
"widget.atlas.description" = "Riigid, kus oled käinud, kaardil helendamas.";
|
||||
"widget.collab.name" = "Reisiseltskond";
|
||||
"widget.collab.description" = "Kes tuleb kaasa, viimased sõnumid ja avatud hääletus.";
|
||||
"widget.intent.title" = "Vali reis";
|
||||
"widget.intent.description" = "Reis, mida see vidin näitab.";
|
||||
"widget.intent.trip" = "Reis";
|
||||
"widget.intent.auto" = "Automaatne";
|
||||
"widget.intent.autoHint" = "Sama reis mis töölaual";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Voyage";
|
||||
"widget.trip.description" = "Compte les jours jusqu'au prochain voyage, puis montre la journée et la suite pendant le voyage.";
|
||||
"widget.today.name" = "Aujourd'hui";
|
||||
"widget.today.description" = "Le programme du jour en frise, avec la météo et la prochaine étape.";
|
||||
"widget.bookings.name" = "Réservations";
|
||||
"widget.bookings.description" = "Vos prochains vols, trains, hôtels et tables, et quand ils arrivent.";
|
||||
"widget.todos.name" = "À faire";
|
||||
"widget.todos.description" = "Ce qui reste à faire pour le voyage et où en sont les bagages.";
|
||||
"widget.packing.name" = "Bagages";
|
||||
"widget.packing.description" = "Le remplissage des sacs, leur poids et ce qui manque encore.";
|
||||
"widget.atlas.name" = "Carte du monde";
|
||||
"widget.atlas.description" = "Les pays où vous êtes allé, illuminés sur la carte.";
|
||||
"widget.collab.name" = "Groupe de voyage";
|
||||
"widget.collab.description" = "Qui vient, les derniers messages et le sondage ouvert.";
|
||||
"widget.intent.title" = "Choisir le voyage";
|
||||
"widget.intent.description" = "Le voyage affiché par ce widget.";
|
||||
"widget.intent.trip" = "Voyage";
|
||||
"widget.intent.auto" = "Automatique";
|
||||
"widget.intent.autoHint" = "Le même voyage que le tableau de bord";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "טיול";
|
||||
"widget.trip.description" = "סופר את הימים עד הטיול הבא, ובמהלכו מציג את היום ואת מה שבא אחריו.";
|
||||
"widget.today.name" = "היום";
|
||||
"widget.today.description" = "תוכנית היום כציר זמן, עם מזג האוויר והעצירה הבאה.";
|
||||
"widget.bookings.name" = "הזמנות";
|
||||
"widget.bookings.description" = "הטיסות, הרכבות, המלונות והשולחנות הבאים שלך, ומתי הם.";
|
||||
"widget.todos.name" = "משימות";
|
||||
"widget.todos.description" = "מה עוד פתוח לטיול וכמה התקדמה האריזה.";
|
||||
"widget.packing.name" = "אריזה";
|
||||
"widget.packing.description" = "כמה מלאים התיקים, המשקל שלהם ומה עוד חסר.";
|
||||
"widget.atlas.name" = "מפת העולם";
|
||||
"widget.atlas.description" = "המדינות שביקרת בהן, זוהרות על המפה.";
|
||||
"widget.collab.name" = "קבוצת הטיול";
|
||||
"widget.collab.description" = "מי מצטרף, ההודעות האחרונות והסקר הפתוח.";
|
||||
"widget.intent.title" = "בחירת טיול";
|
||||
"widget.intent.description" = "הטיול שהווידג׳ט הזה מציג.";
|
||||
"widget.intent.trip" = "טיול";
|
||||
"widget.intent.auto" = "אוטומטי";
|
||||
"widget.intent.autoHint" = "אותו טיול כמו בלוח הבקרה";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Utazás";
|
||||
"widget.trip.description" = "Visszaszámol a következő utazásig, útközben pedig a napot és a következő programot mutatja.";
|
||||
"widget.today.name" = "Ma";
|
||||
"widget.today.description" = "A nap terve idővonalon, az időjárással és a következő megállóval.";
|
||||
"widget.bookings.name" = "Foglalások";
|
||||
"widget.bookings.description" = "A következő járataid, vonataid, szállásaid és asztalfoglalásaid, és hogy mikor esedékesek.";
|
||||
"widget.todos.name" = "Teendők";
|
||||
"widget.todos.description" = "Ami még hátravan az utazáshoz, és hol tart a csomagolás.";
|
||||
"widget.packing.name" = "Csomagolás";
|
||||
"widget.packing.description" = "Mennyire telik a táska, a súlya, és mi hiányzik még.";
|
||||
"widget.atlas.name" = "Világtérkép";
|
||||
"widget.atlas.description" = "Az országok, ahol jártál, világítva a térképen.";
|
||||
"widget.collab.name" = "Útitársak";
|
||||
"widget.collab.description" = "Ki jön veled, a legújabb üzenetek és a nyitott szavazás.";
|
||||
"widget.intent.title" = "Utazás választása";
|
||||
"widget.intent.description" = "Az utazás, amelyet ez a widget mutat.";
|
||||
"widget.intent.trip" = "Utazás";
|
||||
"widget.intent.auto" = "Automatikus";
|
||||
"widget.intent.autoHint" = "Ugyanaz az utazás, mint az irányítópulton";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Perjalanan";
|
||||
"widget.trip.description" = "Menghitung mundur ke perjalanan berikutnya, lalu menampilkan hari dan apa yang berikutnya.";
|
||||
"widget.today.name" = "Hari ini";
|
||||
"widget.today.description" = "Rencana hari ini sebagai linimasa, dengan cuaca dan perhentian berikutnya.";
|
||||
"widget.bookings.name" = "Pemesanan";
|
||||
"widget.bookings.description" = "Penerbangan, kereta, hotel, dan meja berikutnya, beserta waktunya.";
|
||||
"widget.todos.name" = "Tugas";
|
||||
"widget.todos.description" = "Yang masih tersisa untuk perjalanan dan sejauh mana persiapan barang.";
|
||||
"widget.packing.name" = "Barang bawaan";
|
||||
"widget.packing.description" = "Seberapa penuh tas, beratnya, dan yang masih kurang.";
|
||||
"widget.atlas.name" = "Peta dunia";
|
||||
"widget.atlas.description" = "Negara yang pernah kamu kunjungi, bersinar di peta.";
|
||||
"widget.collab.name" = "Rombongan";
|
||||
"widget.collab.description" = "Siapa yang ikut, pesan terbaru, dan polling yang terbuka.";
|
||||
"widget.intent.title" = "Pilih perjalanan";
|
||||
"widget.intent.description" = "Perjalanan yang ditampilkan widget ini.";
|
||||
"widget.intent.trip" = "Perjalanan";
|
||||
"widget.intent.auto" = "Otomatis";
|
||||
"widget.intent.autoHint" = "Perjalanan yang sama dengan dasbor";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viaggio";
|
||||
"widget.trip.description" = "Conta i giorni al prossimo viaggio e durante il viaggio mostra la giornata e cosa viene dopo.";
|
||||
"widget.today.name" = "Oggi";
|
||||
"widget.today.description" = "Il programma del giorno come linea del tempo, con il meteo e la prossima tappa.";
|
||||
"widget.bookings.name" = "Prenotazioni";
|
||||
"widget.bookings.description" = "I tuoi prossimi voli, treni, hotel e tavoli, e quando sono.";
|
||||
"widget.todos.name" = "Da fare";
|
||||
"widget.todos.description" = "Cosa manca ancora per il viaggio e a che punto è la valigia.";
|
||||
"widget.packing.name" = "Bagagli";
|
||||
"widget.packing.description" = "Quanto sono piene le borse, il loro peso e cosa manca ancora.";
|
||||
"widget.atlas.name" = "Mappa del mondo";
|
||||
"widget.atlas.description" = "I paesi in cui sei stato, illuminati sulla mappa.";
|
||||
"widget.collab.name" = "Gruppo di viaggio";
|
||||
"widget.collab.description" = "Chi viene, gli ultimi messaggi e il sondaggio aperto.";
|
||||
"widget.intent.title" = "Scegli viaggio";
|
||||
"widget.intent.description" = "Il viaggio mostrato da questo widget.";
|
||||
"widget.intent.trip" = "Viaggio";
|
||||
"widget.intent.auto" = "Automatico";
|
||||
"widget.intent.autoHint" = "Lo stesso viaggio della dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "次の旅行までをカウントダウンし、旅行中はその日の予定と次の行き先を表示します。";
|
||||
"widget.today.name" = "今日";
|
||||
"widget.today.description" = "今日の予定をタイムラインで、天気と次の予定とともに表示します。";
|
||||
"widget.bookings.name" = "予約";
|
||||
"widget.bookings.description" = "次のフライト、列車、ホテル、レストランとその日時。";
|
||||
"widget.todos.name" = "やること";
|
||||
"widget.todos.description" = "旅行のために残っていることと、荷造りの進み具合。";
|
||||
"widget.packing.name" = "持ち物";
|
||||
"widget.packing.description" = "バッグの詰め具合と重さ、まだ足りないもの。";
|
||||
"widget.atlas.name" = "世界地図";
|
||||
"widget.atlas.description" = "訪れた国が地図上で光ります。";
|
||||
"widget.collab.name" = "旅行グループ";
|
||||
"widget.collab.description" = "一緒に行くメンバー、最新のメッセージ、受付中の投票。";
|
||||
"widget.intent.title" = "旅行を選択";
|
||||
"widget.intent.description" = "このウィジェットに表示する旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自動";
|
||||
"widget.intent.autoHint" = "ダッシュボードと同じ旅行";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "여행";
|
||||
"widget.trip.description" = "다음 여행까지 남은 날을 세고, 여행 중에는 오늘과 다음 일정을 보여 줍니다.";
|
||||
"widget.today.name" = "오늘";
|
||||
"widget.today.description" = "오늘 일정을 타임라인으로, 날씨와 다음 장소와 함께 보여 줍니다.";
|
||||
"widget.bookings.name" = "예약";
|
||||
"widget.bookings.description" = "다가오는 항공편, 기차, 호텔, 식당과 그 시간.";
|
||||
"widget.todos.name" = "할 일";
|
||||
"widget.todos.description" = "여행을 위해 남은 일과 짐 싸기 진행 상황.";
|
||||
"widget.packing.name" = "짐 싸기";
|
||||
"widget.packing.description" = "가방이 얼마나 찼는지, 무게, 아직 빠진 것.";
|
||||
"widget.atlas.name" = "세계 지도";
|
||||
"widget.atlas.description" = "다녀온 나라가 지도 위에서 빛납니다.";
|
||||
"widget.collab.name" = "여행 그룹";
|
||||
"widget.collab.description" = "함께 가는 사람, 최근 메시지, 진행 중인 투표.";
|
||||
"widget.intent.title" = "여행 선택";
|
||||
"widget.intent.description" = "이 위젯에 표시할 여행.";
|
||||
"widget.intent.trip" = "여행";
|
||||
"widget.intent.auto" = "자동";
|
||||
"widget.intent.autoHint" = "대시보드와 같은 여행";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Reis";
|
||||
"widget.trip.description" = "Telt af naar je volgende reis en toont onderweg de dag en wat er hierna komt.";
|
||||
"widget.today.name" = "Vandaag";
|
||||
"widget.today.description" = "Het dagplan als tijdlijn, met het weer en de volgende stop.";
|
||||
"widget.bookings.name" = "Boekingen";
|
||||
"widget.bookings.description" = "Je volgende vluchten, treinen, hotels en tafels, en wanneer ze zijn.";
|
||||
"widget.todos.name" = "Taken";
|
||||
"widget.todos.description" = "Wat er nog openstaat voor de reis en hoe ver het inpakken is.";
|
||||
"widget.packing.name" = "Bagage";
|
||||
"widget.packing.description" = "Hoe vol de tassen zijn, hun gewicht en wat er nog mist.";
|
||||
"widget.atlas.name" = "Wereldkaart";
|
||||
"widget.atlas.description" = "De landen waar je bent geweest, oplichtend op de kaart.";
|
||||
"widget.collab.name" = "Reisgezelschap";
|
||||
"widget.collab.description" = "Wie er meegaat, de nieuwste berichten en de open peiling.";
|
||||
"widget.intent.title" = "Reis kiezen";
|
||||
"widget.intent.description" = "De reis die deze widget toont.";
|
||||
"widget.intent.trip" = "Reis";
|
||||
"widget.intent.auto" = "Automatisch";
|
||||
"widget.intent.autoHint" = "Dezelfde reis als het dashboard";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Podróż";
|
||||
"widget.trip.description" = "Odlicza dni do następnej podróży, a w trakcie pokazuje dzień i to, co dalej.";
|
||||
"widget.today.name" = "Dziś";
|
||||
"widget.today.description" = "Plan dnia jako oś czasu, z pogodą i kolejnym przystankiem.";
|
||||
"widget.bookings.name" = "Rezerwacje";
|
||||
"widget.bookings.description" = "Twoje najbliższe loty, pociągi, hotele i stoliki oraz ich terminy.";
|
||||
"widget.todos.name" = "Zadania";
|
||||
"widget.todos.description" = "Co jeszcze zostało do zrobienia przed podróżą i jak idzie pakowanie.";
|
||||
"widget.packing.name" = "Pakowanie";
|
||||
"widget.packing.description" = "Jak pełne są torby, ich waga i czego jeszcze brakuje.";
|
||||
"widget.atlas.name" = "Mapa świata";
|
||||
"widget.atlas.description" = "Kraje, w których byłeś, świecące na mapie.";
|
||||
"widget.collab.name" = "Grupa podróży";
|
||||
"widget.collab.description" = "Kto jedzie, najnowsze wiadomości i otwarta ankieta.";
|
||||
"widget.intent.title" = "Wybierz podróż";
|
||||
"widget.intent.description" = "Podróż, którą pokazuje ten widżet.";
|
||||
"widget.intent.trip" = "Podróż";
|
||||
"widget.intent.auto" = "Automatycznie";
|
||||
"widget.intent.autoHint" = "Ta sama podróż co na pulpicie";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Viagem";
|
||||
"widget.trip.description" = "Conta os dias até a próxima viagem e, durante ela, mostra o dia e o que vem a seguir.";
|
||||
"widget.today.name" = "Hoje";
|
||||
"widget.today.description" = "O plano do dia como linha do tempo, com o clima e a próxima parada.";
|
||||
"widget.bookings.name" = "Reservas";
|
||||
"widget.bookings.description" = "Seus próximos voos, trens, hotéis e mesas, e quando acontecem.";
|
||||
"widget.todos.name" = "Tarefas";
|
||||
"widget.todos.description" = "O que ainda falta para a viagem e quanto da mala já está pronto.";
|
||||
"widget.packing.name" = "Bagagem";
|
||||
"widget.packing.description" = "Quão cheias estão as malas, o peso e o que ainda falta.";
|
||||
"widget.atlas.name" = "Mapa-múndi";
|
||||
"widget.atlas.description" = "Os países em que você esteve, brilhando no mapa.";
|
||||
"widget.collab.name" = "Grupo de viagem";
|
||||
"widget.collab.description" = "Quem vai junto, as mensagens recentes e a votação aberta.";
|
||||
"widget.intent.title" = "Escolher viagem";
|
||||
"widget.intent.description" = "A viagem que este widget mostra.";
|
||||
"widget.intent.trip" = "Viagem";
|
||||
"widget.intent.auto" = "Automático";
|
||||
"widget.intent.autoHint" = "A mesma viagem do painel";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Поездка";
|
||||
"widget.trip.description" = "Отсчитывает дни до следующей поездки, а в пути показывает день и что дальше.";
|
||||
"widget.today.name" = "Сегодня";
|
||||
"widget.today.description" = "План дня в виде ленты времени, с погодой и следующей остановкой.";
|
||||
"widget.bookings.name" = "Бронирования";
|
||||
"widget.bookings.description" = "Ближайшие рейсы, поезда, отели и столики и когда они.";
|
||||
"widget.todos.name" = "Задачи";
|
||||
"widget.todos.description" = "Что ещё осталось сделать к поездке и как идут сборы.";
|
||||
"widget.packing.name" = "Сборы";
|
||||
"widget.packing.description" = "Насколько заполнены сумки, их вес и чего ещё не хватает.";
|
||||
"widget.atlas.name" = "Карта мира";
|
||||
"widget.atlas.description" = "Страны, где вы были, светятся на карте.";
|
||||
"widget.collab.name" = "Группа поездки";
|
||||
"widget.collab.description" = "Кто едет, последние сообщения и открытый опрос.";
|
||||
"widget.intent.title" = "Выбрать поездку";
|
||||
"widget.intent.description" = "Поездка, которую показывает этот виджет.";
|
||||
"widget.intent.trip" = "Поездка";
|
||||
"widget.intent.auto" = "Автоматически";
|
||||
"widget.intent.autoHint" = "Та же поездка, что и на главной";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Cesta";
|
||||
"widget.trip.description" = "Odpočítava dni do ďalšej cesty a počas nej ukazuje deň a čo príde ďalej.";
|
||||
"widget.today.name" = "Dnes";
|
||||
"widget.today.description" = "Plán dňa ako časová os, s počasím a ďalšou zastávkou.";
|
||||
"widget.bookings.name" = "Rezervácie";
|
||||
"widget.bookings.description" = "Tvoje ďalšie lety, vlaky, hotely a stoly a kedy ťa čakajú.";
|
||||
"widget.todos.name" = "Úlohy";
|
||||
"widget.todos.description" = "Čo ešte zostáva na cestu a ako ďaleko je balenie.";
|
||||
"widget.packing.name" = "Balenie";
|
||||
"widget.packing.description" = "Aké plné sú tašky, ich váha a čo ešte chýba.";
|
||||
"widget.atlas.name" = "Mapa sveta";
|
||||
"widget.atlas.description" = "Krajiny, kde si bol, žiariace na mape.";
|
||||
"widget.collab.name" = "Cestovná skupina";
|
||||
"widget.collab.description" = "Kto ide s tebou, najnovšie správy a otvorené hlasovanie.";
|
||||
"widget.intent.title" = "Vybrať cestu";
|
||||
"widget.intent.description" = "Cesta, ktorú zobrazuje tento widget.";
|
||||
"widget.intent.trip" = "Cesta";
|
||||
"widget.intent.auto" = "Automaticky";
|
||||
"widget.intent.autoHint" = "Rovnaká cesta ako na nástenke";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Resa";
|
||||
"widget.trip.description" = "Räknar ner till nästa resa och visar under resan dagen och vad som kommer härnäst.";
|
||||
"widget.today.name" = "Idag";
|
||||
"widget.today.description" = "Dagens plan som tidslinje, med vädret och nästa stopp.";
|
||||
"widget.bookings.name" = "Bokningar";
|
||||
"widget.bookings.description" = "Dina nästa flyg, tåg, hotell och bord, och när de är.";
|
||||
"widget.todos.name" = "Att göra";
|
||||
"widget.todos.description" = "Vad som återstår inför resan och hur långt packningen har kommit.";
|
||||
"widget.packing.name" = "Packning";
|
||||
"widget.packing.description" = "Hur fulla väskorna är, deras vikt och vad som fortfarande saknas.";
|
||||
"widget.atlas.name" = "Världskarta";
|
||||
"widget.atlas.description" = "Länderna du har besökt, lysande på kartan.";
|
||||
"widget.collab.name" = "Resesällskap";
|
||||
"widget.collab.description" = "Vem som följer med, de senaste meddelandena och den öppna omröstningen.";
|
||||
"widget.intent.title" = "Välj resa";
|
||||
"widget.intent.description" = "Resan som den här widgeten visar.";
|
||||
"widget.intent.trip" = "Resa";
|
||||
"widget.intent.auto" = "Automatiskt";
|
||||
"widget.intent.autoHint" = "Samma resa som på startsidan";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "ทริป";
|
||||
"widget.trip.description" = "นับถอยหลังสู่ทริปถัดไป และระหว่างทริปแสดงวันนี้และสิ่งที่จะตามมา";
|
||||
"widget.today.name" = "วันนี้";
|
||||
"widget.today.description" = "แผนของวันนี้เป็นไทม์ไลน์ พร้อมสภาพอากาศและจุดหมายถัดไป";
|
||||
"widget.bookings.name" = "การจอง";
|
||||
"widget.bookings.description" = "เที่ยวบิน รถไฟ โรงแรม และร้านอาหารถัดไปของคุณ และเวลาของแต่ละรายการ";
|
||||
"widget.todos.name" = "สิ่งที่ต้องทำ";
|
||||
"widget.todos.description" = "สิ่งที่ยังค้างอยู่สำหรับทริป และความคืบหน้าการจัดกระเป๋า";
|
||||
"widget.packing.name" = "จัดกระเป๋า";
|
||||
"widget.packing.description" = "กระเป๋าเต็มแค่ไหน น้ำหนัก และอะไรที่ยังขาด";
|
||||
"widget.atlas.name" = "แผนที่โลก";
|
||||
"widget.atlas.description" = "ประเทศที่คุณเคยไป ส่องแสงบนแผนที่";
|
||||
"widget.collab.name" = "กลุ่มเดินทาง";
|
||||
"widget.collab.description" = "ใครไปด้วย ข้อความล่าสุด และโหวตที่เปิดอยู่";
|
||||
"widget.intent.title" = "เลือกทริป";
|
||||
"widget.intent.description" = "ทริปที่วิดเจ็ตนี้แสดง";
|
||||
"widget.intent.trip" = "ทริป";
|
||||
"widget.intent.auto" = "อัตโนมัติ";
|
||||
"widget.intent.autoHint" = "ทริปเดียวกับแดชบอร์ด";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Seyahat";
|
||||
"widget.trip.description" = "Sonraki seyahate kalan günleri sayar, yolculukta günü ve sırada ne olduğunu gösterir.";
|
||||
"widget.today.name" = "Bugün";
|
||||
"widget.today.description" = "Günün planı zaman çizelgesi olarak, hava durumu ve sonraki durakla.";
|
||||
"widget.bookings.name" = "Rezervasyonlar";
|
||||
"widget.bookings.description" = "Sıradaki uçuşların, trenlerin, otellerin ve masaların, ne zaman oldukları ile.";
|
||||
"widget.todos.name" = "Yapılacaklar";
|
||||
"widget.todos.description" = "Seyahat için hâlâ açık olanlar ve bavulun ne durumda olduğu.";
|
||||
"widget.packing.name" = "Bavul";
|
||||
"widget.packing.description" = "Çantaların ne kadar dolu olduğu, ağırlıkları ve eksikler.";
|
||||
"widget.atlas.name" = "Dünya haritası";
|
||||
"widget.atlas.description" = "Bulunduğun ülkeler haritada parlıyor.";
|
||||
"widget.collab.name" = "Seyahat grubu";
|
||||
"widget.collab.description" = "Kimler geliyor, son mesajlar ve açık anket.";
|
||||
"widget.intent.title" = "Seyahat seç";
|
||||
"widget.intent.description" = "Bu widget'ın gösterdiği seyahat.";
|
||||
"widget.intent.trip" = "Seyahat";
|
||||
"widget.intent.auto" = "Otomatik";
|
||||
"widget.intent.autoHint" = "Paneldeki seyahatin aynısı";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Подорож";
|
||||
"widget.trip.description" = "Відлічує дні до наступної подорожі, а в дорозі показує день і що далі.";
|
||||
"widget.today.name" = "Сьогодні";
|
||||
"widget.today.description" = "План дня у вигляді стрічки часу, з погодою і наступною зупинкою.";
|
||||
"widget.bookings.name" = "Бронювання";
|
||||
"widget.bookings.description" = "Найближчі рейси, поїзди, готелі й столики та коли вони.";
|
||||
"widget.todos.name" = "Завдання";
|
||||
"widget.todos.description" = "Що ще лишилося зробити до подорожі і як просувається пакування.";
|
||||
"widget.packing.name" = "Пакування";
|
||||
"widget.packing.description" = "Наскільки заповнені сумки, їхня вага і чого ще бракує.";
|
||||
"widget.atlas.name" = "Мапа світу";
|
||||
"widget.atlas.description" = "Країни, де ви були, світяться на мапі.";
|
||||
"widget.collab.name" = "Група подорожі";
|
||||
"widget.collab.description" = "Хто їде, останні повідомлення і відкрите опитування.";
|
||||
"widget.intent.title" = "Вибрати подорож";
|
||||
"widget.intent.description" = "Подорож, яку показує цей віджет.";
|
||||
"widget.intent.trip" = "Подорож";
|
||||
"widget.intent.auto" = "Автоматично";
|
||||
"widget.intent.autoHint" = "Та сама подорож, що й на панелі";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "Chuyến đi";
|
||||
"widget.trip.description" = "Đếm ngược đến chuyến đi tiếp theo, trong chuyến đi thì hiện ngày và điểm tiếp theo.";
|
||||
"widget.today.name" = "Hôm nay";
|
||||
"widget.today.description" = "Kế hoạch hôm nay dạng dòng thời gian, kèm thời tiết và điểm dừng tiếp theo.";
|
||||
"widget.bookings.name" = "Đặt chỗ";
|
||||
"widget.bookings.description" = "Các chuyến bay, tàu, khách sạn và bàn sắp tới của bạn, cùng thời gian.";
|
||||
"widget.todos.name" = "Việc cần làm";
|
||||
"widget.todos.description" = "Những gì còn lại cho chuyến đi và việc soạn hành lý đã tới đâu.";
|
||||
"widget.packing.name" = "Hành lý";
|
||||
"widget.packing.description" = "Túi đã đầy đến đâu, cân nặng và những gì còn thiếu.";
|
||||
"widget.atlas.name" = "Bản đồ thế giới";
|
||||
"widget.atlas.description" = "Những quốc gia bạn đã đến, sáng lên trên bản đồ.";
|
||||
"widget.collab.name" = "Nhóm du lịch";
|
||||
"widget.collab.description" = "Ai đi cùng, tin nhắn mới nhất và cuộc bình chọn đang mở.";
|
||||
"widget.intent.title" = "Chọn chuyến đi";
|
||||
"widget.intent.description" = "Chuyến đi mà tiện ích này hiển thị.";
|
||||
"widget.intent.trip" = "Chuyến đi";
|
||||
"widget.intent.auto" = "Tự động";
|
||||
"widget.intent.autoHint" = "Cùng chuyến đi với bảng điều khiển";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "倒数到下一次旅行,旅途中显示当天行程和接下来的安排。";
|
||||
"widget.today.name" = "今天";
|
||||
"widget.today.description" = "以时间线显示今天的计划,附带天气和下一站。";
|
||||
"widget.bookings.name" = "预订";
|
||||
"widget.bookings.description" = "你接下来的航班、火车、酒店和餐厅,以及时间。";
|
||||
"widget.todos.name" = "待办";
|
||||
"widget.todos.description" = "旅行还剩哪些事没做,以及行李收拾到哪一步。";
|
||||
"widget.packing.name" = "行李";
|
||||
"widget.packing.description" = "包装得多满、重量多少,以及还缺什么。";
|
||||
"widget.atlas.name" = "世界地图";
|
||||
"widget.atlas.description" = "你去过的国家在地图上亮起。";
|
||||
"widget.collab.name" = "旅行小组";
|
||||
"widget.collab.description" = "谁一起去、最新消息和进行中的投票。";
|
||||
"widget.intent.title" = "选择旅行";
|
||||
"widget.intent.description" = "此小组件显示的旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自动";
|
||||
"widget.intent.autoHint" = "与仪表板相同的旅行";
|
||||
@@ -0,0 +1,20 @@
|
||||
/* The widget gallery: names and descriptions (TREKWidgets/NextTripWidget.swift). */
|
||||
"widget.trip.name" = "旅行";
|
||||
"widget.trip.description" = "倒數到下一次旅行,旅途中顯示當天行程與接下來的安排。";
|
||||
"widget.today.name" = "今天";
|
||||
"widget.today.description" = "以時間軸顯示今天的計畫,附上天氣與下一站。";
|
||||
"widget.bookings.name" = "預訂";
|
||||
"widget.bookings.description" = "你接下來的航班、火車、飯店與餐廳,以及時間。";
|
||||
"widget.todos.name" = "待辦";
|
||||
"widget.todos.description" = "旅行還剩哪些事沒做,以及行李整理到哪一步。";
|
||||
"widget.packing.name" = "行李";
|
||||
"widget.packing.description" = "包包裝得多滿、重量多少,以及還缺什麼。";
|
||||
"widget.atlas.name" = "世界地圖";
|
||||
"widget.atlas.description" = "你去過的國家在地圖上亮起。";
|
||||
"widget.collab.name" = "旅行小組";
|
||||
"widget.collab.description" = "誰一起去、最新訊息與進行中的投票。";
|
||||
"widget.intent.title" = "選擇旅行";
|
||||
"widget.intent.description" = "此小工具顯示的旅行。";
|
||||
"widget.intent.trip" = "旅行";
|
||||
"widget.intent.auto" = "自動";
|
||||
"widget.intent.autoHint" = "與儀表板相同的旅行";
|
||||
@@ -0,0 +1,3 @@
|
||||
Sources/WidgetPreviews/Widgets/
|
||||
.build/
|
||||
out/
|
||||
@@ -0,0 +1,14 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Renders the widget views (client/ios/App/TREKWidgets/*Content.swift and the
|
||||
// files they use) to PNGs on a Mac, so a change to a widget can be looked at
|
||||
// without a phone: .github/workflows/native.yml copies them into Sources/ and
|
||||
// uploads what this writes. Not part of the app.
|
||||
let package = Package(
|
||||
name: "WidgetPreviews",
|
||||
platforms: [.macOS(.v14)],
|
||||
targets: [
|
||||
.executableTarget(name: "WidgetPreviews", path: "Sources/WidgetPreviews"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Home and lock screens of an iPhone filled with the widgets, at the size of
|
||||
/// an iPhone 16 Pro (402 by 874 points), for showing what the widgets look like
|
||||
/// where they live. The app icon comes from the asset catalogue.
|
||||
enum Mockups {
|
||||
@MainActor
|
||||
static func render(to out: URL, iconPath: String?, now: Date) throws {
|
||||
let icon = iconPath.flatMap { NSImage(contentsOfFile: $0) }.map { Image(nsImage: $0) }
|
||||
var upcoming = WidgetSnapshot.sample(running: false, now: now)
|
||||
upcoming.trip?.cover = Render.sampleCover()
|
||||
var running = WidgetSnapshot.sample(running: true, now: now)
|
||||
running.trip?.cover = upcoming.trip?.cover
|
||||
|
||||
let pages: [(String, ColorScheme, AnyView)] = [
|
||||
("1-travelling", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 0) {
|
||||
WidgetFrame(kind: .trip, family: .large, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .today, family: .medium, snapshot: running, now: now)
|
||||
AppRow(icon: icon, apps: [.maps, .photos, .camera, .weather])
|
||||
})),
|
||||
("2-countdown", .light, AnyView(PhonePage(wallpaper: .dawn, icon: icon, page: 1) {
|
||||
WidgetFrame(kind: .trip, family: .medium, snapshot: upcoming, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .packing, family: .small, snapshot: upcoming, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .todos, family: .medium, snapshot: upcoming, now: now)
|
||||
AppRow(icon: icon, apps: [.calendar, .trek, .mail, .notes])
|
||||
})),
|
||||
("3-explorer", .dark, AnyView(PhonePage(wallpaper: .ocean, icon: icon, page: 2) {
|
||||
WidgetFrame(kind: .atlas, family: .large, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .collab, family: .medium, snapshot: running, now: now)
|
||||
AppRow(icon: icon, apps: [.photos, .trek, .music, .settings])
|
||||
})),
|
||||
("4-getting-ready", .light, AnyView(PhonePage(wallpaper: .sand, icon: icon, page: 3) {
|
||||
WidgetFrame(kind: .packing, family: .large, snapshot: upcoming, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: upcoming, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: upcoming, now: now)
|
||||
}
|
||||
AppRow(icon: icon, apps: [.wallet, .trek, .maps, .clock])
|
||||
})),
|
||||
("5-on-the-road", .dark, AnyView(PhonePage(wallpaper: .night, icon: icon, page: 4) {
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .trip, family: .small, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .collab, family: .small, snapshot: running, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .bookings, family: .medium, snapshot: running, now: now)
|
||||
HStack(spacing: 24) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: running, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: running, now: now)
|
||||
}
|
||||
AppRow(icon: icon, apps: [.maps, .trek, .camera, .weather])
|
||||
})),
|
||||
("6-lock-screen", .dark, AnyView(LockPage(snapshot: running, now: now))),
|
||||
]
|
||||
for (name, scheme, view) in pages {
|
||||
try Render.write(
|
||||
view.environment(\.colorScheme, scheme).environment(\.trekStaticPreview, true),
|
||||
to: out.appendingPathComponent("mockup-\(name).png")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The phone
|
||||
|
||||
enum Wallpaper {
|
||||
case night, dawn, ocean, sand
|
||||
|
||||
@ViewBuilder var view: some View {
|
||||
switch self {
|
||||
case .night:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0x0E1230), Color(hex: 0x1B1340), Color(hex: 0x05060C)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0x6A3FD8).opacity(0.75), .clear], center: UnitPoint(x: 0.15, y: 0.25), startRadius: 0, endRadius: 360)
|
||||
RadialGradient(colors: [Color(hex: 0x1E8FD8).opacity(0.6), .clear], center: UnitPoint(x: 0.9, y: 0.7), startRadius: 0, endRadius: 380)
|
||||
}
|
||||
case .dawn:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0xF6D5C3), Color(hex: 0xE9B8C9), Color(hex: 0xB9C6EE)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0xFFE2A8).opacity(0.9), .clear], center: UnitPoint(x: 0.8, y: 0.15), startRadius: 0, endRadius: 320)
|
||||
RadialGradient(colors: [Color(hex: 0x8FA8F0).opacity(0.7), .clear], center: UnitPoint(x: 0.1, y: 0.85), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
case .ocean:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0x03263A), Color(hex: 0x064D5C), Color(hex: 0x021018)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
RadialGradient(colors: [Color(hex: 0x22C7A9).opacity(0.55), .clear], center: UnitPoint(x: 0.85, y: 0.2), startRadius: 0, endRadius: 340)
|
||||
RadialGradient(colors: [Color(hex: 0x2E5BD8).opacity(0.55), .clear], center: UnitPoint(x: 0.1, y: 0.75), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
case .sand:
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(hex: 0xEFE6D8), Color(hex: 0xE3D2BC), Color(hex: 0xCFD9D4)], startPoint: .top, endPoint: .bottom)
|
||||
RadialGradient(colors: [Color(hex: 0xF4B183).opacity(0.65), .clear], center: UnitPoint(x: 0.2, y: 0.2), startRadius: 0, endRadius: 330)
|
||||
RadialGradient(colors: [Color(hex: 0x9CC5B8).opacity(0.7), .clear], center: UnitPoint(x: 0.9, y: 0.8), startRadius: 0, endRadius: 360)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One page of the home screen: status bar, the widgets and apps, the page
|
||||
/// dots and the dock, on the wallpaper, with the screen's rounded corners.
|
||||
struct PhonePage<Content: View>: View {
|
||||
let wallpaper: Wallpaper
|
||||
let icon: Image?
|
||||
let page: Int
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
wallpaper.view
|
||||
VStack(spacing: 0) {
|
||||
StatusBar(dark: wallpaper == .night || wallpaper == .ocean)
|
||||
VStack(spacing: 22) { content() }
|
||||
.padding(.top, 14)
|
||||
Spacer(minLength: 0)
|
||||
PageDots(count: 5, current: page)
|
||||
.padding(.bottom, 12)
|
||||
Dock(icon: icon)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
.frame(width: 402, height: 874)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
|
||||
.padding(30)
|
||||
.background(Color(hex: 0x111214))
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBar: View {
|
||||
let dark: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("9:41").font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 120)
|
||||
Spacer()
|
||||
Capsule().fill(.black).frame(width: 124, height: 36)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "cellularbars")
|
||||
Image(systemName: "wifi")
|
||||
Image(systemName: "battery.100percent")
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.frame(width: 120)
|
||||
}
|
||||
.foregroundStyle(dark ? .white : .black)
|
||||
.padding(.top, 12)
|
||||
.frame(height: 58)
|
||||
}
|
||||
}
|
||||
|
||||
struct PageDots: View {
|
||||
let count: Int
|
||||
let current: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<count, id: \.self) { index in
|
||||
Circle().fill(Color.white.opacity(index == current ? 0.95 : 0.4)).frame(width: 7, height: 7)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(Capsule().fill(Color.black.opacity(0.18)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic app, drawn as a symbol on a gradient, never a real app's icon.
|
||||
enum MockApp {
|
||||
case trek, maps, photos, camera, weather, calendar, mail, notes, music, settings, wallet, clock, phone, messages, browser
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .trek: return "TREK"
|
||||
case .maps: return "Maps"
|
||||
case .photos: return "Photos"
|
||||
case .camera: return "Camera"
|
||||
case .weather: return "Weather"
|
||||
case .calendar: return "Calendar"
|
||||
case .mail: return "Mail"
|
||||
case .notes: return "Notes"
|
||||
case .music: return "Music"
|
||||
case .settings: return "Settings"
|
||||
case .wallet: return "Wallet"
|
||||
case .clock: return "Clock"
|
||||
case .phone: return "Phone"
|
||||
case .messages: return "Messages"
|
||||
case .browser: return "Browser"
|
||||
}
|
||||
}
|
||||
|
||||
var look: (String, [Color], Color) {
|
||||
switch self {
|
||||
case .trek: return ("map.fill", [Color(hex: 0x202024), Color(hex: 0x0C0C0F)], .white)
|
||||
case .maps: return ("map", [Color(hex: 0x6FD08C), Color(hex: 0x2FA37A)], .white)
|
||||
case .photos: return ("camera.macro", [Color(hex: 0xFFFFFF), Color(hex: 0xF2F2F2)], Color(hex: 0xE8A13A))
|
||||
case .camera: return ("camera.fill", [Color(hex: 0xC8C8CC), Color(hex: 0x8E8E93)], Color(hex: 0x2B2B2E))
|
||||
case .weather: return ("cloud.sun.fill", [Color(hex: 0x5AB3F5), Color(hex: 0x2E6FD8)], .white)
|
||||
case .calendar: return ("calendar", [Color(hex: 0xFFFFFF), Color(hex: 0xF4F4F4)], Color(hex: 0xE5484D))
|
||||
case .mail: return ("envelope.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
case .notes: return ("note.text", [Color(hex: 0xFFE27A), Color(hex: 0xF5C542)], Color(hex: 0x5A4A12))
|
||||
case .music: return ("music.note", [Color(hex: 0xFF6B81), Color(hex: 0xF0304F)], .white)
|
||||
case .settings: return ("gearshape.fill", [Color(hex: 0xA6A6AB), Color(hex: 0x6E6E73)], .white)
|
||||
case .wallet: return ("wallet.pass.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], Color(hex: 0x7BC96F))
|
||||
case .clock: return ("clock.fill", [Color(hex: 0x2B2B2E), Color(hex: 0x111113)], .white)
|
||||
case .phone: return ("phone.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .messages: return ("message.fill", [Color(hex: 0x6FE38A), Color(hex: 0x34C759)], .white)
|
||||
case .browser: return ("safari.fill", [Color(hex: 0x56B4FF), Color(hex: 0x1D7AF2)], .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AppIcon: View {
|
||||
let app: MockApp
|
||||
let icon: Image?
|
||||
var label = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
Group {
|
||||
if app == .trek, let icon {
|
||||
icon.resizable().scaledToFill()
|
||||
} else {
|
||||
let (symbol, colors, ink) = app.look
|
||||
LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 28, weight: .medium)).foregroundStyle(ink))
|
||||
}
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 3, y: 1)
|
||||
if label {
|
||||
Text(app.name).font(.system(size: 12, weight: .medium)).foregroundStyle(.white).shadow(color: .black.opacity(0.35), radius: 2)
|
||||
}
|
||||
}
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
|
||||
struct AppRow: View {
|
||||
let icon: Image?
|
||||
let apps: [MockApp]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach(Array(apps.enumerated()), id: \.offset) { _, app in AppIcon(app: app, icon: icon) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dock: View {
|
||||
let icon: Image?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 13) {
|
||||
ForEach([MockApp.phone, .browser, .messages, .trek], id: \.name) { app in
|
||||
AppIcon(app: app, icon: icon, label: false)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 38, style: .continuous).fill(Color.white.opacity(0.22)))
|
||||
.overlay(RoundedRectangle(cornerRadius: 38, style: .continuous).stroke(Color.white.opacity(0.25), lineWidth: 0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The lock screen
|
||||
|
||||
struct LockPage: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
StatusBar(dark: true)
|
||||
Image(systemName: "lock.fill").font(.system(size: 18, weight: .semibold)).foregroundStyle(.white).padding(.top, 6)
|
||||
LockAccessory(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
.padding(.top, 10)
|
||||
Text("9:41")
|
||||
.font(.system(size: 104, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.padding(.top, -6)
|
||||
HStack(spacing: 14) {
|
||||
LockAccessory(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
LockAccessory(kind: .packing, family: .circular, snapshot: snapshot, now: now)
|
||||
LockAccessory(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
Spacer(minLength: 0)
|
||||
LiveActivityMock(snapshot: snapshot, now: now)
|
||||
.padding(.horizontal, 12)
|
||||
HStack {
|
||||
LockButton(symbol: "flashlight.off.fill")
|
||||
Spacer()
|
||||
LockButton(symbol: "camera.fill")
|
||||
}
|
||||
.padding(.horizontal, 46)
|
||||
.padding(.top, 22)
|
||||
Capsule().fill(.white).frame(width: 140, height: 5).padding(.top, 20).padding(.bottom, 10)
|
||||
}
|
||||
.frame(width: 402, height: 874)
|
||||
// Behind, not inside: a photo filled into a stack would widen it past the screen.
|
||||
.background(TripBackdrop(trip: snapshot.trip, shade: 0.35).frame(width: 402, height: 874).clipped())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 56, style: .continuous))
|
||||
.padding(30)
|
||||
.background(Color(hex: 0x111214))
|
||||
}
|
||||
}
|
||||
|
||||
private struct LockButton: View {
|
||||
let symbol: String
|
||||
|
||||
var body: some View {
|
||||
Circle().fill(Color.black.opacity(0.35)).frame(width: 50, height: 50)
|
||||
.overlay(Image(systemName: symbol).font(.system(size: 19)).foregroundStyle(.white))
|
||||
}
|
||||
}
|
||||
|
||||
/// The travel day's Live Activity as the lock screen shows it (the real one is
|
||||
/// TripDayLiveActivity, which needs ActivityKit).
|
||||
struct LiveActivityMock: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let today = snapshot.today
|
||||
let minutes = Calendar.current.component(.hour, from: now) * 60 + Calendar.current.component(.minute, from: now)
|
||||
let stops = today?.stops ?? []
|
||||
let next = stops.first { ($0.minutes ?? -1) >= minutes } ?? stops.last
|
||||
let done = stops.filter { ($0.minutes ?? Int.max) < minutes }.count
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.18), lineWidth: 5)
|
||||
Circle().trim(from: 0, to: stops.isEmpty ? 0 : Double(done) / Double(stops.count))
|
||||
.stroke(Trek.confirmed, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 17, weight: .bold)).foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(snapshot.trip?.title ?? "").font(.system(size: 11, weight: .bold)).tracking(0.5).textCase(.uppercase).foregroundStyle(.white.opacity(0.65)).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(today?.dayLabel ?? "").font(.system(size: 11, weight: .bold)).foregroundStyle(.white.opacity(0.65))
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(next?.name ?? "").font(.system(size: 18, weight: .heavy)).foregroundStyle(.white).lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
Text(next?.time ?? "").font(Trek.number(18, .heavy)).foregroundStyle(.white)
|
||||
}
|
||||
DaySegments(index: done + 1, total: max(stops.count, 1), color: Trek.confirmed, track: Color.white.opacity(0.2))
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 26, style: .continuous).fill(Color(hex: 0x0C0C0F).opacity(0.78)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A lock-screen accessory as the system draws it: white on the wallpaper,
|
||||
/// the circular ones on a soft disc, no box of their own.
|
||||
struct LockAccessory: View {
|
||||
let kind: WidgetKind
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
Group {
|
||||
switch kind {
|
||||
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
|
||||
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
|
||||
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
|
||||
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
|
||||
case .atlas: AtlasContent(snapshot: snapshot, family: family)
|
||||
default: EmptyView()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.16 : 0)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Draws every widget in every size, light and dark, for a trip ahead, a trip
|
||||
/// under way and no trip, plus a home screen of them on a wallpaper per scheme.
|
||||
/// Usage: WidgetPreviews <output directory>
|
||||
@main
|
||||
struct Render {
|
||||
@MainActor
|
||||
static func main() throws {
|
||||
let out = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "out", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: out, withIntermediateDirectories: true)
|
||||
// A fixed afternoon, so the now line sits between the stops.
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: Date())
|
||||
components.hour = 12
|
||||
components.minute = 10
|
||||
let now = Calendar.current.date(from: components) ?? Date()
|
||||
|
||||
var withCover = WidgetSnapshot.sample(running: false, now: now)
|
||||
withCover.trip?.cover = sampleCover()
|
||||
var runningCover = WidgetSnapshot.sample(running: true, now: now)
|
||||
runningCover.trip?.cover = withCover.trip?.cover
|
||||
let states: [(String, WidgetSnapshot)] = [
|
||||
("upcoming", .sample(running: false, now: now)),
|
||||
("running", .sample(running: true, now: now)),
|
||||
("empty", .sampleEmpty),
|
||||
("cover", withCover),
|
||||
("coverrunning", runningCover),
|
||||
]
|
||||
for scheme in [ColorScheme.light, .dark] {
|
||||
let schemeName = scheme == .dark ? "dark" : "light"
|
||||
for (stateName, snapshot) in states {
|
||||
for kind in WidgetKind.allCases {
|
||||
for family in kind.families {
|
||||
let view = WidgetFrame(kind: kind, family: family, snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(view, to: out.appendingPathComponent("\(kind.rawValue)-\(family.rawValue)-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
let showcase = NewWidgets(snapshot: states[0].1, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(showcase, to: out.appendingPathComponent("new-widgets-\(schemeName).png"))
|
||||
for (stateName, snapshot) in states where stateName != "empty" {
|
||||
let sheet = HomeScreen(snapshot: snapshot, now: now)
|
||||
.environment(\.colorScheme, scheme)
|
||||
.environment(\.trekStaticPreview, true)
|
||||
try write(sheet, to: out.appendingPathComponent("home-\(stateName)-\(schemeName).png"))
|
||||
}
|
||||
let lock = LockScreen(snapshots: states.prefix(3).map(\.1), now: now).environment(\.colorScheme, scheme)
|
||||
try write(lock, to: out.appendingPathComponent("lock-\(schemeName).png"))
|
||||
}
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
try Mockups.render(to: out, iconPath: args.count > 1 ? args[1] : nil, now: now)
|
||||
print("wrote \(try FileManager.default.contentsOfDirectory(atPath: out.path).count) images to \(out.path)")
|
||||
}
|
||||
|
||||
/// A stand-in for a trip's cover photo: dusk over layered mountains, as the
|
||||
/// small JPEG the app would send.
|
||||
@MainActor
|
||||
static func sampleCover() -> String? {
|
||||
let scene = ZStack(alignment: .bottom) {
|
||||
LinearGradient(colors: [Color(hex: 0x2B3A67), Color(hex: 0xC0617A), Color(hex: 0xF3A65A)], startPoint: .top, endPoint: .bottom)
|
||||
Circle().fill(Color(hex: 0xFFE2A8)).frame(width: 120, height: 120).offset(x: 140, y: -170).blur(radius: 2)
|
||||
Ridge(height: 0.55, seed: 3).fill(Color(hex: 0x6B4A6E))
|
||||
Ridge(height: 0.42, seed: 7).fill(Color(hex: 0x3E2E4F))
|
||||
Ridge(height: 0.26, seed: 11).fill(Color(hex: 0x1C1726))
|
||||
}
|
||||
.frame(width: 720, height: 480)
|
||||
let renderer = ImageRenderer(content: scene)
|
||||
renderer.scale = 1
|
||||
guard let image = renderer.nsImage, let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.74]) else { return nil }
|
||||
return jpeg.base64EncodedString()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func write<V: View>(_ view: V, to url: URL) throws {
|
||||
let renderer = ImageRenderer(content: view)
|
||||
renderer.scale = 3
|
||||
guard let image = renderer.nsImage,
|
||||
let tiff = image.tiffRepresentation,
|
||||
let bitmap = NSBitmapImageRep(data: tiff),
|
||||
let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
throw NSError(domain: "WidgetPreviews", code: 1, userInfo: [NSLocalizedDescriptionKey: "could not render \(url.lastPathComponent)"])
|
||||
}
|
||||
try png.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
enum WidgetKind: String, CaseIterable {
|
||||
case trip, today, bookings, todos, packing, atlas, collab
|
||||
|
||||
var families: [TrekFamily] {
|
||||
switch self {
|
||||
case .trip: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .today: return [.medium, .large]
|
||||
case .bookings: return [.small, .medium, .large, .rectangular, .inline]
|
||||
case .todos: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .packing: return [.small, .medium, .large, .circular, .rectangular]
|
||||
case .atlas: return [.small, .medium, .large, .circular, .rectangular, .inline]
|
||||
case .collab: return [.small, .medium, .large, .rectangular]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget as the home screen draws it: its background, the 16 pt content
|
||||
/// margin, the rounded corners; accessories white on a lock-screen tint.
|
||||
struct WidgetFrame: View {
|
||||
let kind: WidgetKind
|
||||
let family: TrekFamily
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
let size = family.previewSize
|
||||
if family.isAccessory {
|
||||
// As the lock screen draws them: white on the wallpaper, no box of their own
|
||||
// (the circular one gets the system's soft disc).
|
||||
content
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(Circle().fill(Color.white.opacity(family == .circular ? 0.14 : 0)))
|
||||
.padding(6)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
} else {
|
||||
content
|
||||
.padding(16)
|
||||
.frame(width: size.width, height: size.height)
|
||||
.background(background)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch kind {
|
||||
case .trip: TripContent(snapshot: snapshot, family: family, now: now)
|
||||
case .today: TodayContent(snapshot: snapshot, family: family, now: now)
|
||||
case .bookings: BookingsContent(snapshot: snapshot, family: family, now: now)
|
||||
case .todos: TodosContent(snapshot: snapshot, family: family, now: now)
|
||||
case .packing: PackingContent(snapshot: snapshot, family: family, now: now)
|
||||
case .atlas: AtlasContent(snapshot: snapshot, family: family)
|
||||
case .collab: CollabContent(snapshot: snapshot, family: family)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
if kind == .trip && snapshot.trip != nil {
|
||||
TripBackdrop(trip: snapshot.trip)
|
||||
} else {
|
||||
TrekPaper(contours: kind != .atlas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A home screen of the widgets on an iPhone-sized wallpaper.
|
||||
struct HomeScreen: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .trip, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: snapshot.today == nil ? .trip : .today, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .bookings, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .large, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .bookings, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock-screen accessories in a row per state.
|
||||
struct LockScreen: View {
|
||||
let snapshots: [WidgetSnapshot]
|
||||
let now: Date
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
ForEach(Array(snapshots.enumerated()), id: \.offset) { _, snapshot in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
WidgetFrame(kind: .trip, family: .inline, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .trip, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .circular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .trip, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
WidgetFrame(kind: .bookings, family: .rectangular, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .rectangular, snapshot: snapshot, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.background(Color(hex: 0x1E2A3A))
|
||||
}
|
||||
}
|
||||
|
||||
/// A mountain ridge across the bottom of its frame.
|
||||
struct Ridge: Shape {
|
||||
let height: CGFloat
|
||||
let seed: Int
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
let steps = 48
|
||||
for step in 0...steps {
|
||||
let x: CGFloat = rect.minX + rect.width * CGFloat(step) / CGFloat(steps)
|
||||
let phase: CGFloat = CGFloat(step) * 0.35 + CGFloat(seed)
|
||||
let wave: CGFloat = sin(phase) * 0.06 + sin(phase * 2.7) * 0.035
|
||||
let y: CGFloat = rect.maxY - rect.height * (height + wave)
|
||||
path.addLine(to: CGPoint(x: x, y: y))
|
||||
}
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// The packing, atlas and group widgets side by side, the sheet to judge them by.
|
||||
struct NewWidgets: View {
|
||||
let snapshot: WidgetSnapshot
|
||||
let now: Date
|
||||
@Environment(\.colorScheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .packing, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .atlas, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .atlas, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .medium, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .collab, family: .medium, snapshot: snapshot, now: now)
|
||||
HStack(spacing: 22) {
|
||||
WidgetFrame(kind: .collab, family: .small, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .todos, family: .small, snapshot: snapshot, now: now)
|
||||
}
|
||||
WidgetFrame(kind: .atlas, family: .large, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .packing, family: .large, snapshot: snapshot, now: now)
|
||||
WidgetFrame(kind: .collab, family: .large, snapshot: snapshot, now: now)
|
||||
}
|
||||
.padding(.vertical, 60)
|
||||
.padding(.horizontal, 24)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: scheme == .dark ? [Color(hex: 0x1D2440), Color(hex: 0x0B0F1E)] : [Color(hex: 0xC9D8F0), Color(hex: 0xF3E3D3)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Writes client/ios/App/TREKWidgets/WorldGrid.swift: the world as a grid of dots,
|
||||
each with the ISO code of the country under it, for the atlas widget.
|
||||
|
||||
Run from the repo root after the country shapes change:
|
||||
python client/ios/WidgetPreviews/make-world-grid.py
|
||||
Reads server/assets/atlas/admin0.geojson.gz, the same shapes the Atlas page draws.
|
||||
"""
|
||||
import gzip
|
||||
import json
|
||||
|
||||
COLS, ROWS = 76, 34
|
||||
LAT_TOP, LAT_BOTTOM = 80.0, -58.0 # Antarctica and the far north stay off the map
|
||||
SOURCE = 'server/assets/atlas/admin0.geojson.gz'
|
||||
TARGET = 'client/ios/App/TREKWidgets/WorldGrid.swift'
|
||||
|
||||
|
||||
def rings(geometry):
|
||||
if geometry['type'] == 'Polygon':
|
||||
yield geometry['coordinates']
|
||||
elif geometry['type'] == 'MultiPolygon':
|
||||
yield from geometry['coordinates']
|
||||
|
||||
|
||||
def inside(ring, x, y):
|
||||
hit = False
|
||||
j = len(ring) - 1
|
||||
for i in range(len(ring)):
|
||||
xi, yi = ring[i][0], ring[i][1]
|
||||
xj, yj = ring[j][0], ring[j][1]
|
||||
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi:
|
||||
hit = not hit
|
||||
j = i
|
||||
return hit
|
||||
|
||||
|
||||
def in_polygon(polygon, x, y):
|
||||
return inside(polygon[0], x, y) and not any(inside(hole, x, y) for hole in polygon[1:])
|
||||
|
||||
|
||||
features = []
|
||||
for feature in json.load(gzip.open(SOURCE))['features']:
|
||||
code = feature['properties'].get('ISO_A2') or ''
|
||||
if len(code) != 2 or not code.isalpha():
|
||||
continue
|
||||
for polygon in rings(feature['geometry']):
|
||||
xs = [p[0] for p in polygon[0]]
|
||||
ys = [p[1] for p in polygon[0]]
|
||||
features.append((code, (min(xs), min(ys), max(xs), max(ys)), polygon))
|
||||
|
||||
# Each cell is sampled at five points, so a narrow country still claims its dot.
|
||||
offsets = [(0.5, 0.5), (0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]
|
||||
cell_w = 360.0 / COLS
|
||||
cell_h = (LAT_TOP - LAT_BOTTOM) / ROWS
|
||||
rows = []
|
||||
for r in range(ROWS):
|
||||
row = []
|
||||
for c in range(COLS):
|
||||
votes = {}
|
||||
for ox, oy in offsets:
|
||||
x = -180 + (c + ox) * cell_w
|
||||
y = LAT_TOP - (r + oy) * cell_h
|
||||
for code, (x0, y0, x1, y1), polygon in features:
|
||||
if x0 <= x <= x1 and y0 <= y <= y1 and in_polygon(polygon, x, y):
|
||||
votes[code] = votes.get(code, 0) + 1
|
||||
break
|
||||
row.append(max(votes, key=votes.get) if votes else '..')
|
||||
rows.append(''.join(row))
|
||||
|
||||
land = sum(len(r) // 2 - r.count('..') for r in rows)
|
||||
body = '\n'.join(f' "{r}",' for r in rows)
|
||||
swift = f'''// Generated by client/ios/WidgetPreviews/make-world-grid.py from
|
||||
// server/assets/atlas/admin0.geojson.gz; do not edit by hand.
|
||||
import Foundation
|
||||
|
||||
/// The world as {COLS} by {ROWS} dots from {LAT_TOP:g}° north to {-LAT_BOTTOM:g}° south,
|
||||
/// each the ISO code of the country under it, ".." for sea ({land} land dots).
|
||||
enum WorldGrid {{
|
||||
static let columns = {COLS}
|
||||
static let rows: [String] = [
|
||||
{body}
|
||||
]
|
||||
|
||||
/// The country code of a dot, nil for sea.
|
||||
static func code(row: Int, column: Int) -> String? {{
|
||||
let line = rows[row]
|
||||
let start = line.index(line.startIndex, offsetBy: column * 2)
|
||||
let code = String(line[start..<line.index(start, offsetBy: 2)])
|
||||
return code == ".." ? nil : code
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
open(TARGET, 'w', encoding='utf8', newline='\n').write(swift)
|
||||
print('land dots', land)
|
||||
+127
-43
@@ -5,8 +5,8 @@ import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
|
||||
import { fakeIapClient, getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL, STORE_TERMS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
import { TrekShell } from '../src/native/trekShell';
|
||||
import { pickLocale, translator } from './i18n';
|
||||
@@ -28,8 +28,12 @@ const root = document.getElementById('shell')!;
|
||||
const PROBE_TIMEOUT_MS = 8000;
|
||||
|
||||
const iap = getIapClient();
|
||||
/** Whether this build can take a payment in-app; decided once at start. Until then, and in a store build without the purchase plugin, the plan picker finishes on the web. */
|
||||
let iapAvailable = false;
|
||||
/**
|
||||
* Whether this build can take a payment in-app, asked once at start. Without it
|
||||
* the plan picker shows the plans but offers no way to buy: the store rules
|
||||
* allow no link or button that sends a buyer to the website instead.
|
||||
*/
|
||||
const iapReady: Promise<boolean> = iap.isAvailable().catch(() => false);
|
||||
|
||||
/** The subdomain suffix a cloud instance sits on, from the cloud address: `liketrek.com` → `.liketrek.com`. */
|
||||
function cloudSuffix(): string {
|
||||
@@ -359,34 +363,54 @@ async function fetchPlans(): Promise<CloudPlan[] | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
|
||||
void tap(ImpactStyle.Medium);
|
||||
// In-app purchase when this build can take one; otherwise finish on the web, so
|
||||
// nothing half-wired faces a customer until the store products exist.
|
||||
if (iapAvailable) {
|
||||
showClaim(plan, interval);
|
||||
} else {
|
||||
window.open(`${CLOUD_URL}/order?plan=${encodeURIComponent(plan.id)}`, '_blank', 'noopener');
|
||||
}
|
||||
/**
|
||||
* What the plan picker can sell. With the store, a plan is offered at the price
|
||||
* string the App Store gives for its product, in the buyer's currency, and only
|
||||
* when the store knows that product. The fake (development, preview) sells
|
||||
* every plan at the plan list's price. Without either, nothing is for sale.
|
||||
*/
|
||||
interface Offer {
|
||||
canBuy: boolean;
|
||||
storePrices: Map<string, string> | null;
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean): HTMLElement {
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
function offerFor(offer: Offer, plan: CloudPlan, interval: 'month' | 'year'): { label: string; buyable: boolean } {
|
||||
const cents = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
const storePrice = offer.storePrices?.get(productIdFor(plan.id, interval));
|
||||
if (offer.storePrices) return { label: storePrice ?? money(cents), buyable: offer.canBuy && !!storePrice };
|
||||
return { label: money(cents), buyable: offer.canBuy };
|
||||
}
|
||||
|
||||
async function loadOffer(plans: CloudPlan[]): Promise<Offer> {
|
||||
const canBuy = await iapReady;
|
||||
if (!canBuy || iap === fakeIapClient) return { canBuy, storePrices: null };
|
||||
const ids = plans.flatMap((p) => [productIdFor(p.id, 'month'), productIdFor(p.id, 'year')]);
|
||||
return { canBuy, storePrices: await iap.prices(ids) };
|
||||
}
|
||||
|
||||
function planCard(plan: CloudPlan, interval: 'month' | 'year', offer: Offer): HTMLElement {
|
||||
const { label, buyable } = offerFor(offer, plan, interval);
|
||||
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
|
||||
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
|
||||
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
|
||||
// green tick, so "none" never reads as a perk.
|
||||
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
|
||||
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => subscribe(plan, interval));
|
||||
let sub: HTMLElement | null = null;
|
||||
if (buyable) {
|
||||
sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => {
|
||||
void tap(ImpactStyle.Medium);
|
||||
showClaim(plan, interval, label);
|
||||
});
|
||||
}
|
||||
return el(
|
||||
'div',
|
||||
{ className: featured ? 'plan featured' : 'plan' },
|
||||
{ className: 'plan' },
|
||||
el(
|
||||
'div',
|
||||
{ className: 'plan-top' },
|
||||
el('span', { className: 'plan-name' }, plan.name),
|
||||
el('span', { className: 'plan-price' }, money(price), el('small', {}, per))
|
||||
el('span', { className: 'plan-price' }, label, el('small', {}, per))
|
||||
),
|
||||
el(
|
||||
'div',
|
||||
@@ -397,15 +421,65 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
|
||||
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
|
||||
: featOff(t('native.plans.noAi'))
|
||||
),
|
||||
sub
|
||||
...(sub ? [sub] : [])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Below the plans. With a store: how the subscription renews, the terms and the
|
||||
* privacy policy, and a way to restore a subscription this Apple ID already
|
||||
* holds (a reinstall, a new phone). Without one: only that buying in the app is
|
||||
* not available, and no pointer elsewhere.
|
||||
*/
|
||||
function planFooter(offer: Offer): Node[] {
|
||||
if (!offer.canBuy) return [el('p', { className: 'note' }, t('native.plans.soon'))];
|
||||
|
||||
const status = el('p', { className: 'hint', role: 'status' });
|
||||
const restore = el('button', { type: 'button', className: 'btn btn-ghost' }, t('native.plans.restore'));
|
||||
restore.addEventListener('click', async () => {
|
||||
void tap();
|
||||
restore.disabled = true;
|
||||
status.className = 'hint';
|
||||
status.textContent = t('native.plans.restoring');
|
||||
const purchase = await iap.restore();
|
||||
if (!purchase) {
|
||||
restore.disabled = false;
|
||||
status.textContent = t('native.plans.restoreNone');
|
||||
return;
|
||||
}
|
||||
// A restore finds the instance the purchase was first redeemed for; address and email are not asked again.
|
||||
const redeemed = await redeemPurchase('', '', purchase);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(purchase);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
return;
|
||||
}
|
||||
void notify(NotificationType.Error);
|
||||
restore.disabled = false;
|
||||
status.className = 'hint bad';
|
||||
status.textContent = t('native.plans.restoreFailed');
|
||||
});
|
||||
|
||||
return [
|
||||
restore,
|
||||
status,
|
||||
el('p', { className: 'note' }, t('native.plans.legal')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'foot-links' },
|
||||
externalLink(t('native.plans.terms'), STORE_TERMS_URL),
|
||||
externalLink(t('native.privacy'), PRIVACY_URL)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function showPlans(opts: { back?: boolean } = {}): void {
|
||||
let interval: 'month' | 'year' = 'month';
|
||||
let plans: CloudPlan[] | null = null;
|
||||
let offer: Offer = { canBuy: false, storePrices: null };
|
||||
|
||||
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
|
||||
const footer = el('div', { className: 'plans-foot' });
|
||||
|
||||
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
|
||||
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
|
||||
@@ -419,7 +493,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
const list = plans;
|
||||
if (!list) return;
|
||||
content.className = 'plans';
|
||||
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
|
||||
content.replaceChildren(...list.map((p) => planCard(p, interval, offer)));
|
||||
};
|
||||
const choose = (next: 'month' | 'year') => {
|
||||
if (interval === next) return;
|
||||
@@ -433,9 +507,12 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
paintSeg();
|
||||
|
||||
const load = async () => {
|
||||
plans = await fetchPlans();
|
||||
if (plans) {
|
||||
const list = await fetchPlans();
|
||||
if (list) {
|
||||
offer = await loadOffer(list);
|
||||
plans = list;
|
||||
renderList();
|
||||
footer.replaceChildren(...planFooter(offer));
|
||||
return;
|
||||
}
|
||||
content.className = '';
|
||||
@@ -458,7 +535,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
el('p', { className: 'sub' }, t('native.plans.sub')),
|
||||
seg,
|
||||
content,
|
||||
el('p', { className: 'note' }, t('native.plans.soon'))
|
||||
footer
|
||||
),
|
||||
opts
|
||||
);
|
||||
@@ -507,8 +584,9 @@ async function redeemPurchase(slug: string, email: string, purchase: Purchase):
|
||||
}
|
||||
}
|
||||
|
||||
function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: boolean } = {}): void {
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
function showClaim(plan: CloudPlan, interval: 'month' | 'year', price: string, opts: { back?: boolean } = {}): void {
|
||||
/** A purchase that went through but whose redeem did not: a retry redeems it again instead of buying twice. */
|
||||
let bought: Purchase | null = null;
|
||||
|
||||
const slugInput = el('input', {
|
||||
type: 'text',
|
||||
@@ -546,7 +624,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
const submit = el(
|
||||
'button',
|
||||
{ type: 'submit', className: 'btn' },
|
||||
t('native.claim.subscribe', { price: money(price) })
|
||||
t('native.claim.subscribe', { price })
|
||||
);
|
||||
const error = el('p', { className: 'error', role: 'alert' });
|
||||
const form = el(
|
||||
@@ -617,24 +695,33 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
submit.replaceChildren(el('span', { className: 'spinner' }), t('native.connect.checking'));
|
||||
const restore = () => {
|
||||
lock(false);
|
||||
submit.replaceChildren(t('native.claim.subscribe', { price: money(price) }));
|
||||
submit.replaceChildren(t('native.claim.subscribe', { price }));
|
||||
};
|
||||
|
||||
const outcome = await iap.purchase(productIdFor(plan.id, interval));
|
||||
if (outcome.kind === 'cancelled') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.cancelled');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
void notify(NotificationType.Error);
|
||||
restore();
|
||||
error.textContent = t('native.claim.failed');
|
||||
return;
|
||||
if (!bought) {
|
||||
const outcome = await iap.purchase(productIdFor(plan.id, interval));
|
||||
if (outcome.kind === 'cancelled') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.cancelled');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'pending') {
|
||||
restore();
|
||||
error.textContent = t('native.claim.pending');
|
||||
return;
|
||||
}
|
||||
if (outcome.kind === 'failed') {
|
||||
void notify(NotificationType.Error);
|
||||
restore();
|
||||
error.textContent = t('native.claim.failed');
|
||||
return;
|
||||
}
|
||||
bought = outcome.purchase;
|
||||
}
|
||||
|
||||
const redeemed = await redeemPurchase(slug, email, outcome.purchase);
|
||||
const redeemed = await redeemPurchase(slug, email, bought);
|
||||
if (redeemed.ok && redeemed.instanceUrl) {
|
||||
await iap.finish(bought);
|
||||
showProvisioning(redeemed.instanceUrl);
|
||||
return;
|
||||
}
|
||||
@@ -808,9 +895,6 @@ async function paintStatusBar(): Promise<void> {
|
||||
|
||||
async function start(): Promise<void> {
|
||||
void paintStatusBar();
|
||||
iap.isAvailable().then((ok) => {
|
||||
iapAvailable = ok;
|
||||
});
|
||||
let url: string | null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
|
||||
@@ -881,6 +881,20 @@ a.linkbtn {
|
||||
margin: 2px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
/* Under the plans: restore, the renewal terms, the legal links. */
|
||||
.plans-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.plans-foot .hint {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
.plans-foot .hint:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
|
||||
/* One rounded field holding the address input and its fixed ".liketrek.com"
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 377,
|
||||
"@typescript-eslint/no-non-null-assertion": 375,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
"@typescript-eslint/no-explicit-any": 144,
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"max-params": 31,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 553,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"react-hooks/set-state-in-effect": 192,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 115,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/exhaustive-deps": 114,
|
||||
"@typescript-eslint/no-unused-vars": 123,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 50,
|
||||
@@ -21,7 +21,7 @@
|
||||
"no-empty": 19,
|
||||
"trek/no-window-globals": 41,
|
||||
"react-refresh/only-export-components": 44,
|
||||
"unused-disable-directive": 6,
|
||||
"unused-disable-directive": 5,
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1914,
|
||||
"@typescript-eslint/no-non-null-assertion": 1912,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -620,8 +620,7 @@
|
||||
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -670,6 +669,12 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Demo welcome",
|
||||
"hooks": ["src/components/Demo/useDemoWelcome.ts"],
|
||||
"desktop": ["src/components/Demo/DemoWelcome.tsx"],
|
||||
"mobile": ["src/mobile/screens/dashboard/MDemoWelcome.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
@@ -1002,6 +1007,7 @@
|
||||
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
|
||||
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
|
||||
"src/mobile/screens/journey/MDawarichDaySheet.tsx": "Markup only; the phone frame the Dawarich day sheets (route and stays) render their shared review in.",
|
||||
"src/mobile/screens/settings/MAppLockCard.tsx": "Phone only: the app lock exists only in the native app, so there is no desktop setting to share with.",
|
||||
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
|
||||
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
|
||||
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
|
||||
@@ -1010,17 +1016,18 @@
|
||||
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
|
||||
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
|
||||
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
|
||||
"src/mobile/screens/settings/MTileCacheUsage.tsx": "Phone only: the native tile cache size and clear action, which the browser build has no counterpart for.",
|
||||
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
|
||||
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
|
||||
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
|
||||
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
|
||||
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
|
||||
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
|
||||
"src/mobile/screens/trip/plan/MRouteAppsSheet.tsx": "Markup only; the route apps come from the shared components/Planner/dayRouteApps the desktop menu uses too.",
|
||||
"src/mobile/screens/trip/plan/openRouteTarget.ts": "Phone only helper that opens a route target: a web link in a new tab, geo: in place.",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
|
||||
@@ -1039,10 +1046,12 @@
|
||||
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
|
||||
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
|
||||
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
|
||||
"src/mobile/screens/trip/sheets/MTourDetailShell.tsx": "Markup only phone frame for the shared TourDetailDialog, which renders its own content into it.",
|
||||
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
|
||||
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
|
||||
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
|
||||
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
|
||||
"src/mobile/screens/trip/sheets/tourSheetOpen.ts": "Phone only context for MTourDetailShell: whether the tour sheet is open and the name it carries.",
|
||||
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
|
||||
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
|
||||
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
|
||||
|
||||
@@ -1351,11 +1351,9 @@
|
||||
"src/mobile/screens/trip/dockTabs.ts",
|
||||
"src/mobile/screens/trip/lib/dayNotes.ts",
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx",
|
||||
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx",
|
||||
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx",
|
||||
"src/mobile/screens/trip/places/MToursSelectionList.test.tsx",
|
||||
@@ -1366,8 +1364,6 @@
|
||||
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.test.ts",
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.test.tsx",
|
||||
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.test.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts",
|
||||
"src/mobile/screens/trip/plan/useMPlanTimeline.ts",
|
||||
@@ -1982,7 +1978,6 @@
|
||||
"tests/unit/mobile/trip/MPlaceSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesBrowser.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesBulkCategorySheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesFilterSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlacesSaveToCollectionSheet.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlanTimeline.test.tsx",
|
||||
"tests/unit/mobile/trip/MPlanTimelineRows.test.tsx",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"collab.ts": 9,
|
||||
"collection.ts": 35,
|
||||
"common.ts": 7,
|
||||
"dashboard.ts": 65,
|
||||
"dashboard.ts": 64,
|
||||
"dawarich.ts": 15,
|
||||
"dayplan.ts": 17,
|
||||
"docsync.ts": 25,
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"help/contexts/tripMap.ts": 1,
|
||||
"hooks/useAnchoredPosition.ts": 3,
|
||||
"hooks/useElementRect.ts": 3,
|
||||
"mobile/components/MSegmented.tsx": 3,
|
||||
"mobile/components/MSegmented.tsx": 1,
|
||||
"mobile/components/MSheet.tsx": 1,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 1,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 3,
|
||||
|
||||
@@ -4,5 +4,7 @@
|
||||
"components/Studio/StudioSelectionChrome.tsx": 2,
|
||||
"components/Studio/inspector/FocalPad.tsx": 1,
|
||||
"mobile/components/trek3d/MascotClip.tsx": 1,
|
||||
"mobile/screens/trip/MTripShell.tsx": 1,
|
||||
"mobile/screens/vacay/MVacay.tsx": 1,
|
||||
"pages/tripPlanner/PlanMapStage.tsx": 4
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1214,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2183,
|
||||
"src/components/Map/MapViewGL.tsx": 2127,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
@@ -16,7 +16,7 @@
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1347,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"src/mobile/screens/trip/map/MMapArea.tsx": 1,
|
||||
"src/mobile/screens/trip/places/MPlacesBrowser.tsx": 2,
|
||||
"src/mobile/screens/trip/plan/planTimelineModel.ts": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 2,
|
||||
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": 1,
|
||||
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": 2,
|
||||
|
||||
@@ -166,31 +166,31 @@
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 7,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 5,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
|
||||
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 6,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 6,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
|
||||
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
|
||||
"mobile/screens/notifications/MNotificationRow.tsx": 4,
|
||||
"mobile/screens/notifications/MNotifications.tsx": 3,
|
||||
@@ -201,13 +201,13 @@
|
||||
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 26,
|
||||
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsOffline.tsx": 18,
|
||||
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
|
||||
@@ -216,9 +216,9 @@
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
|
||||
@@ -235,8 +235,8 @@
|
||||
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
|
||||
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
|
||||
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
|
||||
@@ -251,11 +251,10 @@
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
@@ -280,10 +279,10 @@
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 17,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
"pages/DashboardPage.tsx": 1,
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import NewVersionNotice from './components/Layout/NewVersionNotice'
|
||||
import NativeUpdateNotice from './components/Layout/NativeUpdateNotice'
|
||||
import AppLockGate from './components/Layout/AppLockGate'
|
||||
import NativeImportSheet from './components/Layout/NativeImportSheet'
|
||||
import NativeImportSheet from './mobile/components/NativeImportSheet'
|
||||
import NativeWidgetsSync from './components/Layout/NativeWidgetsSync'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
import HelpPanel from './components/Help/HelpPanel'
|
||||
|
||||
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
|
||||
}))
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow }))
|
||||
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('../native/platform', () => ({ isNativeApp }))
|
||||
|
||||
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
|
||||
|
||||
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
|
||||
isNativeApp.mockReturnValue(true)
|
||||
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
expect(loc.href).not.toContain('/login')
|
||||
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
|
||||
|
||||
delete (document as { readyState?: unknown }).readyState
|
||||
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
|
||||
isNativeApp.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
|
||||
|
||||
@@ -70,13 +70,13 @@ import {
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
RoadtripViaReanchorRequest, RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
import { translateRateLimit } from './rateLimitMessage'
|
||||
import { hardNavigate } from './hardNavigate'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -218,7 +218,7 @@ apiClient.interceptors.response.use(
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -226,7 +226,7 @@ apiClient.interceptors.response.use(
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
hardNavigate('/settings?mfa=required')
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isNativeApp } from '../native/platform';
|
||||
|
||||
/**
|
||||
* A full page load to `url`. In the apps it waits for the first page to finish
|
||||
* loading: started earlier, it cancels that load, and the shell answers a
|
||||
* cancelled load with its "Server not reachable" page, which is where a
|
||||
* signed-out user opening the app for the first time ended up.
|
||||
*/
|
||||
export function hardNavigate(url: string): void {
|
||||
const go = () => {
|
||||
window.location.href = url;
|
||||
};
|
||||
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true });
|
||||
else go();
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -84,10 +84,12 @@ describe('JourneyEntryCover', () => {
|
||||
const { container, rerender } = render(
|
||||
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[136px]')
|
||||
expect(container.querySelector('button')!.className).toContain('scale-100')
|
||||
|
||||
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[164px]')
|
||||
// Drawn larger, not laid out larger: the box stays, so nothing beside it moves.
|
||||
expect(container.querySelector('button')?.className).toContain('scale-[1.18]')
|
||||
expect(container.querySelector('button')?.className).toContain('w-[136px]')
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
|
||||
|
||||
@@ -103,8 +103,13 @@ export default function JourneyEntryCover({
|
||||
// The whole card is the photo, so its own ring is what separates it from the
|
||||
// map behind. A suggestion is drawn as an outline rather than a solid thing:
|
||||
// it is somewhere you planned to be, not somewhere you have written about.
|
||||
className={`relative flex-none overflow-hidden rounded-[18px] text-start transition-[width,height] duration-150 ${
|
||||
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
//
|
||||
// Every card takes the same room and the active one is only drawn larger:
|
||||
// growing its box moved every card after it once a swipe had landed, and the
|
||||
// snap then pulled the strip again, which read as a jolt (carousels keep the
|
||||
// room for it with their gap and top padding).
|
||||
className={`relative h-[152px] w-[136px] flex-none origin-bottom overflow-hidden rounded-[18px] text-start transition-[transform,box-shadow] duration-200 ease-[cubic-bezier(.2,.8,.2,1)] motion-reduce:transition-none ${
|
||||
isActive ? 'z-[1] scale-[1.18] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'scale-100 shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
|
||||
// backgroundImage, not background: the shorthand would drop the opaque colour
|
||||
// the class supplies and leave the map showing through a card that is only a
|
||||
|
||||
@@ -691,7 +691,13 @@ function JourneyMap(
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{/* A finger zooms with a pinch, and on a phone these sat under the bottom bar (#3001). */}
|
||||
<div
|
||||
// The display lives in the classes, not the style: an inline display beat the
|
||||
// hidden class and left the buttons on phones.
|
||||
className="flex flex-col gap-1 [@media(pointer:coarse)]:hidden"
|
||||
style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400 }}
|
||||
>
|
||||
<button type="button" onClick={() => zoom(true)} style={zoomButtonStyle(dark)}>+</button>
|
||||
<button type="button" onClick={() => zoom(false)} style={zoomButtonStyle(dark)}>−</button>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('MobileMapTimeline', () => {
|
||||
await userEvent.click(segments[1])
|
||||
|
||||
const { cards } = carouselOf(container)
|
||||
expect(cards[1].querySelector('button')!.className).toContain('w-[164px]')
|
||||
expect(cards[1].querySelector('button')!.className).toContain('scale-[1.18]')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPTL-003: the first entry starts out active and drives the map highlight', () => {
|
||||
@@ -285,13 +285,14 @@ describe('MobileMapTimeline', () => {
|
||||
carousel.getBoundingClientRect = () => fakeRect(0, 300)
|
||||
cards.forEach((c, i) => { c.getBoundingClientRect = () => fakeRect(i === 2 ? 30 : -400, 240) })
|
||||
|
||||
// The strip counts as landed 90 ms after its last scroll event.
|
||||
fireEvent.scroll(carousel)
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
act(() => { vi.advanceTimersByTime(60) })
|
||||
fireEvent.scroll(carousel)
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
act(() => { vi.advanceTimersByTime(60) })
|
||||
expect(lastMapProps.current.activeMarkerId).toBe('1')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
act(() => { vi.advanceTimersByTime(40) })
|
||||
expect(lastMapProps.current.activeMarkerId).toBe('3')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function MobileMapTimeline({
|
||||
controlsTop,
|
||||
}: Props) {
|
||||
const {
|
||||
mapRef, carouselRef, cardRefs, activeIndex, setActiveIndex, scrubberDays,
|
||||
mapRef, carouselRef, cardRefs, activeIndex, focusIndex, setActiveIndex, scrubberDays,
|
||||
syncMapToCard: syncMapToCarousel, handleMarkerClick, handleCardTap, openEntryById, jumpToDay,
|
||||
} = useJourneyCarouselSync({ entries, mapEntries, onOpenEntry: onEntryClick })
|
||||
// The delayed initial focus reads both through refs so it always works off
|
||||
@@ -108,8 +108,8 @@ export default function MobileMapTimeline({
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
className="fixed inset-x-0 bottom-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)' }}
|
||||
>
|
||||
<JourneyLayeredMap
|
||||
ref={mapRef}
|
||||
@@ -147,8 +147,10 @@ export default function MobileMapTimeline({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-x-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)', bottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
// Down to the very edge: Safari draws its toolbar over the page, and a map
|
||||
// that stopped at the safe area left a bare strip under it (#3002).
|
||||
className="fixed inset-x-0 bottom-0 z-10"
|
||||
style={{ top: 'var(--nav-h, 0px)' }}
|
||||
>
|
||||
{/* Full-screen map */}
|
||||
<JourneyLayeredMap
|
||||
@@ -187,7 +189,7 @@ export default function MobileMapTimeline({
|
||||
/>
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="flex items-end gap-[10px] overflow-x-auto px-4 pb-3 pt-1"
|
||||
className="flex items-end gap-[22px] overflow-x-auto px-6 pb-3 pt-[30px]"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
@@ -205,7 +207,7 @@ export default function MobileMapTimeline({
|
||||
<JourneyEntryCover
|
||||
entry={entry}
|
||||
dayColor={dayColorOf(scrubberDays, entry.entry_date)}
|
||||
isActive={i === activeIndex}
|
||||
isActive={i === focusIndex}
|
||||
onClick={() => handleCardTap(entry, i)}
|
||||
showMood={showMood}
|
||||
showWeather={showWeather}
|
||||
|
||||
@@ -127,9 +127,9 @@ describe('useJourneyCarouselSync', () => {
|
||||
});
|
||||
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
act(() => vi.advanceTimersByTime(60));
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => vi.advanceTimersByTime(149));
|
||||
act(() => vi.advanceTimersByTime(89));
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
// Centre 250: card 1 spans 200 to 300.
|
||||
@@ -170,7 +170,7 @@ describe('useJourneyCarouselSync', () => {
|
||||
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
vi.advanceTimersByTime(90);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,42 @@ export interface JourneyCarouselSyncOptions<E extends CarouselEntry> {
|
||||
syncMapOnTap?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The card closest to the strip's horizontal centre. More stable than
|
||||
* IntersectionObserver thresholds when the active card can drift toward the
|
||||
* viewport edge with proximity snapping.
|
||||
*/
|
||||
function nearestCardIndex(el: HTMLDivElement | null, cards: Map<number, HTMLDivElement>): number | null {
|
||||
if (!el) return null;
|
||||
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
cards.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
const d = Math.abs(r.left + r.width / 2 - center);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
// The first and the last card can never reach the centre, so a carousel scrolled
|
||||
// as far as it goes means the card at that end: without this, opening on the
|
||||
// latest day settled on the one before it (#2851). Measured from the start in
|
||||
// either direction: in a right-to-left layout scrollLeft runs negative.
|
||||
const travel = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
if (travel > 1 && scrolled >= travel - 1) bestIdx = cards.size - 1;
|
||||
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the strip has to be still before the swipe counts as landed: long
|
||||
* enough to see past the gaps between scroll events of a momentum swipe, short
|
||||
* enough that the map follows the card without a noticeable wait.
|
||||
*/
|
||||
const SETTLE_MS = 90;
|
||||
|
||||
/**
|
||||
* The card carousel over the journey map, behind both the phone journey screen and the
|
||||
* narrow desktop and public timeline: the active card is the one nearest the centre
|
||||
@@ -35,6 +71,10 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
// The card nearest the centre while a swipe is still moving: drawn larger at
|
||||
// once (a transform, so nothing reflows), while the active card and the map
|
||||
// only follow once the strip settles. Null when not swiping.
|
||||
const [liveIndex, setLiveIndex] = useState<number | null>(null);
|
||||
|
||||
const scrubberDays = useMemo(() => journeyDays(entries), [entries]);
|
||||
|
||||
@@ -54,36 +94,17 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
[entries, mapEntries]
|
||||
);
|
||||
|
||||
// Pick the card closest to the horizontal centre. More stable than
|
||||
// IntersectionObserver thresholds when the active card can drift toward the
|
||||
// viewport edge with proximity snapping.
|
||||
const nearestCard = useCallback(() => nearestCardIndex(carouselRef.current, cardRefs.current), []);
|
||||
|
||||
const pickNearestCard = useCallback(() => {
|
||||
const el = carouselRef.current;
|
||||
if (!el) return;
|
||||
const center = el.getBoundingClientRect().left + el.clientWidth / 2;
|
||||
let bestIdx = 0;
|
||||
let bestDist = Infinity;
|
||||
cardRefs.current.forEach((node, idx) => {
|
||||
const r = node.getBoundingClientRect();
|
||||
const d = Math.abs(r.left + r.width / 2 - center);
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
// The first and the last card can never reach the centre, so a carousel scrolled
|
||||
// as far as it goes means the card at that end: without this, opening on the
|
||||
// latest day settled on the one before it (#2851). Measured from the start in
|
||||
// either direction: in a right-to-left layout scrollLeft runs negative.
|
||||
const travel = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
|
||||
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
|
||||
const bestIdx = nearestCard();
|
||||
if (bestIdx == null) return;
|
||||
setLiveIndex(null);
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== bestIdx) syncMapToCard(bestIdx);
|
||||
return bestIdx;
|
||||
});
|
||||
}, [syncMapToCard]);
|
||||
}, [nearestCard, syncMapToCard]);
|
||||
|
||||
// Defer every state update until scrolling settles: updating the active card
|
||||
// mid swipe resizes the cards and reflows the layout on every frame.
|
||||
@@ -91,16 +112,33 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
const el = carouselRef.current;
|
||||
if (!el || entries.length === 0) return;
|
||||
let settleTimer: number | null = null;
|
||||
let frame: number | null = null;
|
||||
const onScroll = () => {
|
||||
if (frame == null) {
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
setLiveIndex(nearestCard());
|
||||
});
|
||||
}
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = window.setTimeout(pickNearestCard, 150);
|
||||
settleTimer = window.setTimeout(pickNearestCard, SETTLE_MS);
|
||||
};
|
||||
// Where the browser says when the snap has landed, the card grows right then
|
||||
// instead of a beat later; the timer stays for the ones that do not.
|
||||
const onScrollEnd = () => {
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
pickNearestCard();
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
el.addEventListener('scrollend', onScrollEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
el.removeEventListener('scrollend', onScrollEnd);
|
||||
if (settleTimer != null) window.clearTimeout(settleTimer);
|
||||
if (frame != null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [entries.length, pickNearestCard]);
|
||||
}, [entries.length, pickNearestCard, nearestCard]);
|
||||
|
||||
const scrollCardIntoCenter = useCallback((idx: number) => {
|
||||
cardRefs.current.get(idx)?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
@@ -159,6 +197,8 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
carouselRef,
|
||||
cardRefs,
|
||||
activeIndex,
|
||||
/** The card to draw as the active one: the one at the centre while swiping, else the active card. */
|
||||
focusIndex: liveIndex ?? activeIndex,
|
||||
setActiveIndex,
|
||||
scrubberDays,
|
||||
syncMapToCard,
|
||||
|
||||
@@ -281,6 +281,8 @@ describe('useJourneyEntryForm', () => {
|
||||
vi.useFakeTimers();
|
||||
const places = Array.from({ length: 8 }, (_, i) => ({ name: `P${i}`, lat: String(i), lng: '1' }));
|
||||
const search = vi.spyOn(mapsApi, 'search').mockResolvedValue({ places } as never);
|
||||
// No suggestions: the full search answers.
|
||||
vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({ suggestions: [], source: 'none' } as never);
|
||||
const { result } = setup();
|
||||
act(() => result.current.searchLocation('R'));
|
||||
expect(result.current.showLocationResults).toBe(true);
|
||||
@@ -306,6 +308,35 @@ describe('useJourneyEntryForm', () => {
|
||||
expect(result.current.showLocationResults).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-JRN-ENTRYFORM-013b: suggestions come first, as in the planner, and a pick fetches its point (#3003)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const suggest = vi.spyOn(mapsApi, 'autocomplete').mockResolvedValue({
|
||||
suggestions: [{ placeId: 'g1', mainText: 'Hotel Keihan Temmabashi', secondaryText: 'Osaka, Japan' }],
|
||||
source: 'google',
|
||||
} as never);
|
||||
const search = vi.spyOn(mapsApi, 'search');
|
||||
const details = vi.spyOn(mapsApi, 'details').mockResolvedValue({ place: { lat: 34.69, lng: 135.51 } } as never);
|
||||
const { result } = setup();
|
||||
act(() => result.current.searchLocation('Hotel Keihan'));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(suggest).toHaveBeenCalledWith('Hotel Keihan', 'en', undefined, expect.any(AbortSignal), expect.any(String));
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(result.current.locationResults).toEqual([
|
||||
{ name: 'Hotel Keihan Temmabashi', address: 'Osaka, Japan', lat: null, lng: null, placeId: 'g1' },
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
result.current.pickLocation(result.current.locationResults[0]);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(details).toHaveBeenCalledWith('g1', 'en', expect.any(String));
|
||||
expect(result.current.locationName).toBe('Hotel Keihan Temmabashi');
|
||||
expect(result.current.locationLat).toBe(34.69);
|
||||
expect(result.current.locationLng).toBe(135.51);
|
||||
});
|
||||
|
||||
it('FE-JRN-ENTRYFORM-014: "use my location" fills the point, then the place name', async () => {
|
||||
locate.mockResolvedValue({ lat: 1.234567, lng: 2.345678 });
|
||||
vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Here' } as never);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { MapsAutocompleteResult } from '@trek/shared';
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { journeyApi, mapsApi, weatherApi } from '../../api/client';
|
||||
import { getCurrentPositionOnce } from '../../hooks/useGeolocation';
|
||||
import { usePlaceLanguage } from '../../hooks/usePlaceLanguage';
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic';
|
||||
import { localIsoDate } from '../../utils/localDate';
|
||||
import { PlacesSession } from '../../utils/placesSession';
|
||||
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import type { ProviderPhotoGroup } from './JourneyDetailPageProviderPicker';
|
||||
@@ -22,8 +25,10 @@ export type PendingProviderGroup = ProviderPhotoGroup & { provider: string };
|
||||
export interface LocationResult {
|
||||
name: string;
|
||||
address?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Null for a suggestion while typing: its point comes with the details when it is picked. */
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
placeId?: string;
|
||||
}
|
||||
|
||||
export interface JourneyEntryFormOptions {
|
||||
@@ -104,6 +109,11 @@ export function useJourneyEntryForm({
|
||||
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Counts location lookups, so an answer that comes back after the field moved on is dropped.
|
||||
const locationSearchSeqRef = useRef(0);
|
||||
// The planner's typed-ahead search (usePlaceSuggestions): names and their place in the
|
||||
// user's language as the provider has them, billed as one session per search (#3003).
|
||||
const { autocomplete } = usePlaceSuggestions();
|
||||
const placesSessionRef = useRef(new PlacesSession());
|
||||
const suggestAbortRef = useRef<AbortController | null>(null);
|
||||
const [mood, setMood] = useState(entry.mood || '');
|
||||
const [weather, setWeather] = useState(entry.weather || '');
|
||||
// The icon the form filled in by itself (see the weather effect below). It is a
|
||||
@@ -456,7 +466,33 @@ export function useJourneyEntryForm({
|
||||
}
|
||||
locationTimerRef.current = setTimeout(async () => {
|
||||
setLocationSearching(true);
|
||||
suggestAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
suggestAbortRef.current = controller;
|
||||
try {
|
||||
// Suggestions first, as the planner does; the full search when they bring nothing.
|
||||
const suggested = await autocomplete(
|
||||
query,
|
||||
placeLang,
|
||||
undefined,
|
||||
controller.signal,
|
||||
placesSessionRef.current.current()
|
||||
)
|
||||
.then((r): MapsAutocompleteResult['suggestions'] => r.suggestions ?? [])
|
||||
.catch((): MapsAutocompleteResult['suggestions'] => []);
|
||||
if (seq !== locationSearchSeqRef.current) return;
|
||||
if (suggested.length > 0) {
|
||||
setLocationResults(
|
||||
suggested.slice(0, 6).map((sg) => ({
|
||||
name: sg.mainText,
|
||||
address: sg.secondaryText || undefined,
|
||||
lat: sg.lat ?? null,
|
||||
lng: sg.lng ?? null,
|
||||
placeId: sg.placeId,
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
const res = await mapsApi.search(query, placeLang);
|
||||
if (seq !== locationSearchSeqRef.current) return;
|
||||
setLocationResults(
|
||||
@@ -478,7 +514,7 @@ export function useJourneyEntryForm({
|
||||
};
|
||||
|
||||
const pickLocation = (r: LocationResult) => {
|
||||
locationSearchSeqRef.current++;
|
||||
const seq = ++locationSearchSeqRef.current;
|
||||
setLocationSearching(false);
|
||||
setLocationName(r.name);
|
||||
setLocationLat(r.lat);
|
||||
@@ -486,6 +522,26 @@ export function useJourneyEntryForm({
|
||||
setLocationQuery('');
|
||||
setShowLocationResults(false);
|
||||
setLocationResults([]);
|
||||
if (r.lat != null && r.lng != null) {
|
||||
placesSessionRef.current.end();
|
||||
return;
|
||||
}
|
||||
// A suggestion names the place; its point comes with the details, which close the session.
|
||||
if (!r.placeId) return;
|
||||
void mapsApi
|
||||
.details(r.placeId, placeLang, placesSessionRef.current.peek())
|
||||
.then((res) => {
|
||||
if (seq !== locationSearchSeqRef.current) return;
|
||||
const place = res.place;
|
||||
if (place?.lat == null || place?.lng == null) return;
|
||||
setLocationLat(Number(place.lat));
|
||||
setLocationLng(Number(place.lng));
|
||||
})
|
||||
.catch(() => {
|
||||
// The name stays without a point, as a typed one would; the map simply has no pin for it.
|
||||
if (seq === locationSearchSeqRef.current) setLocationLat(null);
|
||||
})
|
||||
.finally(() => placesSessionRef.current.end());
|
||||
};
|
||||
|
||||
const handleUseCurrentLocation = async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock('../../native/appLock', () => ({
|
||||
}));
|
||||
|
||||
function stateListener(): (state: { isActive: boolean }) => void {
|
||||
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => name === 'appStateChange');
|
||||
const call = vi.mocked(App.addListener).mock.calls.find(([name]) => (name as string) === 'appStateChange');
|
||||
if (!call) throw new Error('no appStateChange listener');
|
||||
return call[1] as unknown as (state: { isActive: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -28,11 +28,16 @@ export default function AppLockGate(): React.ReactElement | null {
|
||||
if (ok) setLocked(false);
|
||||
}, [t]);
|
||||
|
||||
// At launch, once the capabilities are known.
|
||||
// At launch, once the capabilities are known: locked in the same render the
|
||||
// capabilities arrive in, so nothing of the app shows before the prompt.
|
||||
const [capsSeen, setCapsSeen] = useState<typeof caps | undefined>(undefined);
|
||||
if (caps !== capsSeen) {
|
||||
setCapsSeen(caps);
|
||||
if (isAppLockEnabled()) setLocked(true);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!isAppLockEnabled()) return;
|
||||
setLocked(true);
|
||||
void ask();
|
||||
// Asked after the effect, not inside it: the prompt's answer sets state.
|
||||
if (isAppLockEnabled()) void Promise.resolve().then(ask);
|
||||
}, [caps, ask]);
|
||||
|
||||
// Back from the background after a while.
|
||||
|
||||
@@ -1,57 +1,185 @@
|
||||
/**
|
||||
* NativeWidgetsSync: keeps the iOS home-screen widget and the travel-day Live
|
||||
* Activity in step with the trips stored on this device (native/widgets.ts).
|
||||
* Runs at start, whenever the app comes back to the foreground and every
|
||||
* quarter hour while it is open, so the next stop moves on through the day.
|
||||
* Renders nothing; outside an iOS binary with the widgets it does nothing.
|
||||
* NativeWidgetsSync: keeps the iOS widgets and the travel-day Live Activity in
|
||||
* step with the trips stored on this device (native/widgets.ts). Runs at start,
|
||||
* whenever the app comes back to the foreground and every quarter hour while it
|
||||
* is open, so the next stop moves on through the day. Renders nothing; outside
|
||||
* an iOS binary with the widgets it does nothing.
|
||||
*/
|
||||
import { App } from '@capacitor/app';
|
||||
import { useEffect } from 'react';
|
||||
import { offlineDb } from '../../db/offlineDb';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useNativeCapabilities } from '../../native/capabilities';
|
||||
import { buildSnapshot, pickTrip, publishWidgets, todayOf, tripDayState, widgetsActive } from '../../native/widgets';
|
||||
import { bookingsAhead, packingSummary, todosSummary, tripPlace } from '../../native/widgetExtras';
|
||||
import { widgetCover, widgetWeather } from '../../native/widgetMedia';
|
||||
import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline';
|
||||
import {
|
||||
buildSnapshot,
|
||||
pickTrip,
|
||||
publishWidgets,
|
||||
todayOf,
|
||||
tripDayState,
|
||||
widgetTripChoices,
|
||||
widgetsActive,
|
||||
withBookings,
|
||||
type TripWidgetSnapshot,
|
||||
type WidgetLabels,
|
||||
} from '../../native/widgets';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Day, Place, TranslationFn, Trip } from '../../types';
|
||||
import { formatTime } from '../../utils/formatters';
|
||||
import { localDateStr } from '../../vacay/monthGrid';
|
||||
|
||||
const REFRESH_MS = 15 * 60 * 1000;
|
||||
/** The forecast reaches about two weeks ahead; past that the widget shows no weather. */
|
||||
const FORECAST_DAYS = 14;
|
||||
|
||||
function labels(t: TranslationFn): WidgetLabels {
|
||||
return {
|
||||
nextTrip: t('dashboard.nextTrip'),
|
||||
today: t('native.widget.today'),
|
||||
noTrip: t('native.widget.noTrip'),
|
||||
inDays: t('native.widget.inDays'),
|
||||
tomorrow: t('native.widget.tomorrow'),
|
||||
nothingPlanned: t('native.widget.nothingPlanned'),
|
||||
days: t('atlas.days'),
|
||||
bookings: t('assistant.card.tripChat.phase.upcoming'),
|
||||
noBookings: t('native.widget.noBookings'),
|
||||
todos: t('todo.subtab.todo'),
|
||||
noTodos: t('native.widget.noTodos'),
|
||||
packing: t('packing.title'),
|
||||
confirmed: t('reservations.confirmed'),
|
||||
pending: t('reservations.pending'),
|
||||
todayShort: t('native.widget.today'),
|
||||
tomorrowShort: t('native.widget.tomorrow'),
|
||||
now: t('native.widget.now'),
|
||||
places: t('dashboard.places'),
|
||||
travellers: t('native.widget.travellers'),
|
||||
allDone: t('native.widget.dayDone'),
|
||||
missing: t('native.widget.missing'),
|
||||
countries: t('native.widget.countries'),
|
||||
world: t('native.widget.world'),
|
||||
group: t('native.widget.group'),
|
||||
poll: t('native.widget.poll'),
|
||||
votes: t('native.widget.votes'),
|
||||
noMessages: t('native.widget.noMessages'),
|
||||
departure: t('native.widget.departure'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Where to ask for the weather: today's first place with coordinates, else any of the trip's. */
|
||||
function weatherSpot(day: Day | undefined, places: Place[]): { lat: number; lng: number } | null {
|
||||
const planned = (day?.assignments ?? []).map((a) => a.place).find((p) => p?.lat != null && p?.lng != null);
|
||||
if (planned?.lat != null && planned.lng != null) return { lat: planned.lat, lng: planned.lng };
|
||||
const any = places.find((p) => p.lat != null && p.lng != null);
|
||||
return any?.lat != null && any.lng != null ? { lat: any.lat, lng: any.lng } : null;
|
||||
}
|
||||
|
||||
interface SyncContext {
|
||||
today: string;
|
||||
language: string;
|
||||
unit: string | undefined;
|
||||
time: (value: string) => string;
|
||||
}
|
||||
|
||||
async function tripExtras(trip: Trip, days: Day[], { today, language, unit, time }: SyncContext) {
|
||||
const [places, reservations, todos, packing, memberRows] = await Promise.all([
|
||||
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
|
||||
offlineDb.reservations.where('trip_id').equals(trip.id).toArray(),
|
||||
offlineDb.todoItems.where('trip_id').equals(trip.id).toArray(),
|
||||
offlineDb.packingItems.where('trip_id').equals(trip.id).toArray(),
|
||||
offlineDb.tripMembers.where('tripId').equals(trip.id).toArray(),
|
||||
]);
|
||||
const members = memberRows.length;
|
||||
const names = [trip.owner_username, ...memberRows.map((m) => m.username)].filter((n): n is string => !!n);
|
||||
const path = `/trips/${trip.id}`;
|
||||
const place = tripPlace(places);
|
||||
// While the trip runs, today where it is; before, its first day, once that is in forecast range.
|
||||
const start = trip.start_date ?? today;
|
||||
const weatherDate = start > today ? start : today;
|
||||
const inRange = (Date.parse(weatherDate) - Date.parse(today)) / 86_400_000 <= FORECAST_DAYS;
|
||||
const spot = weatherSpot(
|
||||
days.find((d) => (d.date ?? '').slice(0, 10) === weatherDate),
|
||||
places
|
||||
);
|
||||
const [weather, cover, bags, collab] = await Promise.all([
|
||||
spot && inRange ? widgetWeather(spot, { date: weatherDate, lang: language, unit, place }) : Promise.resolve(null),
|
||||
widgetCover(trip.cover_image),
|
||||
packingDetail(trip.id, packing),
|
||||
collabSummary(
|
||||
trip.id,
|
||||
`/trips/${trip.id}?tab=collab`,
|
||||
[...new Set(names)],
|
||||
useAuthStore.getState().user?.id ?? null
|
||||
),
|
||||
]);
|
||||
const packed = packingSummary(packing, `/trips/${trip.id}?tab=listen`);
|
||||
const dayDates = new Map(days.map((d) => [d.id, (d.date ?? '').slice(0, 10)]));
|
||||
return {
|
||||
reservations,
|
||||
extras: {
|
||||
place,
|
||||
cover,
|
||||
placeCount: places.length,
|
||||
// The members table lists the others; the owner comes on top.
|
||||
memberCount: members > 0 ? members + 1 : 0,
|
||||
weather,
|
||||
bookings: bookingsAhead(reservations, { dayDates, today, tripPath: path, formatTime: time }),
|
||||
todos: todosSummary(todos, `${path}?tab=listen`),
|
||||
packing: packed && bags ? { ...packed, ...bags } : packed,
|
||||
collab,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function NativeWidgetsSync(): null {
|
||||
const { t, language } = useTranslation();
|
||||
const { caps } = useNativeCapabilities();
|
||||
const timeFormat = useSettingsStore((s) => s.settings.time_format);
|
||||
const unit = useSettingsStore((s) => s.settings.temperature_unit);
|
||||
|
||||
useEffect(() => {
|
||||
if (!widgetsActive()) return;
|
||||
let cancelled = false;
|
||||
|
||||
/** One trip's snapshot with its day, as the widgets set to that trip show it. */
|
||||
const tripSnapshot = async (trip: Trip | null, context: SyncContext, atlas: WidgetAtlas | null) => {
|
||||
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
|
||||
const extra = trip ? await tripExtras(trip, days, context) : null;
|
||||
const plain = trip
|
||||
? todayOf(trip, days, context.today, (n, m) => t('native.widget.dayOf', { n, m }), context.time)
|
||||
: null;
|
||||
const day = plain ? withBookings(plain, extra?.reservations ?? [], context.today, context.time) : null;
|
||||
return { day, snapshot: buildSnapshot(trip, day, labels(t), { ...extra?.extras, atlas }) };
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
const now = new Date();
|
||||
const today = localDateStr(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const context: SyncContext = { today, language, unit, time: (value) => formatTime(value, language, timeFormat) };
|
||||
const trips = await offlineDb.trips.toArray();
|
||||
const trip = pickTrip(trips, today);
|
||||
const days = trip ? await offlineDb.days.where('trip_id').equals(trip.id).toArray() : [];
|
||||
const day = trip
|
||||
? todayOf(
|
||||
trip,
|
||||
days,
|
||||
today,
|
||||
(n, m) => t('native.widget.dayOf', { n, m }),
|
||||
(time) => formatTime(time, language, timeFormat)
|
||||
)
|
||||
: null;
|
||||
const hero = pickTrip(trips, today);
|
||||
// The countries do not depend on a trip: the atlas widget fills without one.
|
||||
const atlas = await atlasSummary(language);
|
||||
const main = await tripSnapshot(hero, context, atlas);
|
||||
// Every trip running or ahead also gets its own, for widgets set to it in Edit Widget.
|
||||
const choices: TripWidgetSnapshot[] = [];
|
||||
for (const trip of widgetTripChoices(trips, today)) {
|
||||
const own = trip.id === hero?.id ? main : await tripSnapshot(trip, context, null);
|
||||
choices.push({
|
||||
id: trip.id,
|
||||
title: trip.title,
|
||||
start: trip.start_date ?? '',
|
||||
end: trip.end_date ?? '',
|
||||
snapshot: own.snapshot,
|
||||
});
|
||||
}
|
||||
if (cancelled) return;
|
||||
const snapshot = buildSnapshot(trip, day, {
|
||||
nextTrip: t('dashboard.nextTrip'),
|
||||
today: t('native.widget.today'),
|
||||
noTrip: t('native.widget.noTrip'),
|
||||
inDays: t('native.widget.inDays'),
|
||||
tomorrow: t('native.widget.tomorrow'),
|
||||
nothingPlanned: t('native.widget.nothingPlanned'),
|
||||
});
|
||||
const live = day ? tripDayState(day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) : null;
|
||||
await publishWidgets(snapshot, live);
|
||||
const live = main.day
|
||||
? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone'))
|
||||
: null;
|
||||
await publishWidgets(main.snapshot, live, choices);
|
||||
};
|
||||
|
||||
void sync();
|
||||
@@ -64,7 +192,7 @@ export default function NativeWidgetsSync(): null {
|
||||
window.clearInterval(timer);
|
||||
void listener.then((handle) => handle.remove());
|
||||
};
|
||||
}, [caps, t, language, timeFormat]);
|
||||
}, [caps, t, language, timeFormat, unit]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const { size, borderWidth } = look
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, safeHexColor(place.category_color, 'white'))
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(isSelected, place.category_color)
|
||||
const bgColor = safeHexColor(place.category_color, '#6b7280')
|
||||
|
||||
// Number badges (bottom-right), or the rating where there are none: a numbered stop
|
||||
|
||||
@@ -99,7 +99,7 @@ function nativeIosWithoutStore(nativeStore: boolean): boolean | undefined {
|
||||
return nativeStore ? false : undefined
|
||||
}
|
||||
|
||||
const PILL = 'absolute left-1/2 z-[5] flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
|
||||
const PILL = 'absolute inset-x-0 z-[5] mx-auto flex w-fit items-center gap-1.5 rounded-full border border-edge bg-surface-card px-3 py-1 text-caption font-semibold text-content-secondary shadow-sm backdrop-blur-md bottom-[calc(var(--bottom-nav-h,0px)+12px)]'
|
||||
|
||||
/**
|
||||
* Says the map on screen is the stored offline basemap, not the user's own. With
|
||||
|
||||
@@ -50,6 +50,7 @@ import { poiPinParts } from './poiMarker'
|
||||
import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack, setGpxTracks } from './gpxTrackLayers'
|
||||
import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { PLUGIN_TONE_COLORS, buildPluginLayerData } from './pluginLayerGeo'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { onRealWorld } from '../../utils/worldCopy'
|
||||
@@ -128,7 +129,7 @@ function createMarkerElement(place: Place & PlaceMarkerFlags & { category_color?
|
||||
const look = placeMarkerLook(place, selected)
|
||||
const { size, borderWidth } = look
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, safeHexColor(place.category_color, 'white'))
|
||||
const { borderColor, shadow, className: frameClass } = placeMarkerFrame(selected, place.category_color)
|
||||
const bgColor = safeHexColor(place.category_color, '#6b7280')
|
||||
|
||||
// The visual circle is `size` + 2*border on each side. To make the
|
||||
@@ -213,66 +214,6 @@ const PLUGIN_LINE_LAYER_IDS: Record<'solid' | 'dash' | 'dot', string> = {
|
||||
dot: 'trek-plugin-layers-line-dot',
|
||||
}
|
||||
const PLUGIN_FILL_LAYER_ID = 'trek-plugin-layers-fill'
|
||||
const PLUGIN_TONE_COLORS: Record<string, string> = {
|
||||
default: '#4F46E5',
|
||||
success: '#10b981',
|
||||
warn: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
}
|
||||
|
||||
// GL circle layers size in screen pixels, so a metric circle has to become a
|
||||
// polygon. Equirectangular approximation — plenty for a display-only overlay.
|
||||
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
|
||||
const [lat, lng] = center
|
||||
const dLat = radiusM / 111_320
|
||||
const cos = Math.cos((lat * Math.PI) / 180)
|
||||
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)))
|
||||
const ring: [number, number][] = []
|
||||
for (let i = 0; i <= 64; i++) {
|
||||
const a = (i / 64) * 2 * Math.PI
|
||||
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat])
|
||||
}
|
||||
return ring
|
||||
}
|
||||
|
||||
interface PluginLayerGeoFeature {
|
||||
type: 'Feature'
|
||||
properties: { id: string; color: string; width: number; opacity: number; dash: string; fillOpacity: number; label: string }
|
||||
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] }
|
||||
}
|
||||
|
||||
function buildPluginLayerData(layers: PluginMapLayer[]) {
|
||||
const features = layers.flatMap(layer => layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
|
||||
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default
|
||||
const properties = {
|
||||
id: `${layer.pluginId}:${layer.id}:${i}`,
|
||||
color,
|
||||
width: f.width,
|
||||
opacity: f.opacity,
|
||||
dash: f.dash,
|
||||
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
|
||||
label: f.label || '',
|
||||
}
|
||||
if (f.type === 'polyline' && f.points) {
|
||||
return [{
|
||||
type: 'Feature' as const,
|
||||
properties,
|
||||
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
|
||||
}]
|
||||
}
|
||||
if (f.type === 'polygon' && f.points) {
|
||||
const ring = f.points.map(([lat, lng]) => [lng, lat])
|
||||
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1])) ring.push(ring[0])
|
||||
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }]
|
||||
}
|
||||
if (f.type === 'circle' && f.center && f.radiusM) {
|
||||
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] } }]
|
||||
}
|
||||
return []
|
||||
}))
|
||||
return { type: 'FeatureCollection' as const, features }
|
||||
}
|
||||
|
||||
/**
|
||||
* A place pin on the map, whatever is carrying it.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,11 @@ import type { GlMapProvider } from './glProviders'
|
||||
import type { MapViewProps } from './mapViewContract'
|
||||
|
||||
/** What a caller hands the GL trip map: the shared map contract and which provider's style to load. */
|
||||
export type MapViewGLProps = MapViewProps & { glProvider?: GlMapProvider }
|
||||
export type MapViewGLProps = MapViewProps & {
|
||||
glProvider?: GlMapProvider
|
||||
/** A style URL that replaces the provider's, as the offline fallback map (MapViewAuto). */
|
||||
styleOverride?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The six ways into a GL map, one chunk per engine.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './selectedMarker.css'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
|
||||
/**
|
||||
* Two ways a place marker can be told apart from the rest, set on the place object the
|
||||
@@ -57,8 +58,11 @@ export const SELECTED_MARKER = {
|
||||
} as const
|
||||
|
||||
/** The border colour and shadow of a round place marker, selected or not. */
|
||||
export function placeMarkerFrame(selected: boolean, categoryBorder: string): { borderColor: string; shadow: string; className: string } {
|
||||
export function placeMarkerFrame(
|
||||
selected: boolean,
|
||||
categoryColor: string | null | undefined
|
||||
): { borderColor: string; shadow: string; className: string } {
|
||||
return selected
|
||||
? { borderColor: SELECTED_MARKER.border, shadow: SELECTED_MARKER.ring, className: SELECTED_MARKER.className }
|
||||
: { borderColor: categoryBorder, shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
|
||||
: { borderColor: safeHexColor(categoryColor, 'white'), shadow: '0 2px 8px rgba(0,0,0,0.22)', className: '' }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { glWorksOffline, isOfflineForbiddenTemplate, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
|
||||
import { isOfflineForbiddenTemplate } from '../../utils/tileUrl';
|
||||
import { glWorksOffline, leafletWorksOffline, offlineStyleFor } from './offlineBasemap';
|
||||
|
||||
describe('offlineBasemap', () => {
|
||||
it('picks the stored style for the colour scheme', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { isNativeIos } from '../../native/platform';
|
||||
import { isVectorStyle } from '../../utils/tileUrl';
|
||||
import { isOfflineForbiddenTemplate, isVectorStyle } from '../../utils/tileUrl';
|
||||
import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
|
||||
|
||||
/**
|
||||
@@ -14,25 +14,13 @@ import { normalizeStyleForProvider, type GlMapProvider } from './glProviders';
|
||||
* and glyphs. A fallback has to be exactly a style that was stored, or its
|
||||
* sprite and labels are missing offline.
|
||||
*/
|
||||
export const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
|
||||
const OFFLINE_STYLES: readonly string[] = [OFM_POSITRON, OFM_DARK];
|
||||
|
||||
/** The stored style that matches the colour scheme. */
|
||||
export function offlineStyleFor(dark: boolean): string {
|
||||
return dark ? OFM_DARK : OFM_POSITRON;
|
||||
}
|
||||
|
||||
/** Hosts whose tiles may not be stored for offline use at all (OSM tile usage policy). */
|
||||
const NO_OFFLINE_HOSTS = ['tile.openstreetmap.org'];
|
||||
|
||||
export function isOfflineForbiddenTemplate(template: string): boolean {
|
||||
try {
|
||||
const host = new URL(template.replace(/\{s\}/g, 'a')).hostname;
|
||||
return NO_OFFLINE_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a GL map can be drawn offline as configured. Only MapLibre on one of
|
||||
* the stored styles can: Mapbox has nothing stored to draw from, and another
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { PluginMapLayer } from '../../api/client';
|
||||
|
||||
/** The plugin tone palette, shared by the GL layers and markers. */
|
||||
export const PLUGIN_TONE_COLORS: Record<string, string> = {
|
||||
default: '#4F46E5',
|
||||
success: '#10b981',
|
||||
warn: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
};
|
||||
|
||||
// GL circle layers size in screen pixels, so a metric circle has to become a
|
||||
// polygon. Equirectangular approximation — plenty for a display-only overlay.
|
||||
function circleToRing(center: [number, number], radiusM: number): [number, number][] {
|
||||
const [lat, lng] = center;
|
||||
const dLat = radiusM / 111_320;
|
||||
const cos = Math.cos((lat * Math.PI) / 180);
|
||||
const dLng = radiusM / (111_320 * Math.max(0.01, Math.abs(cos)));
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= 64; i++) {
|
||||
const a = (i / 64) * 2 * Math.PI;
|
||||
ring.push([lng + Math.cos(a) * dLng, lat + Math.sin(a) * dLat]);
|
||||
}
|
||||
return ring;
|
||||
}
|
||||
|
||||
interface PluginLayerGeoFeature {
|
||||
type: 'Feature';
|
||||
properties: {
|
||||
id: string;
|
||||
color: string;
|
||||
width: number;
|
||||
opacity: number;
|
||||
dash: string;
|
||||
fillOpacity: number;
|
||||
label: string;
|
||||
};
|
||||
geometry: { type: 'LineString'; coordinates: number[][] } | { type: 'Polygon'; coordinates: number[][][] };
|
||||
}
|
||||
|
||||
/** The plugins' vector layers as one GeoJSON source for the GL map. */
|
||||
export function buildPluginLayerData(layers: PluginMapLayer[]) {
|
||||
const features = layers.flatMap((layer) =>
|
||||
layer.features.flatMap((f, i): PluginLayerGeoFeature[] => {
|
||||
const color = PLUGIN_TONE_COLORS[f.tone] ?? PLUGIN_TONE_COLORS.default;
|
||||
const properties = {
|
||||
id: `${layer.pluginId}:${layer.id}:${i}`,
|
||||
color,
|
||||
width: f.width,
|
||||
opacity: f.opacity,
|
||||
dash: f.dash,
|
||||
fillOpacity: f.fill ? Math.min(0.25, f.opacity) : 0,
|
||||
label: f.label || '',
|
||||
};
|
||||
if (f.type === 'polyline' && f.points) {
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties,
|
||||
geometry: { type: 'LineString' as const, coordinates: f.points.map(([lat, lng]) => [lng, lat]) },
|
||||
},
|
||||
];
|
||||
}
|
||||
if (f.type === 'polygon' && f.points) {
|
||||
const ring = f.points.map(([lat, lng]) => [lng, lat]);
|
||||
if (ring.length && (ring[0][0] !== ring[ring.length - 1][0] || ring[0][1] !== ring[ring.length - 1][1]))
|
||||
ring.push(ring[0]);
|
||||
return [{ type: 'Feature' as const, properties, geometry: { type: 'Polygon' as const, coordinates: [ring] } }];
|
||||
}
|
||||
if (f.type === 'circle' && f.center && f.radiusM) {
|
||||
return [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
properties,
|
||||
geometry: { type: 'Polygon' as const, coordinates: [circleToRing(f.center, f.radiusM)] },
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}
|
||||
@@ -610,8 +610,13 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
|
||||
<meta charset="UTF-8">
|
||||
<base href="${window.location.origin}/">
|
||||
<title>${escHtml(trip?.title || tr('pdf.travelPlan'))}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* Poppins from this server (client/public/fonts), never from Google: the print view
|
||||
would otherwise send every reader's IP to Google's font servers. */
|
||||
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Regular.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Poppins'; font-style: italic; font-weight: 400; font-display: swap; src: url('/fonts/Poppins-Italic.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 500; font-display: swap; src: url('/fonts/Poppins-Medium.ttf') format('truetype'); }
|
||||
@font-face { font-family: 'Poppins'; font-style: normal; font-weight: 600 700; font-display: swap; src: url('/fonts/Poppins-Bold.ttf') format('truetype'); }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
|
||||
@@ -36,11 +36,8 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
// A booking document shared to TREK lands here already chosen.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const shared = useSharedImportStore.getState().file
|
||||
if (shared) setFiles([useSharedImportStore.getState().take() ?? shared])
|
||||
if (isOpen && useSharedImportStore.getState().file) useSharedImportStore.getState().take()
|
||||
}, [isOpen])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -50,6 +47,21 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
|
||||
// Only a model that reads images can do anything with a photo, so photos are offered only then.
|
||||
const acceptedExts: readonly string[] = readsPhotos ? [...ACCEPTED_EXTS, ...LLM_PHOTO_EXTENSIONS] : ACCEPTED_EXTS
|
||||
|
||||
// A booking document shared to TREK lands here already chosen: picked up in
|
||||
// the render that opens the modal, and taken out of the store once shown.
|
||||
const [openSeen, setOpenSeen] = useState(false)
|
||||
if (isOpen !== openSeen) {
|
||||
setOpenSeen(isOpen)
|
||||
if (isOpen) {
|
||||
// Opens clean, but with a shared document already in the list.
|
||||
const shared = useSharedImportStore.getState().file
|
||||
setFiles(shared ? [shared] : [])
|
||||
setIsDragOver(false)
|
||||
setLoading(false)
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setFiles([])
|
||||
setIsDragOver(false)
|
||||
@@ -57,12 +69,6 @@ export default function BookingImportModal({ isOpen, onClose, tripId, kind }: Bo
|
||||
setError('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) reset()
|
||||
// reset is stable — intentional
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
|
||||
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
* telling the reader to go and ask an administrator for one. One quiet line says
|
||||
* that nothing came back, and whose key is missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { LABEL } from '../shared/dialogParts'
|
||||
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
|
||||
fluid?: boolean
|
||||
/**
|
||||
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
|
||||
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
|
||||
* default is the phone sheet's look, which this prop leaves exactly as it was.
|
||||
* small eyebrows. The default is the phone sheet's look: a card in the mobile
|
||||
* tokens with the same one-line hints.
|
||||
*/
|
||||
variant?: 'sheet' | 'dialog'
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
|
||||
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
|
||||
<span className="flex-none">{icon}</span>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overline label, the section heading Vacay and the dashboard widgets share.
|
||||
* Uppercase with wide tracking, muted, no rule underneath.
|
||||
*/
|
||||
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
|
||||
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
|
||||
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
|
||||
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
if (!dialog) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-[5px]">
|
||||
<Overline>{label}</Overline>
|
||||
{children}
|
||||
</div>
|
||||
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
|
||||
const adoptBlockedId = useId()
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
const [state, setState] = useState<LoadState>('idle')
|
||||
// The phone sheet folds the card away to give the form's own fields the room.
|
||||
const [open, setOpen] = useState(true)
|
||||
const bodyId = useId()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const selectionKey = selection ? cacheKeyFor(selection, language) : null
|
||||
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
|
||||
// tiles, which is too small to tell a facade from a foyer. The column is
|
||||
// stretched to the form's height by the row it sits in, so the extra room
|
||||
// costs nothing that was being used.
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
|
||||
{dialog ? (
|
||||
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Landmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
|
||||
{/* Folding on the phone: the grid row eases between its height and none. */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
|
||||
>
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
|
||||
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
|
||||
{/* The place's picture first: its own ones are there whatever the sources answer. */}
|
||||
<PictureBlock
|
||||
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
|
||||
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. The dialog says the
|
||||
same in a quiet card, and the column keeps its width either way. */}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: one quiet
|
||||
line, in the dialog and on the phone alike, so the form's own fields
|
||||
stay in view instead of sitting under a mascot half a screen high. */}
|
||||
{!selection && (dialog ? (
|
||||
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'loading' && (dialog ? (
|
||||
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
|
||||
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
{/* Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (dialog ? (
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -642,7 +661,7 @@ function DescriptionBlock({
|
||||
|
||||
return (
|
||||
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
|
||||
{aboutBrand && (
|
||||
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
|
||||
<Building2 className="mt-px h-3 w-3 shrink-0" />
|
||||
@@ -674,7 +693,7 @@ function DescriptionBlock({
|
||||
onClick={onAdopt}
|
||||
disabled={adoptBlocked}
|
||||
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
|
||||
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
|
||||
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
|
||||
style={dialog ? fs(13, 'body') : undefined}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
data-field-trigger={floating ? undefined : 'pad'}
|
||||
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
|
||||
style={
|
||||
floating
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
|
||||
import { copyText } from '../../../utils/clipboard'
|
||||
import { fmtTransitDuration } from '../transitDisplay'
|
||||
import { parseMeta, displayTitle } from './bookingsModel'
|
||||
import { formatDay, type BookingFacts } from './bookingFacts'
|
||||
import { stayStats, type BookingFacts } from './bookingFacts'
|
||||
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
|
||||
import { TransitLegs, transitSummary } from './transitParts'
|
||||
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
|
||||
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
|
||||
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.isHotel) {
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
stats.push(...stayStats(facts, meta, t, locale))
|
||||
} else {
|
||||
const from = facts.endpoints[0]
|
||||
const to = facts.endpoints[facts.endpoints.length - 1]
|
||||
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
|
||||
)
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
isHotel,
|
||||
}
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's three tiles: check-in and check-out with their dates and times, and
|
||||
* the nights between. The desktop booking dialog and the phone's booking sheet
|
||||
* show the same ones.
|
||||
*/
|
||||
export function stayStats(
|
||||
facts: BookingFacts,
|
||||
meta: { check_in_time?: string | null; check_out_time?: string | null },
|
||||
t: FactsContext['t'],
|
||||
locale: string,
|
||||
): { value: string; label: string }[] {
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
|
||||
import { BOX } from '../Planner/bookings/bookingParts';
|
||||
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
|
||||
import { SoftPill, tintOf } from '../Planner/planParts';
|
||||
import DetailShell from '../shared/DetailShell';
|
||||
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
|
||||
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||
import ElevationProfile from '../shared/ElevationProfile';
|
||||
import { NavigationMenu } from '../shared/NavigationMenu';
|
||||
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
readOnly?: boolean;
|
||||
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
|
||||
desktopFocusReturnTarget?: HTMLElement | null;
|
||||
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
|
||||
shell?: ComponentType<DetailShellProps>;
|
||||
}
|
||||
|
||||
/** The pills of the head band: white like a place's, one fact each. */
|
||||
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
|
||||
desktopNonModal = false,
|
||||
readOnly = false,
|
||||
desktopFocusReturnTarget = null,
|
||||
shell: Shell = DetailShell,
|
||||
canEdit: editPermission,
|
||||
canAssign: assignPermission,
|
||||
}: TourDetailDialogProps) {
|
||||
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
<Shell
|
||||
testId="tour-detail"
|
||||
header={header}
|
||||
footer={footer}
|
||||
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
|
||||
|
||||
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
|
||||
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
|
||||
</DetailShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { mounted, closing } = usePresence(props.isOpen)
|
||||
// The question stays readable while it fades, even when its owner clears the subject on close.
|
||||
const shown = useLastOpen(props, props.isOpen)
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ConfirmDialogFrame({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
closing = false,
|
||||
}: ConfirmDialogProps & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
@@ -57,14 +66,21 @@ export default function ConfirmDialog({
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen && !closing) return null
|
||||
|
||||
// Each used by both layouts, worked out once.
|
||||
const heading = title || t('common.confirm')
|
||||
const cancelText = cancelLabel || t('common.cancel')
|
||||
const confirmText = confirmLabel || t('common.delete')
|
||||
const modalExit = closing ? 'trek-modal-exit' : ''
|
||||
const width = children ? 'max-w-md' : 'max-w-sm'
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
// Backdrop only — Escape (above) and the Cancel button below are the
|
||||
// keyboard routes out of the dialog.
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -74,7 +90,7 @@ export default function ConfirmDialog({
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${width} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
@@ -85,7 +101,7 @@ export default function ConfirmDialog({
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">
|
||||
{title || t('common.confirm')}
|
||||
{heading}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-content-secondary">
|
||||
{message}
|
||||
@@ -99,7 +115,7 @@ export default function ConfirmDialog({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors text-content-secondary border border-edge-secondary"
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
{cancelText}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
@@ -107,7 +123,7 @@ export default function ConfirmDialog({
|
||||
danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +133,7 @@ export default function ConfirmDialog({
|
||||
// with its tile, the message below, the answers in a bar at the foot.
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
className={`trek-modal-enter ${modalExit} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${width}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
|
||||
@@ -127,7 +143,7 @@ export default function ConfirmDialog({
|
||||
: <HelpCircle size={20} strokeWidth={1.9} className="text-content-muted" />}
|
||||
</DialogTile>
|
||||
<h3 className="m-0 min-w-0 flex-1 font-bold tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>
|
||||
{title || t('common.confirm')}
|
||||
{heading}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto overscroll-contain px-6 py-5">
|
||||
@@ -137,13 +153,13 @@ export default function ConfirmDialog({
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-edge-faint px-6 py-3.5" style={fs(13, 'body')}>
|
||||
<button type="button" onClick={onClose}
|
||||
className="inline-flex items-center rounded-[10px] bg-surface-tertiary px-3.5 py-2 font-medium text-content hover:bg-surface-hover">
|
||||
{cancelLabel || t('common.cancel')}
|
||||
{cancelText}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => { runConfirm(onConfirm); onClose() }}
|
||||
className={`inline-flex items-center rounded-[10px] px-4 py-2 font-medium hover:opacity-90 ${danger ? 'bg-danger text-white' : 'bg-accent text-accent-text'}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user