mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Fix emojionlytextfield regression by @rcgv1
Co-authored-by: garthvh <1795163+garthvh@users.noreply.github.com>
This commit is contained in:
parent
e50d793454
commit
c5ceca2aa8
3 changed files with 222 additions and 272 deletions
|
|
@ -233,12 +233,6 @@
|
|||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : " : %@"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
|
|
@ -280,12 +274,6 @@
|
|||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : " : %d"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
|
|
@ -7774,6 +7762,7 @@
|
|||
}
|
||||
},
|
||||
"Bearing: %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -7784,6 +7773,7 @@
|
|||
}
|
||||
},
|
||||
"Bearing: N/A" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -17895,6 +17885,7 @@
|
|||
}
|
||||
},
|
||||
"Distance: %@" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
|
|
@ -31390,10 +31381,6 @@
|
|||
"comment" : "A description of the read-only mode feature in TAK Server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. This helps us understand how the app is being used and where we can make improvements. The data we collect is non-personally identifiable and cannot be linked to you as an individual. You can opt out of this under app settings." : {
|
||||
"comment" : "Privacy policy text for Meshtastic.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Meshtastic does not collect any personal information. We do anonymously collect usage and crash data to improve the app. You can opt out under app settings." : {
|
||||
"localizations" : {
|
||||
"es" : {
|
||||
|
|
@ -52718,7 +52705,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
"TAK Cannot Be Used on Public Channel" : {
|
||||
"comment" : "A warning displayed when the user's primary channel is public.",
|
||||
|
|
@ -62963,88 +62949,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
": %@" : {
|
||||
"localizations" : {
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
},
|
||||
"zh-Hant-TW" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %@"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
": %d" : {
|
||||
"localizations" : {
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"sr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
},
|
||||
"zh-Hant-TW" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ": %d"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shouldTranslate" : false
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ struct CompassView: View {
|
|||
|
||||
/// Single waypoint parameter
|
||||
let waypointLocation: CLLocationCoordinate2D?
|
||||
|
||||
|
||||
let waypointName: String?
|
||||
|
||||
|
||||
let color: Color
|
||||
|
||||
@ObservedObject private var locationsHandler = LocationsHandler.shared
|
||||
|
|
@ -24,6 +24,8 @@ struct CompassView: View {
|
|||
private let alignmentTolerance: Double = 5.0
|
||||
@State private var inAlignment = false
|
||||
|
||||
private let dialRadius: CGFloat = 140
|
||||
|
||||
// Compute bearing from user → waypoint
|
||||
private func bearingToWaypoint() -> Double? {
|
||||
guard
|
||||
|
|
@ -39,10 +41,9 @@ struct CompassView: View {
|
|||
|
||||
// Trigger a vibration if aligned with waypoint
|
||||
private func checkAlignment(bearing: Double, heading: Double) {
|
||||
// Compute minimal angular difference between heading and bearing in [0, 180]
|
||||
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
|
||||
let diff = min(rawDiff, 360 - rawDiff)
|
||||
|
||||
let rawDiff = abs(heading - bearing).truncatingRemainder(dividingBy: 360)
|
||||
let diff = min(rawDiff, 360 - rawDiff)
|
||||
|
||||
if diff <= alignmentTolerance {
|
||||
if !inAlignment {
|
||||
inAlignment = true
|
||||
|
|
@ -53,124 +54,246 @@ struct CompassView: View {
|
|||
inAlignment = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func distanceToWaypoint() -> CLLocationDistance? {
|
||||
guard
|
||||
let waypoint = waypointLocation,
|
||||
let user = LocationsHandler.currentLocation
|
||||
else { return nil }
|
||||
|
||||
|
||||
let userLocation = CLLocation(latitude: user.latitude, longitude: user.longitude)
|
||||
let waypointLocation = CLLocation(latitude: waypoint.latitude, longitude: waypoint.longitude)
|
||||
|
||||
|
||||
return userLocation.distance(from: waypointLocation)
|
||||
}
|
||||
|
||||
// Format distance with localization
|
||||
private func formatDistance(_ distance: CLLocationDistance) -> String {
|
||||
let measurement = Measurement(value: distance, unit: UnitLength.meters)
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.numberFormatter.maximumFractionDigits = 2
|
||||
formatter.numberFormatter.maximumFractionDigits = 1
|
||||
return formatter.string(from: measurement)
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 15) {
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(waypointName ?? "Waypoint")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.foregroundColor(color)
|
||||
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// Top fixed heading indicator triangle
|
||||
Image(systemName: "triangle.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.rotationEffect(.degrees(180))
|
||||
.padding(.bottom, 4)
|
||||
|
||||
// Rotating compass dial
|
||||
ZStack {
|
||||
// Outer bezel ring
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: 1.5)
|
||||
.frame(width: dialRadius * 2 + 20, height: dialRadius * 2 + 20)
|
||||
|
||||
// Tick marks
|
||||
ForEach(0..<360, id: \.self) { degree in
|
||||
CompassTickMark(degree: Double(degree), radius: dialRadius)
|
||||
}
|
||||
|
||||
// Cardinal and intercardinal labels
|
||||
ForEach(CompassLabel.allLabels, id: \.degrees) { label in
|
||||
CompassLabelView(label: label, radius: dialRadius - 28)
|
||||
.rotationEffect(.degrees(-locationsHandler.heading))
|
||||
}
|
||||
|
||||
// North triangle indicator at 0°
|
||||
CompassNorthIndicator(radius: dialRadius + 2)
|
||||
|
||||
// Degree readout at center
|
||||
VStack(spacing: 4) {
|
||||
Text(headingText())
|
||||
.font(.system(size: 42, weight: .light, design: .rounded))
|
||||
.foregroundColor(.white)
|
||||
.monospacedDigit()
|
||||
|
||||
if let distance = distanceToWaypoint() {
|
||||
Text(formatDistance(distance))
|
||||
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
}
|
||||
|
||||
if waypointName != nil || waypointLocation != nil {
|
||||
Text(waypointName ?? "Waypoint")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(color.opacity(0.8))
|
||||
}
|
||||
}
|
||||
|
||||
// Waypoint bearing indicator
|
||||
if let bearing = bearingToWaypoint() {
|
||||
WaypointMarkerView(
|
||||
bearing: bearing,
|
||||
radius: dialRadius + 14,
|
||||
color: color
|
||||
)
|
||||
.onChange(of: locationsHandler.heading) { _, _ in
|
||||
checkAlignment(bearing: bearing, heading: locationsHandler.heading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: dialRadius * 2 + 40, height: dialRadius * 2 + 40)
|
||||
.rotationEffect(Angle(degrees: -locationsHandler.heading))
|
||||
|
||||
// Bottom info
|
||||
if let wp = waypointLocation {
|
||||
HStack {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))")
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
if let distance = distanceToWaypoint() {
|
||||
HStack {
|
||||
Image(systemName: "lines.measurement.horizontal")
|
||||
Text("Distance: \(formatDistance(distance))")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.system(size: 11))
|
||||
Text("\(String(format: "%.4f", wp.latitude)), \(String(format: "%.4f", wp.longitude))")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "location.north")
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
|
||||
if let bearing = bearingToWaypoint() {
|
||||
Text("Bearing: \(String(format: "%.0f°", bearing))")
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
Text("Bearing: N/A")
|
||||
.font(.subheadline)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "location.north.fill")
|
||||
.font(.system(size: 11))
|
||||
.rotationEffect(.degrees(bearing))
|
||||
Text("\(String(format: "%.0f°", bearing))")
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(color.opacity(0.7))
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Capsule()
|
||||
.frame(width: 5, height: 50)
|
||||
ZStack {
|
||||
|
||||
// Cardinal/degree markers
|
||||
ForEach(Marker.markers(), id: \.self) { marker in
|
||||
CompassMarkerView(
|
||||
marker: marker,
|
||||
compassDegrees: -locationsHandler.heading
|
||||
)
|
||||
}
|
||||
|
||||
// Waypoint bearing indicator
|
||||
if let bearing = bearingToWaypoint() {
|
||||
WaypointMarkerView(
|
||||
bearing: bearing,
|
||||
compassDegrees: locationsHandler.heading,
|
||||
color: color
|
||||
)
|
||||
// Move waypoint marker outside compass
|
||||
.onChange(of: locationsHandler.heading) { _, _ in
|
||||
checkAlignment(bearing: bearing, heading:locationsHandler.heading)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
.rotationEffect(Angle(degrees: -locationsHandler.heading))
|
||||
.statusBar(hidden: true)
|
||||
.onAppear {
|
||||
locationsHandler.startHeadingUpdates()
|
||||
locationsHandler.startLocationUpdates()
|
||||
}
|
||||
.onDisappear {
|
||||
locationsHandler.stopHeadingUpdates()
|
||||
locationsHandler.stopLocationUpdates()
|
||||
}
|
||||
.navigationTitle("Compass")
|
||||
}
|
||||
.statusBar(hidden: true)
|
||||
.onAppear {
|
||||
locationsHandler.startHeadingUpdates()
|
||||
locationsHandler.startLocationUpdates()
|
||||
}
|
||||
.onDisappear {
|
||||
locationsHandler.stopHeadingUpdates()
|
||||
locationsHandler.stopLocationUpdates()
|
||||
}
|
||||
.navigationTitle("Compass")
|
||||
.toolbarColorScheme(.dark, for: .navigationBar)
|
||||
}
|
||||
}
|
||||
|
||||
private func headingText() -> String {
|
||||
let h = Int(locationsHandler.heading.rounded()) % 360
|
||||
return "\(h)°"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compass Tick Mark
|
||||
struct CompassTickMark: View {
|
||||
let degree: Double
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
let isCardinal = degree.truncatingRemainder(dividingBy: 90) == 0
|
||||
let isMajor = degree.truncatingRemainder(dividingBy: 30) == 0
|
||||
let isMinor = degree.truncatingRemainder(dividingBy: 10) == 0
|
||||
|
||||
let length: CGFloat = isCardinal ? 16 : (isMajor ? 12 : (isMinor ? 8 : 4))
|
||||
let width: CGFloat = isCardinal ? 2.5 : (isMajor ? 1.5 : 1)
|
||||
let tickColor: Color = isCardinal ? .white : (isMajor ? .white.opacity(0.7) : .white.opacity(0.3))
|
||||
|
||||
// Only draw ticks at 2° intervals
|
||||
if Int(degree) % 2 == 0 {
|
||||
Capsule()
|
||||
.fill(tickColor)
|
||||
.frame(width: width, height: length)
|
||||
.offset(y: -(radius - length / 2))
|
||||
.rotationEffect(.degrees(degree))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - North Indicator
|
||||
struct CompassNorthIndicator: View {
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Triangle()
|
||||
.fill(Color.orange)
|
||||
.frame(width: 12, height: 10)
|
||||
.offset(y: -(radius + 8))
|
||||
}
|
||||
}
|
||||
|
||||
struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compass Label Model & View
|
||||
struct CompassLabel {
|
||||
let degrees: Double
|
||||
let text: String
|
||||
let isCardinal: Bool
|
||||
|
||||
static let allLabels: [CompassLabel] = [
|
||||
CompassLabel(degrees: 0, text: "N", isCardinal: true),
|
||||
CompassLabel(degrees: 45, text: "NE", isCardinal: false),
|
||||
CompassLabel(degrees: 90, text: "E", isCardinal: true),
|
||||
CompassLabel(degrees: 135, text: "SE", isCardinal: false),
|
||||
CompassLabel(degrees: 180, text: "S", isCardinal: true),
|
||||
CompassLabel(degrees: 225, text: "SW", isCardinal: false),
|
||||
CompassLabel(degrees: 270, text: "W", isCardinal: true),
|
||||
CompassLabel(degrees: 315, text: "NW", isCardinal: false)
|
||||
]
|
||||
}
|
||||
|
||||
struct CompassLabelView: View {
|
||||
let label: CompassLabel
|
||||
let radius: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Text(label.text)
|
||||
.font(.system(size: label.isCardinal ? 18 : 13,
|
||||
weight: label.isCardinal ? .bold : .medium))
|
||||
.foregroundColor(label.degrees == 0 ? .orange : .white)
|
||||
.rotationEffect(.degrees(-label.degrees))
|
||||
.offset(y: -radius)
|
||||
.rotationEffect(.degrees(label.degrees))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Waypoint Marker View
|
||||
struct WaypointMarkerView: View {
|
||||
let bearing: Double
|
||||
let compassDegrees: Double
|
||||
let radius: CGFloat
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(color)
|
||||
.offset(y: -170)
|
||||
.rotationEffect(Angle(degrees: bearing))
|
||||
}
|
||||
ZStack {
|
||||
// Outer glow
|
||||
Image(systemName: "arrowtriangle.up.fill")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(color.opacity(0.3))
|
||||
.offset(y: -(radius + 4))
|
||||
.rotationEffect(.degrees(bearing))
|
||||
|
||||
// Arrow
|
||||
Image(systemName: "arrowtriangle.up.fill")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundColor(color)
|
||||
.offset(y: -(radius + 5))
|
||||
.rotationEffect(.degrees(bearing))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bearing Calculator
|
||||
|
|
@ -199,78 +322,6 @@ struct BearingCalculator {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Marker Model
|
||||
struct Marker: Hashable {
|
||||
let degrees: Double
|
||||
let label: String
|
||||
|
||||
init(degrees: Double, label: String = "") {
|
||||
self.degrees = degrees
|
||||
self.label = label
|
||||
}
|
||||
|
||||
func degreeText() -> String {
|
||||
return String(format: "%.0f", self.degrees)
|
||||
}
|
||||
|
||||
static func markers() -> [Marker] {
|
||||
return [
|
||||
Marker(degrees: 0, label: "N"),
|
||||
Marker(degrees: 30),
|
||||
Marker(degrees: 60),
|
||||
Marker(degrees: 90, label: "E"),
|
||||
Marker(degrees: 120),
|
||||
Marker(degrees: 150),
|
||||
Marker(degrees: 180, label: "S"),
|
||||
Marker(degrees: 210),
|
||||
Marker(degrees: 240),
|
||||
Marker(degrees: 270, label: "W"),
|
||||
Marker(degrees: 300),
|
||||
Marker(degrees: 330)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compass Marker View
|
||||
struct CompassMarkerView: View {
|
||||
let marker: Marker
|
||||
let compassDegrees: Double
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text(marker.degreeText())
|
||||
.fontWeight(.light)
|
||||
.rotationEffect(textAngle())
|
||||
|
||||
Capsule()
|
||||
.frame(width: capsuleWidth(), height: capsuleHeight())
|
||||
.foregroundColor(capsuleColor())
|
||||
|
||||
Text(marker.label)
|
||||
.fontWeight(.bold)
|
||||
.rotationEffect(textAngle())
|
||||
.padding(.bottom, 180)
|
||||
}
|
||||
.rotationEffect(Angle(degrees: marker.degrees))
|
||||
}
|
||||
|
||||
private func capsuleWidth() -> CGFloat {
|
||||
marker.degrees == 0 ? 7 : 3
|
||||
}
|
||||
|
||||
private func capsuleHeight() -> CGFloat {
|
||||
marker.degrees == 0 ? 45 : 30
|
||||
}
|
||||
|
||||
private func capsuleColor() -> Color {
|
||||
marker.degrees == 0 ? .red : .gray
|
||||
}
|
||||
|
||||
private func textAngle() -> Angle {
|
||||
Angle(degrees: -compassDegrees - marker.degrees)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
struct CompassView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
|
|
|||
|
|
@ -114,24 +114,19 @@ struct WaypointForm: View {
|
|||
HStack {
|
||||
Text("Icon")
|
||||
Spacer()
|
||||
EmojiOnlyTextField(text: $icon, placeholder: "Select an emoji")
|
||||
TextField("Select an emoji", text: $icon)
|
||||
.keyboardType(.emoji)
|
||||
.font(.title)
|
||||
.focused($iconIsFocused)
|
||||
.onChange(of: icon) {
|
||||
// If it contains non-emoji characters, clear it
|
||||
if !icon.onlyEmojis() {
|
||||
icon = ""
|
||||
return
|
||||
.onChange(of: icon) { _, value in
|
||||
// If a second emoji is entered delete the first one
|
||||
if value.count >= 1 {
|
||||
if value.count > 1 {
|
||||
let index = value.index(value.startIndex, offsetBy: 1)
|
||||
icon = String(value[index])
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple emojis are entered or pasted, keep only the last one
|
||||
if icon.count > 1 {
|
||||
icon = String(icon.suffix(1))
|
||||
}
|
||||
iconIsFocused = false
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
Toggle(isOn: $expires) {
|
||||
Label("Expires", systemImage: "clock.badge.xmark")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue