Added TouchID/FaceID to sign in
This commit is contained in:
parent
5c4d4f8059
commit
7d6643112d
12 changed files with 239 additions and 52 deletions
|
|
@ -335,13 +335,14 @@
|
|||
DEVELOPMENT_TEAM = UQJ7U8R2CV;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "We'll need authentication before we can show sensitive data";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIRequiresFullScreen = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
|
@ -370,13 +371,14 @@
|
|||
DEVELOPMENT_TEAM = UQJ7U8R2CV;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "We'll need authentication before we can show sensitive data";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UIRequiresFullScreen = NO;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,19 @@ import SwiftUI
|
|||
struct June2022: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
//2022-06-11
|
||||
HStack {
|
||||
VStack (alignment: .leading) {
|
||||
Text("2022-06-11")
|
||||
.font(.title2)
|
||||
Text("Version Release Candidate 2 (LVSXT10a.2)\n")
|
||||
.font(.footnote)
|
||||
Text("\u{2022} Added biometrics to sign in along with option for username/password")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(30)
|
||||
|
||||
//2022-06-10
|
||||
HStack {
|
||||
VStack (alignment: .leading) {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ struct ContentView: View {
|
|||
VStack {
|
||||
Spacer()
|
||||
VStack {
|
||||
TextField("Enter distance here", text: $distance)
|
||||
TextField("Enter distance", text: $distance)
|
||||
.padding()
|
||||
.keyboardType(.decimalPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
|
@ -39,7 +39,7 @@ struct ContentView: View {
|
|||
HStack {
|
||||
VStack {
|
||||
Text("Hours")
|
||||
TextField("Enter hours here", text: $timeHours)
|
||||
TextField("Enter hours", text: $timeHours)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($nameIsFocused)
|
||||
|
|
@ -49,7 +49,7 @@ struct ContentView: View {
|
|||
.padding()
|
||||
VStack {
|
||||
Text("Minutes")
|
||||
TextField("Enter minutes here", text: $timeMinutes)
|
||||
TextField("Enter minutes", text: $timeMinutes)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($nameIsFocused)
|
||||
|
|
@ -60,7 +60,7 @@ struct ContentView: View {
|
|||
.padding()
|
||||
VStack {
|
||||
Text("Seconds")
|
||||
TextField("Enter seconds here", text: $timeSeconds)
|
||||
TextField("Enter seconds", text: $timeSeconds)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($nameIsFocused)
|
||||
|
|
@ -99,10 +99,10 @@ struct PaceResults: View {
|
|||
// but then my message would disappear to let you know
|
||||
// what to enter in that box
|
||||
|
||||
let multiplier = {selectedSystem == "mi" ? 1.609344 : 0.6213711922}()
|
||||
let notSelectedSystem = {selectedSystem == "km" ? "mi" : "km"}()
|
||||
let multiplier = (selectedSystem == "mi" ? 1.609344 : 0.6213711922)
|
||||
let notSelectedSystem = (selectedSystem == "km" ? "mi" : "km")
|
||||
let convertedDistance = distanceDub * multiplier
|
||||
let convertedDistanceString = {distance == "" ? "" : String(format: "%.2f", convertedDistance)}()
|
||||
let convertedDistanceString = (distance == "" ? "" : String(format: "%.2f", convertedDistance))
|
||||
|
||||
let convertedSeconds:Double = (Double(timeSeconds) ?? 0) * (1.6666666666666666666666666)
|
||||
let timeSecondsInt:Int = Int(timeSeconds) ?? 0
|
||||
|
|
@ -164,9 +164,9 @@ struct PaceResults: View {
|
|||
// and the hours calculated in the previous section
|
||||
// and adds them together to get our total number of hours.
|
||||
let hoursFormatted:String = String(format: "%.0f", totalHours)
|
||||
let paceFormatted:String = {pace >= 60 ? "\(paceHours):\(properTimeMS)" : "\(properTimeMS)"}()
|
||||
let paceFormatted:String = (pace >= 60 ? "\(paceHours):\(properTimeMS)" : "\(properTimeMS)")
|
||||
|
||||
let paceFormattedOpposite:String = {paceOpposite >= 60 ? "\(paceHoursOpposite):\(properTimeMSOpposite)" : "\(properTimeMSOpposite)"}()
|
||||
let paceFormattedOpposite:String = (paceOpposite >= 60 ? "\(paceHoursOpposite):\(properTimeMSOpposite)" : "\(properTimeMSOpposite)")
|
||||
|
||||
let leadingZeros:String = String(format: "%02d:%02d", timeMinutesUnderSixty, timeSecondsUnderSixty)
|
||||
// this takes the minutes and the seconds and adds leading
|
||||
|
|
@ -176,15 +176,18 @@ struct PaceResults: View {
|
|||
|
||||
HStack {
|
||||
VStack {
|
||||
Text("Distance: \(distance)\(selectedSystem)")
|
||||
Text("Distance: \(convertedDistanceString)\(notSelectedSystem)")
|
||||
Text("\(distance)\(selectedSystem)")
|
||||
Text("\(convertedDistanceString)\(notSelectedSystem)")
|
||||
}
|
||||
Text("Total time\n\(hoursFormatted):\(leadingZeros)")
|
||||
.frame(minWidth: 100)
|
||||
Text("\(hoursFormatted):\(leadingZeros)")
|
||||
.padding()
|
||||
.frame(minWidth: 100)
|
||||
VStack(alignment: .trailing) {
|
||||
Text("\(paceFormatted) per \(selectedSystem)")
|
||||
Text("\(paceFormattedOpposite) per \(notSelectedSystem)")
|
||||
Text("\(paceFormatted)/\(selectedSystem)")
|
||||
Text("\(paceFormattedOpposite)/\(notSelectedSystem)")
|
||||
}
|
||||
.frame(minWidth: 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
// this file will not have comments.
|
||||
// this code is considered simple enough to be
|
||||
// human-readable without aid, as long as
|
||||
import LocalAuthentication
|
||||
// this file will have some comments.
|
||||
// Most of this code is considered simple enough
|
||||
// to be human-readable without aid, as long as
|
||||
// the reader has a basic understanding of
|
||||
// Swift and/or SwiftUI.
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ struct DocsView: View {
|
|||
|
||||
@State private var pass: String = ""
|
||||
@State private var user: String = ""
|
||||
@State private var isUnlocked = false
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -44,12 +46,25 @@ struct DocsView: View {
|
|||
}
|
||||
Section(header: Text("App Information")) {
|
||||
NavigationLink("Software License", destination: LicenseView())
|
||||
Text("Version: Release Candidate (1.0.0)")
|
||||
Text("Release date: 2022-06-10")
|
||||
Text("Version: Release Candidate 2 (1.0.0)")
|
||||
Text("Release date: 2022-06-11")
|
||||
Text("Start date: 2022-03-25")
|
||||
Link("Built with SwiftUI \(Image(systemName: "swift"))", destination: URL(string: "https://developer.apple.com/xcode/swiftui")!)
|
||||
}
|
||||
Section(header: Text("Login")) {
|
||||
if (isUnlocked) {
|
||||
NavigationLink("Contacts", destination: SecretView())
|
||||
Button("Log out") {
|
||||
pass = ""
|
||||
user = ""
|
||||
isUnlocked = false
|
||||
}
|
||||
} else {
|
||||
if !isUnlocked {
|
||||
Button("Log in with biometrics") {
|
||||
authenticate()
|
||||
}
|
||||
}
|
||||
SecureField("Username", text: $user)
|
||||
.keyboardType(.alphabet)
|
||||
.textContentType(.username)
|
||||
|
|
@ -60,11 +75,12 @@ struct DocsView: View {
|
|||
.textContentType(.password)
|
||||
.submitLabel(.done)
|
||||
.focused($focusedField, equals: .password)
|
||||
if (pass == password && user == username) {
|
||||
if checkPassword() {
|
||||
NavigationLink("Contacts", destination: SecretView())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Docs")
|
||||
}
|
||||
.onSubmit {
|
||||
|
|
@ -74,9 +90,49 @@ struct DocsView: View {
|
|||
default:
|
||||
()
|
||||
}
|
||||
if (pass == password && user == username) {
|
||||
isUnlocked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
func checkPassword() -> Bool {
|
||||
if (pass == password && user == username) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
func checkIfUnlocked() {
|
||||
if !isUnlocked {
|
||||
authenticate()
|
||||
}
|
||||
}
|
||||
func authenticate() {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
|
||||
// check whether biometric authentication is possible
|
||||
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
|
||||
// it's possible, so go ahead and use it
|
||||
let reason = "We need authentication before we can show you sensitive data"
|
||||
|
||||
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, authenticationError in
|
||||
// authentication has now completed
|
||||
if success {
|
||||
isUnlocked = true
|
||||
} else {
|
||||
()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
()
|
||||
}
|
||||
}
|
||||
// getting this to work came from
|
||||
// https://www.hackingwithswift.com/books/ios-swiftui/using-touch-id-and-face-id-with-swiftui
|
||||
// a truly epic website and it's helped me with
|
||||
// just about all of my code questions
|
||||
}
|
||||
|
||||
struct DocsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ struct NewFeatures: View {
|
|||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\u{2022} Added TouchID and FaceID to sign in")
|
||||
Text("Implemented in Version Release Candidate 2\n")
|
||||
.font(.footnote)
|
||||
.italic()
|
||||
Text("\u{2022} Implemented a way to dismiss the keyboard")
|
||||
Text("Implemented in Version Release Candidate\n")
|
||||
.font(.footnote)
|
||||
.italic()
|
||||
Text("\u{2022} Reformatted the Docs tabs and made the common things up at the top")
|
||||
Text("Implemented in Version Prerelease LVSXT10d.2\n")
|
||||
.font(.footnote)
|
||||
|
|
@ -33,15 +41,11 @@ struct NewFeatures: View {
|
|||
struct InProgressFeatures: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text("Features In Progress")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.top, 40)
|
||||
Text("Note: this does not include things I have to fix\n")
|
||||
.font(.footnote)
|
||||
.italic()
|
||||
VStack(alignment: .leading) {
|
||||
Text("\u{2022} Working on a conversion between measurements for pace and distance\n")
|
||||
Text("\u{2022} Adding a better-formatted Contacts tab on iPad\n")
|
||||
}
|
||||
.padding(30)
|
||||
}
|
||||
|
|
@ -52,10 +56,6 @@ struct InProgressFeatures: View {
|
|||
struct DeprecatedFeatures: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text("Deprecated Features")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.top, 40)
|
||||
VStack(alignment: .leading) {
|
||||
Text("\n\u{2022} Removed the picker wheel to enter total time (a truly horrible system)")
|
||||
Text("Stricken before recorded history\n")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ struct RecentlyResolved: View {
|
|||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("\u{2022} Implemented an easy way to dismiss the keyboard in the main view of Calculator (it only took 2 1/2 months)")
|
||||
Text("\u{2022} Implemented an easy way to dismiss the keyboard in the main view of Calculator (it only took 2 1/2 months)\n\u{2022} Opening the contacts tab no longer causes the app to crash (RC 2)")
|
||||
}
|
||||
.padding(30)
|
||||
}
|
||||
|
|
@ -24,8 +24,11 @@ struct RecentlyResolved: View {
|
|||
struct HighPriority: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Tapping on the Contacts tab after entering correct login details causes the app to crash")
|
||||
VStack {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 70)
|
||||
}
|
||||
.padding(30)
|
||||
}
|
||||
|
|
@ -36,9 +39,11 @@ struct HighPriority: View {
|
|||
struct MediumPriority: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Wow. Such Empty.")
|
||||
.italic()
|
||||
VStack {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 70)
|
||||
}
|
||||
.padding(30)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ struct LicenseView: View {
|
|||
// setting fontSize to a value based on that where the whole
|
||||
// line can be viewed without scrolling horizontally
|
||||
// or with wrapped text, ruining the required formatting I have.
|
||||
// As of 2022-06-08, I have not tested this on a phone.
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import SwiftUI
|
|||
|
||||
struct SecretView: View {
|
||||
var body: some View {
|
||||
VStack {
|
||||
ScrollView {
|
||||
VStack {
|
||||
VStack {
|
||||
Image("jake.zimmerman.group")
|
||||
.resizable()
|
||||
|
|
@ -101,9 +101,90 @@ Email: greenei@students.lakeviewspartans.org
|
|||
}
|
||||
.frame(minWidth: 350, minHeight: 175)
|
||||
.border(.primary)
|
||||
VStack {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 75, height: 75)
|
||||
Text("Pryor")
|
||||
.font(.title2)
|
||||
VStack (alignment: .leading) {
|
||||
Text("""
|
||||
Name: Becky Pryor
|
||||
Email: bpryor@lakeviewspartans.org
|
||||
""")
|
||||
HStack {
|
||||
Text("Phone:")
|
||||
Link("(269) 209-9906", destination: URL(string: "tel:2692099906")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 175)
|
||||
.border(.primary)
|
||||
VStack {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 75, height: 75)
|
||||
Text("Paige")
|
||||
.font(.title2)
|
||||
VStack (alignment: .leading) {
|
||||
Text("""
|
||||
Name: Paige Ratliff
|
||||
Email: ratliffp@students.lakeviewspartans.org
|
||||
""")
|
||||
HStack {
|
||||
Text("Phone:")
|
||||
Link("(269) 753-8569", destination: URL(string: "tel:2697538569")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 175)
|
||||
.border(.primary)
|
||||
VStack {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 75, height: 75)
|
||||
Text("Emma")
|
||||
.font(.title2)
|
||||
VStack (alignment: .leading) {
|
||||
Text("""
|
||||
Name: Emma Kerschbaum
|
||||
Email: kerschbaume@students.lakeviewspartans.org
|
||||
""")
|
||||
HStack {
|
||||
Text("Phone:")
|
||||
Link("(269) 419-7880", destination: URL(string: "tel:2694197880")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 175)
|
||||
.border(.primary)
|
||||
VStack {
|
||||
Image(systemName: "person.crop.circle.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 75, height: 75)
|
||||
Text("Alyssa")
|
||||
.font(.title2)
|
||||
VStack (alignment: .leading) {
|
||||
Text("""
|
||||
Name: Alyssa Hinton
|
||||
Email: hintona2@students.lakeviewspartans.org
|
||||
""")
|
||||
HStack {
|
||||
Text("Phone:")
|
||||
Link("(269) 589-7609", destination: URL(string: "tel:2695897609")!)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 350, minHeight: 175)
|
||||
.border(.primary)
|
||||
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.navigationTitle("Contacts")
|
||||
}
|
||||
.frame(minWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
qerberymjthnrgbefvdcs.playground/Contents.swift
Normal file
6
qerberymjthnrgbefvdcs.playground/Contents.swift
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import UIKit
|
||||
var selectedSystem = "mi"
|
||||
|
||||
let multiplier = (selectedSystem == "mi" ? 1.609344 : 0.6213711922)
|
||||
|
||||
print(multiplier)
|
||||
4
qerberymjthnrgbefvdcs.playground/contents.xcplayground
Normal file
4
qerberymjthnrgbefvdcs.playground/contents.xcplayground
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
|
||||
<timeline fileName='timeline.xctimeline'/>
|
||||
</playground>
|
||||
7
qerberymjthnrgbefvdcs.playground/playground.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
qerberymjthnrgbefvdcs.playground/playground.xcworkspace/contents.xcworkspacedata
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "group:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
11
qerberymjthnrgbefvdcs.playground/timeline.xctimeline
Normal file
11
qerberymjthnrgbefvdcs.playground/timeline.xctimeline
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Timeline
|
||||
version = "3.0">
|
||||
<TimelineItems>
|
||||
<LoggerValueHistoryTimelineItem
|
||||
documentLocation = "file:///Users/ericgreene1/Desktop/IsaacSchool/Xcode/Splits/Splits/qerberymjthnrgbefvdcs.playground#CharacterRangeLen=17&CharacterRangeLoc=110&EndingColumnNumber=0&EndingLineNumber=6&StartingColumnNumber=1&StartingLineNumber=5&Timestamp=676683411.20383"
|
||||
selectedRepresentationIndex = "0"
|
||||
shouldTrackSuperviewWidth = "NO">
|
||||
</LoggerValueHistoryTimelineItem>
|
||||
</TimelineItems>
|
||||
</Timeline>
|
||||
Loading…
Add table
Add a link
Reference in a new issue