mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Merge pull request #501 from meshtastic/channelkey_editor
Messaging Updates
This commit is contained in:
commit
43d7314192
23 changed files with 440 additions and 418 deletions
|
|
@ -14,6 +14,10 @@
|
|||
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3E905B02B71F7F300654D07 /* TextMessageField.swift */; };
|
||||
C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */; };
|
||||
C9697FA527933B8C00250207 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = C9697FA427933B8C00250207 /* SQLite */; };
|
||||
D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */; };
|
||||
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D42B812B700066FBC8 /* MessageDestination.swift */; };
|
||||
D93068D72B8146690066FBC8 /* MessageText.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D62B8146690066FBC8 /* MessageText.swift */; };
|
||||
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D93068D82B81509C0066FBC8 /* TapbackResponses.swift */; };
|
||||
D9BC22DB2B7DE8E2006A37D5 /* TileDownloadStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */; };
|
||||
D9C9839D2B79CFD700BDBE6A /* TextMessageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */; };
|
||||
D9C983A02B79D0E800BDBE6A /* AlertButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */; };
|
||||
|
|
@ -235,6 +239,10 @@
|
|||
B399E8A32B6F486400E4488E /* RetryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryButton.swift; sourceTree = "<group>"; };
|
||||
B3E905B02B71F7F300654D07 /* TextMessageField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageField.swift; sourceTree = "<group>"; };
|
||||
C9697F9C279336B700250207 /* LocalMBTileOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMBTileOverlay.swift; sourceTree = "<group>"; };
|
||||
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContextMenuItems.swift; sourceTree = "<group>"; };
|
||||
D93068D42B812B700066FBC8 /* MessageDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDestination.swift; sourceTree = "<group>"; };
|
||||
D93068D62B8146690066FBC8 /* MessageText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageText.swift; sourceTree = "<group>"; };
|
||||
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapbackResponses.swift; sourceTree = "<group>"; };
|
||||
D9BC22DA2B7DE8E2006A37D5 /* TileDownloadStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileDownloadStatus.swift; sourceTree = "<group>"; };
|
||||
D9C9839C2B79CFD700BDBE6A /* TextMessageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextMessageSize.swift; sourceTree = "<group>"; };
|
||||
D9C9839F2B79D0E800BDBE6A /* AlertButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertButton.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -693,6 +701,7 @@
|
|||
DD1925B828CDA93900720036 /* SerialConfigEnums.swift */,
|
||||
DD994B68295F88B60013760A /* IntervalEnums.swift */,
|
||||
DD5E5239298EFA5300D21B61 /* TelemetryWeather.swift */,
|
||||
D93068D42B812B700066FBC8 /* MessageDestination.swift */,
|
||||
);
|
||||
path = Enums;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -850,6 +859,9 @@
|
|||
DDB8F4112A9EE5DD00230ECE /* UserList.swift */,
|
||||
DD1BF2F82776FE2E008C8D2F /* UserMessageList.swift */,
|
||||
B399E8A32B6F486400E4488E /* RetryButton.swift */,
|
||||
D93068D62B8146690066FBC8 /* MessageText.swift */,
|
||||
D93068D22B8129510066FBC8 /* MessageContextMenuItems.swift */,
|
||||
D93068D82B81509C0066FBC8 /* TapbackResponses.swift */,
|
||||
);
|
||||
path = Messages;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1242,6 +1254,7 @@
|
|||
DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */,
|
||||
DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */,
|
||||
6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */,
|
||||
D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */,
|
||||
DD5E520A298EE33B00D21B61 /* channel.pb.swift in Sources */,
|
||||
DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */,
|
||||
DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */,
|
||||
|
|
@ -1281,6 +1294,7 @@
|
|||
DD6193752862F6E600E59241 /* ExternalNotificationConfig.swift in Sources */,
|
||||
DDB6ABE428B13FFF00384BA1 /* DisplayEnums.swift in Sources */,
|
||||
DD4975A52B147BA90026544E /* AmbientLightingConfig.swift in Sources */,
|
||||
D93068D92B81509C0066FBC8 /* TapbackResponses.swift in Sources */,
|
||||
DD86D40A287F04F100BAEB7A /* InvalidVersion.swift in Sources */,
|
||||
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
|
||||
DD5E5212298EE33B00D21B61 /* apponly.pb.swift in Sources */,
|
||||
|
|
@ -1298,6 +1312,7 @@
|
|||
DD5E523A298EFA5300D21B61 /* TelemetryWeather.swift in Sources */,
|
||||
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */,
|
||||
C9697F9D279336B700250207 /* LocalMBTileOverlay.swift in Sources */,
|
||||
D93068D52B812B700066FBC8 /* MessageDestination.swift in Sources */,
|
||||
DD58C5F22919AD3C00D5BEFB /* ChannelEntityExtension.swift in Sources */,
|
||||
DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */,
|
||||
DD0F791B28713C8A00A6FDAD /* AdminMessageList.swift in Sources */,
|
||||
|
|
@ -1316,6 +1331,7 @@
|
|||
DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */,
|
||||
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */,
|
||||
DD5E5211298EE33B00D21B61 /* remote_hardware.pb.swift in Sources */,
|
||||
D93068D72B8146690066FBC8 /* MessageText.swift in Sources */,
|
||||
DD5E5204298EE33B00D21B61 /* xmodem.pb.swift in Sources */,
|
||||
DDE5B4062B227E3200FCDD05 /* TraceRouteEntityExtension.swift in Sources */,
|
||||
DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */,
|
||||
|
|
|
|||
19
Meshtastic/Enums/MessageDestination.swift
Normal file
19
Meshtastic/Enums/MessageDestination.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/// Helper abstraction for sharing functionality between channel and direct messaging.
|
||||
enum MessageDestination {
|
||||
case user(UserEntity)
|
||||
case channel(ChannelEntity)
|
||||
|
||||
var userNum: Int64 {
|
||||
switch self {
|
||||
case let .user(user): return user.num
|
||||
case .channel: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var channelNum: Int32 {
|
||||
switch self {
|
||||
case .user: return 0
|
||||
case let .channel(channel): return channel.index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,4 +17,8 @@ extension MessageEntity {
|
|||
let time = messageTimestamp <= 0 ? receivedTimestamp : messageTimestamp
|
||||
return Date(timeIntervalSince1970: TimeInterval(time))
|
||||
}
|
||||
|
||||
var canRetry: Bool {
|
||||
return ackError == 9 || ackError == 5 || ackError == 3
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,3 +25,20 @@
|
|||
Image(systemName: "qrcode")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, macOS 14.0, *)
|
||||
struct CreateChannelsTip: Tip {
|
||||
|
||||
var id: String {
|
||||
return "tip.channels.create"
|
||||
}
|
||||
var title: Text {
|
||||
Text("tip.channels.create.title")
|
||||
}
|
||||
var message: Text? {
|
||||
Text("tip.channels.create.message")
|
||||
}
|
||||
var image: Image? {
|
||||
Image(systemName: "fibrechannel")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ struct ContactsTip: Tip {
|
|||
}
|
||||
var message: Text? {
|
||||
//Text("tip.messages.contacts.message")
|
||||
Text("Each node shows as an available contact. Nodes with recent messages and favorites show up at the top of the list. Select a node to send or view messages. Long press to favorite or mute the node, send a trace route or delete the conversation.")
|
||||
Text("Each node is an available contact. Contacts with recent messages or marked as favorites show up at the top of the list. Select a contact to send or view messages. Long press to favorite or mute the contact or delete the conversation.")
|
||||
}
|
||||
var image: Image? {
|
||||
Image(systemName: "person.circle")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ struct ChannelList: View {
|
|||
|
||||
@State private var isPresentingTraceRouteSentAlert = false
|
||||
|
||||
var restrictedChannels = ["admin", "gpio", "mqtt", "serial"]
|
||||
|
||||
var body: some View {
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMdd", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY")
|
||||
|
|
@ -29,7 +31,7 @@ struct ChannelList: View {
|
|||
// Display Contacts for the rest of the non admin channels
|
||||
if node != nil && node!.myInfo != nil && node!.myInfo!.channels != nil {
|
||||
List(node!.myInfo!.channels!.array as! [ChannelEntity], id: \.self, selection: $channelSelection) { (channel: ChannelEntity) in
|
||||
if channel.name?.lowercased() ?? "" != "admin" && channel.name?.lowercased() ?? "" != "gpio" && channel.name?.lowercased() ?? "" != "serial" {
|
||||
if !restrictedChannels.contains(channel.name?.lowercased() ?? "") {
|
||||
|
||||
NavigationLink(destination: ChannelMessageList(myInfo: node!.myInfo!, channel: channel)) {
|
||||
|
||||
|
|
@ -85,9 +87,6 @@ struct ChannelList: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
// Image(systemName: "chevron.forward")
|
||||
// .font(.caption)
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if channel.allPrivateMessages.count > 0 {
|
||||
|
|
|
|||
|
|
@ -18,15 +18,11 @@ struct ChannelMessageList: View {
|
|||
|
||||
@ObservedObject var myInfo: MyInfoEntity
|
||||
@ObservedObject var channel: ChannelEntity
|
||||
@State var showDeleteMessageAlert = false
|
||||
@State private var deleteMessageId: Int64 = 0
|
||||
@State private var replyMessageId: Int64 = 0
|
||||
@AppStorage("preferredPeripheralNum") private var preferredPeripheralNum = -1
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmssa", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss a")
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
|
|
@ -55,155 +51,28 @@ struct ChannelMessageList: View {
|
|||
.offset(y: -5)
|
||||
}
|
||||
VStack(alignment: currentUser ? .trailing : .leading) {
|
||||
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
|
||||
Text(markdownText)
|
||||
.tint(linkBlue)
|
||||
.padding(10)
|
||||
.foregroundColor(.white)
|
||||
.background(currentUser ? .accentColor : Color(.gray))
|
||||
.cornerRadius(15)
|
||||
.overlay(
|
||||
VStack {
|
||||
if #available(iOS 17.0, macOS 14.0, *) {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
} else {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
)
|
||||
.contextMenu {
|
||||
VStack {
|
||||
Text("channel")+Text(": \(message.channel)")
|
||||
}
|
||||
Menu("tapback") {
|
||||
ForEach(Tapbacks.allCases) { tb in
|
||||
Button(action: {
|
||||
if bleManager.sendMessage(message: tb.emojiString, toUserNum: 0, channel: channel.index, isEmoji: true, replyID: message.messageId) {
|
||||
print("Sent \(tb.emojiString) Tapback")
|
||||
self.context.refresh(channel, mergeChanges: true)
|
||||
} else { print("\(tb.emojiString) Tapback Failed") }
|
||||
|
||||
}) {
|
||||
Text(tb.description)
|
||||
let image = tb.emojiString.image()
|
||||
Image(uiImage: image!)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
self.replyMessageId = message.messageId
|
||||
self.messageFieldFocused = true
|
||||
print("I want to reply to \(message.messageId)")
|
||||
}) {
|
||||
Text("reply")
|
||||
Image(systemName: "arrowshape.turn.up.left.2.fill")
|
||||
}
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = message.messagePayload
|
||||
}) {
|
||||
Text("copy")
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
Menu("message.details") {
|
||||
VStack {
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
Text(" \(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray)
|
||||
}
|
||||
if !currentUser {
|
||||
VStack {
|
||||
Text("SNR \(String(format: "%.2f", message.snr)) dB")
|
||||
}
|
||||
}
|
||||
if currentUser && message.receivedACK {
|
||||
VStack {
|
||||
Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")")
|
||||
}
|
||||
} else if currentUser && message.ackError == 0 {
|
||||
// Empty Error
|
||||
Text("waiting")
|
||||
} else if currentUser && message.ackError > 0 {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if currentUser {
|
||||
VStack {
|
||||
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
|
||||
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
|
||||
if ackDate >= sixMonthsAgo! {
|
||||
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss a"))").foregroundColor(.gray)
|
||||
} else {
|
||||
Text("unknown.age").foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
if message.ackSNR != 0 {
|
||||
VStack {
|
||||
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive, action: {
|
||||
self.showDeleteMessageAlert = true
|
||||
self.deleteMessageId = message.messageId
|
||||
print(deleteMessageId)
|
||||
}) {
|
||||
Text("delete")
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
HStack {
|
||||
MessageText(
|
||||
message: message,
|
||||
tapBackDestination: .channel(channel),
|
||||
isCurrentUser: currentUser
|
||||
) {
|
||||
self.replyMessageId = message.messageId
|
||||
self.messageFieldFocused = true
|
||||
}
|
||||
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
|
||||
if tapbacks.count > 0 {
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
ForEach( tapbacks ) { (tapback: MessageEntity) in
|
||||
VStack {
|
||||
let image = tapback.messagePayload!.image(fontSize: 20)
|
||||
Image(uiImage: image!).font(.caption)
|
||||
Text("\(tapback.fromUser?.shortName ?? "?")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.fixedSize()
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
.onAppear {
|
||||
if !tapback.read {
|
||||
tapback.read = true
|
||||
do {
|
||||
try context.save()
|
||||
print("📖 Read message \(message.messageId) ")
|
||||
appState.unreadChannelMessages = myInfo.unreadMessages
|
||||
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
|
||||
context.refresh(myInfo, mergeChanges: true)
|
||||
} catch {
|
||||
print("Failed to read tapback \(tapback.messageId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color.gray, lineWidth: 1)
|
||||
)
|
||||
|
||||
if currentUser && message.canRetry {
|
||||
RetryButton(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
TapbackResponses(message: message) {
|
||||
appState.unreadChannelMessages = myInfo.unreadMessages
|
||||
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
|
||||
context.refresh(myInfo, mergeChanges: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
if currentUser && message.receivedACK {
|
||||
|
|
@ -218,17 +87,13 @@ struct ChannelMessageList: View {
|
|||
.font(.caption2).foregroundColor(.red)
|
||||
} else if isDetectionSensorMessage {
|
||||
let messageDate = message.timestamp
|
||||
Text(" \(messageDate.formattedDate(format: dateFormatString))").font(.caption2).foregroundColor(.gray)
|
||||
Text(" \(messageDate.formattedDate(format: MessageText.dateFormatString))").font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
.id(channel.allPrivateMessages.firstIndex(of: message))
|
||||
|
||||
if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) {
|
||||
RetryButton(message: message)
|
||||
}
|
||||
|
||||
if !currentUser {
|
||||
Spacer(minLength: 50)
|
||||
}
|
||||
|
|
@ -236,21 +101,6 @@ struct ChannelMessageList: View {
|
|||
.padding([.leading, .trailing])
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(message.messageId)
|
||||
.alert(isPresented: $showDeleteMessageAlert) {
|
||||
Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) {
|
||||
print("OK button tapped")
|
||||
if deleteMessageId > 0 {
|
||||
let message = channel.allPrivateMessages.first(where: { $0.messageId == deleteMessageId })
|
||||
context.delete(message!)
|
||||
do {
|
||||
try context.save()
|
||||
deleteMessageId = 0
|
||||
} catch {
|
||||
print("Failed to delete message \(deleteMessageId)")
|
||||
}
|
||||
}
|
||||
}, secondaryButton: .cancel())
|
||||
}
|
||||
.onAppear {
|
||||
if !message.read {
|
||||
message.read = true
|
||||
|
|
@ -286,7 +136,7 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
|
||||
TextMessageField(
|
||||
destination: .channel(channel.index),
|
||||
destination: .channel(channel),
|
||||
replyMessageId: $replyMessageId,
|
||||
isFocused: $messageFieldFocused
|
||||
) {
|
||||
|
|
|
|||
115
Meshtastic/Views/Messages/MessageContextMenuItems.swift
Normal file
115
Meshtastic/Views/Messages/MessageContextMenuItems.swift
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct MessageContextMenuItems: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
|
||||
let message: MessageEntity
|
||||
let tapBackDestination: MessageDestination
|
||||
let isCurrentUser: Bool
|
||||
@Binding var isShowingDeleteConfirmation: Bool
|
||||
let onReply: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Text("channel") + Text(": \(message.channel)")
|
||||
}
|
||||
|
||||
Menu("tapback") {
|
||||
ForEach(Tapbacks.allCases) { tb in
|
||||
Button {
|
||||
let sentMessage = bleManager.sendMessage(
|
||||
message: tb.emojiString,
|
||||
toUserNum: tapBackDestination.userNum,
|
||||
channel: tapBackDestination.channelNum,
|
||||
isEmoji: true,
|
||||
replyID: message.messageId
|
||||
)
|
||||
if sentMessage {
|
||||
self.context.refresh(tapBackDestination.managedObject, mergeChanges: true)
|
||||
}
|
||||
} label: {
|
||||
Text(tb.description)
|
||||
Image(uiImage: tb.emojiString.image()!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onReply) {
|
||||
Text("reply")
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = message.messagePayload
|
||||
} label: {
|
||||
Text("copy")
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
|
||||
Menu("message.details") {
|
||||
VStack {
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
Text("\(messageDate.formattedDate(format: MessageText.dateFormatString))").foregroundColor(.gray)
|
||||
}
|
||||
if !isCurrentUser {
|
||||
VStack {
|
||||
Text("SNR \(String(format: "%.2f", message.snr)) dB")
|
||||
}
|
||||
}
|
||||
if isCurrentUser && message.receivedACK {
|
||||
VStack {
|
||||
Text("received.ack") + Text(": \(message.receivedACK ? "✔️" : "")")
|
||||
Text("received.ack.real") + Text(": \(message.realACK ? "✔️" : "")")
|
||||
}
|
||||
} else if isCurrentUser && message.ackError == 0 {
|
||||
// Empty Error
|
||||
Text("waiting")
|
||||
} else if isCurrentUser && message.ackError > 0 {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
Text("\(ackErrorVal?.display ?? "Empty Ack Error")")
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if isCurrentUser {
|
||||
VStack {
|
||||
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
|
||||
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
|
||||
if ackDate >= sixMonthsAgo! {
|
||||
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))")
|
||||
.foregroundColor(.gray)
|
||||
} else {
|
||||
Text("unknown.age")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
if message.ackSNR != 0 {
|
||||
VStack {
|
||||
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
isShowingDeleteConfirmation = true
|
||||
} label: {
|
||||
Text("delete")
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension MessageDestination {
|
||||
var managedObject: NSManagedObject {
|
||||
switch self {
|
||||
case let .user(user): return user
|
||||
case let .channel(channel): return channel
|
||||
}
|
||||
}
|
||||
}
|
||||
89
Meshtastic/Views/Messages/MessageText.swift
Normal file
89
Meshtastic/Views/Messages/MessageText.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageText: View {
|
||||
static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
static let localeDateFormat = DateFormatter.dateFormat(
|
||||
fromTemplate: "yyMMddjmmssa",
|
||||
options: 0,
|
||||
locale: Locale.current
|
||||
)
|
||||
static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
let message: MessageEntity
|
||||
let tapBackDestination: MessageDestination
|
||||
let isCurrentUser: Bool
|
||||
let onReply: () -> Void
|
||||
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
|
||||
var body: some View {
|
||||
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
return Text(markdownText)
|
||||
.tint(Self.linkBlue)
|
||||
.padding(10)
|
||||
.foregroundColor(.white)
|
||||
.background(isCurrentUser ? .accentColor : Color(.gray))
|
||||
.cornerRadius(15)
|
||||
.overlay {
|
||||
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
|
||||
if tapBackDestination.overlaySensorMessage {
|
||||
VStack {
|
||||
if #available(iOS 17.0, macOS 14.0, *) {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
} else {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
MessageContextMenuItems(
|
||||
message: message,
|
||||
tapBackDestination: tapBackDestination,
|
||||
isCurrentUser: isCurrentUser,
|
||||
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
|
||||
onReply: onReply
|
||||
)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to delete this message?",
|
||||
isPresented: $isShowingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Message", role: .destructive) {
|
||||
context.delete(message)
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to delete message \(message.messageId)")
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension MessageDestination {
|
||||
var overlaySensorMessage: Bool {
|
||||
switch self {
|
||||
case .user: return false
|
||||
case .channel: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
49
Meshtastic/Views/Messages/TapbackResponses.swift
Normal file
49
Meshtastic/Views/Messages/TapbackResponses.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import SwiftUI
|
||||
|
||||
struct TapbackResponses: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
let message: MessageEntity
|
||||
let onRead: () -> Void
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
|
||||
if !tapbacks.isEmpty {
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
ForEach( tapbacks ) { (tapback: MessageEntity) in
|
||||
VStack {
|
||||
let image = tapback.messagePayload!.image(fontSize: 20)
|
||||
Image(uiImage: image!).font(.caption)
|
||||
Text("\(tapback.fromUser?.shortName ?? "?")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.fixedSize()
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
.onAppear {
|
||||
guard !tapback.read else {
|
||||
return
|
||||
}
|
||||
|
||||
tapback.read = true
|
||||
do {
|
||||
try context.save()
|
||||
print("📖 Read tapback \(tapback.messageId) ")
|
||||
onRead()
|
||||
} catch {
|
||||
print("Failed to read tapback \(tapback.messageId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color.gray, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,15 +4,10 @@ struct TextMessageField: View {
|
|||
static let maxbytes = 228
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
|
||||
let destination: Destination
|
||||
let destination: MessageDestination
|
||||
@Binding var replyMessageId: Int64
|
||||
@FocusState.Binding var isFocused: Bool
|
||||
let onSubmit: () -> Void
|
||||
|
||||
enum Destination {
|
||||
case user(Int64)
|
||||
case channel(Int32)
|
||||
}
|
||||
|
||||
@State private var typingMessage: String = ""
|
||||
@State private var totalBytes = 0
|
||||
|
|
@ -125,7 +120,7 @@ struct TextMessageField: View {
|
|||
}
|
||||
}
|
||||
|
||||
private extension TextMessageField.Destination {
|
||||
private extension MessageDestination {
|
||||
var positionShareMessage: String {
|
||||
switch self {
|
||||
case .user: return "has shared their position and requested a response with your position"
|
||||
|
|
@ -133,23 +128,9 @@ private extension TextMessageField.Destination {
|
|||
}
|
||||
}
|
||||
|
||||
var userNum: Int64 {
|
||||
switch self {
|
||||
case let .user(num): return num
|
||||
case .channel: return 0
|
||||
}
|
||||
}
|
||||
|
||||
var channelNum: Int32 {
|
||||
switch self {
|
||||
case .user: return 0
|
||||
case let .channel(num): return num
|
||||
}
|
||||
}
|
||||
|
||||
var positionDestNum: Int64 {
|
||||
switch self {
|
||||
case let .user(num): return num
|
||||
case let .user(user): return user.num
|
||||
case .channel: return Int64(BLEManager.emptyNodeNum)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,9 +87,6 @@ struct UserList: View {
|
|||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
// Image(systemName: "chevron.forward")
|
||||
// .font(.caption)
|
||||
// .foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if user.messageList.count > 0 {
|
||||
|
|
|
|||
|
|
@ -17,14 +17,10 @@ struct UserMessageList: View {
|
|||
@FocusState var messageFieldFocused: Bool
|
||||
// View State Items
|
||||
@ObservedObject var user: UserEntity
|
||||
@State var showDeleteMessageAlert = false
|
||||
@State private var deleteMessageId: Int64 = 0
|
||||
@State private var replyMessageId: Int64 = 0
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmmss", options: 0, locale: Locale.current)
|
||||
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
|
||||
ScrollViewReader { scrollView in
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
|
|
@ -50,138 +46,26 @@ struct UserMessageList: View {
|
|||
HStack(alignment: .top) {
|
||||
if currentUser { Spacer(minLength: 50) }
|
||||
VStack(alignment: currentUser ? .trailing : .leading) {
|
||||
let markdownText: LocalizedStringKey = LocalizedStringKey.init(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
|
||||
let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
Text(markdownText)
|
||||
.tint(linkBlue)
|
||||
.padding(10)
|
||||
.foregroundColor(.white)
|
||||
.background(currentUser ? .accentColor : Color(.gray))
|
||||
.cornerRadius(15)
|
||||
.contextMenu {
|
||||
VStack {
|
||||
Text("channel")+Text(": \(message.channel)")
|
||||
}
|
||||
Menu("tapback") {
|
||||
ForEach(Tapbacks.allCases) { tb in
|
||||
Button(action: {
|
||||
if bleManager.sendMessage(message: tb.emojiString, toUserNum: user.num, channel: 0, isEmoji: true, replyID: message.messageId) {
|
||||
print("Sent \(tb.emojiString) Tapback")
|
||||
self.context.refresh(user, mergeChanges: true)
|
||||
} else { print("\(tb.emojiString) Tapback Failed") }
|
||||
|
||||
}) {
|
||||
Text(tb.description)
|
||||
let image = tb.emojiString.image()
|
||||
Image(uiImage: image!)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
self.replyMessageId = message.messageId
|
||||
self.messageFieldFocused = true
|
||||
print("I want to reply to \(message.messageId)")
|
||||
}) {
|
||||
Text("reply")
|
||||
Image(systemName: "arrowshape.turn.up.left.2.fill")
|
||||
}
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = message.messagePayload
|
||||
}) {
|
||||
Text("copy")
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
Menu("message.details") {
|
||||
VStack {
|
||||
|
||||
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.messageTimestamp))
|
||||
Text("\(messageDate.formattedDate(format: dateFormatString))").foregroundColor(.gray)
|
||||
}
|
||||
if !currentUser {
|
||||
VStack {
|
||||
Text("SNR \(String(format: "%.2f", message.snr)) dB")
|
||||
}
|
||||
}
|
||||
if currentUser && message.receivedACK {
|
||||
VStack {
|
||||
Text("received.ack")+Text(" \(message.receivedACK ? "✔️" : "")")
|
||||
Text("received.ack.real")+Text(" \(message.realACK ? "✔️" : "")")
|
||||
}
|
||||
} else if currentUser && message.ackError == 0 {
|
||||
// Empty Error
|
||||
Text("waiting")
|
||||
} else if currentUser && message.ackError > 0 {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
Text("\(ackErrorVal?.display ?? "Empty Ack Error")").fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if currentUser {
|
||||
VStack {
|
||||
let ackDate = Date(timeIntervalSince1970: TimeInterval(message.ackTimestamp))
|
||||
let sixMonthsAgo = Calendar.current.date(byAdding: .month, value: -6, to: Date())
|
||||
if ackDate >= sixMonthsAgo! {
|
||||
Text("Ack Time: \(ackDate.formattedDate(format: "h:mm:ss.SSSS a"))").foregroundColor(.gray)
|
||||
} else {
|
||||
Text("unknown.age").font(.caption2).foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
if message.ackSNR != 0 {
|
||||
VStack {
|
||||
Text("Ack SNR: \(String(format: "%.2f", message.ackSNR)) dB")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive, action: {
|
||||
self.showDeleteMessageAlert = true
|
||||
self.deleteMessageId = message.messageId
|
||||
print(deleteMessageId)
|
||||
}) {
|
||||
Text("delete")
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
HStack {
|
||||
MessageText(
|
||||
message: message,
|
||||
tapBackDestination: .user(user),
|
||||
isCurrentUser: currentUser
|
||||
) {
|
||||
self.replyMessageId = message.messageId
|
||||
self.messageFieldFocused = true
|
||||
}
|
||||
|
||||
let tapbacks = message.value(forKey: "tapbacks") as? [MessageEntity] ?? []
|
||||
if tapbacks.count > 0 {
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
ForEach( tapbacks ) { (tapback: MessageEntity) in
|
||||
VStack {
|
||||
let image = tapback.messagePayload!.image(fontSize: 20)
|
||||
Image(uiImage: image!).font(.caption)
|
||||
Text("\(tapback.fromUser?.shortName ?? "?")")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
.fixedSize()
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
.onAppear {
|
||||
if !tapback.read {
|
||||
tapback.read = true
|
||||
do {
|
||||
try context.save()
|
||||
print("📖 Read tapback \(tapback.messageId) ")
|
||||
appState.unreadDirectMessages = user.unreadMessages
|
||||
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
|
||||
|
||||
} catch {
|
||||
print("Failed to read tapback \(tapback.messageId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color.gray, lineWidth: 1)
|
||||
)
|
||||
if currentUser && message.canRetry || (message.receivedACK && !message.realACK) {
|
||||
RetryButton(message: message)
|
||||
}
|
||||
}
|
||||
|
||||
TapbackResponses(message: message) {
|
||||
appState.unreadDirectMessages = user.unreadMessages
|
||||
UIApplication.shared.applicationIconBadgeNumber = appState.unreadChannelMessages + appState.unreadDirectMessages
|
||||
}
|
||||
|
||||
HStack {
|
||||
let ackErrorVal = RoutingError(rawValue: Int(message.ackError))
|
||||
if currentUser && message.receivedACK {
|
||||
|
|
@ -203,10 +87,6 @@ struct UserMessageList: View {
|
|||
.padding(.bottom)
|
||||
.id(user.messageList.firstIndex(of: message))
|
||||
|
||||
if currentUser && (message.ackError == 9 || message.ackError == 5 || message.ackError == 3) || (message.receivedACK && !message.realACK) {
|
||||
RetryButton(message: message)
|
||||
}
|
||||
|
||||
if !currentUser {
|
||||
Spacer(minLength: 50)
|
||||
}
|
||||
|
|
@ -214,20 +94,6 @@ struct UserMessageList: View {
|
|||
.padding([.leading, .trailing])
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(message.messageId)
|
||||
.alert(isPresented: $showDeleteMessageAlert) {
|
||||
Alert(title: Text("Are you sure you want to delete this message?"), message: Text("This action is permanent."), primaryButton: .destructive(Text("Delete")) {
|
||||
if deleteMessageId > 0 {
|
||||
let message = user.messageList.first(where: { $0.messageId == deleteMessageId })
|
||||
context.delete(message!)
|
||||
do {
|
||||
try context.save()
|
||||
deleteMessageId = 0
|
||||
} catch {
|
||||
print("Failed to delete message \(deleteMessageId)")
|
||||
}
|
||||
}
|
||||
}, secondaryButton: .cancel())
|
||||
}
|
||||
.onAppear {
|
||||
if !message.read {
|
||||
message.read = true
|
||||
|
|
@ -264,7 +130,7 @@ struct UserMessageList: View {
|
|||
}
|
||||
|
||||
TextMessageField(
|
||||
destination: .user(user.num),
|
||||
destination: .user(user),
|
||||
replyMessageId: $replyMessageId,
|
||||
isFocused: $messageFieldFocused
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ struct NodeListItem: View {
|
|||
.frame(width: 30)
|
||||
Text("Channel: \(node.channel)")
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption)
|
||||
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
|
||||
}
|
||||
if node.viaMqtt && connectedNode != node.num {
|
||||
Image(systemName: "network")
|
||||
|
|
@ -133,7 +133,7 @@ struct NodeListItem: View {
|
|||
.frame(width: 30)
|
||||
Text("Via MQTT")
|
||||
.foregroundColor(.gray)
|
||||
.font(.caption)
|
||||
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
|
||||
}
|
||||
}
|
||||
if node.hasPositions || node.hasEnvironmentMetrics || node.hasDetectionSensorMetrics || node.hasTraceRoutes {
|
||||
|
|
@ -144,7 +144,7 @@ struct NodeListItem: View {
|
|||
.frame(width: 30)
|
||||
Text("Logs:")
|
||||
.foregroundColor(.gray)
|
||||
.font(.callout)
|
||||
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
|
||||
if node.hasDeviceMetrics {
|
||||
Image(systemName: "flipphone")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
|
|
@ -183,7 +183,6 @@ struct NodeListItem: View {
|
|||
LoRaSignalStrengthMeter(snr: node.snr, rssi: node.rssi, preset: preset ?? ModemPresets.longFast, compact: true)
|
||||
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,28 @@ struct AppSettings: View {
|
|||
var body: some View {
|
||||
VStack {
|
||||
Form {
|
||||
Section(header: Text("options")) {
|
||||
Section(header: Text("Location Settings")) {
|
||||
Toggle(isOn: $provideLocation) {
|
||||
Label("appsettings.provide.location", systemImage: "location.circle.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
if provideLocation {
|
||||
Toggle(isOn: $enableSmartPosition) {
|
||||
Label("appsettings.smartposition", systemImage: "brain.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
VStack {
|
||||
Picker("update.interval", selection: $provideLocationInterval) {
|
||||
ForEach(LocationUpdateInterval.allCases) { lu in
|
||||
Text(lu.description)
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
Text("phone.gps.interval.description")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: $useLegacyMap) {
|
||||
Label("map.use.legacy", systemImage: "map")
|
||||
}
|
||||
|
|
@ -63,35 +84,6 @@ struct AppSettings: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("Location Settings")) {
|
||||
Toggle(isOn: $provideLocation) {
|
||||
Label("appsettings.provide.location", systemImage: "location.circle.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
if provideLocation {
|
||||
Toggle(isOn: $enableSmartPosition) {
|
||||
Label("appsettings.smartposition", systemImage: "brain.fill")
|
||||
}
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
.onChange(of: (enableSmartPosition)) { newEnableSmartPosition in
|
||||
UserDefaults.enableSmartPosition = newEnableSmartPosition
|
||||
}
|
||||
VStack {
|
||||
Picker("update.interval", selection: $provideLocationInterval) {
|
||||
ForEach(LocationUpdateInterval.allCases) { lu in
|
||||
Text(lu.description)
|
||||
}
|
||||
}
|
||||
.pickerStyle(DefaultPickerStyle())
|
||||
.onChange(of: (provideLocationInterval)) { newProvideLocationInterval in
|
||||
UserDefaults.provideLocationInterval = newProvideLocationInterval
|
||||
}
|
||||
Text("phone.gps.interval.description")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(header: Text("App Data")) {
|
||||
Button {
|
||||
isPresentingCoreDataResetConfirm = true
|
||||
|
|
@ -158,6 +150,12 @@ struct AppSettings: View {
|
|||
self.bleManager.sendWantConfig()
|
||||
}
|
||||
}
|
||||
.onChange(of: enableSmartPosition) { newEnableSmartPosition in
|
||||
UserDefaults.enableSmartPosition = newEnableSmartPosition
|
||||
}
|
||||
.onChange(of: (provideLocationInterval)) { newProvideLocationInterval in
|
||||
UserDefaults.provideLocationInterval = newProvideLocationInterval
|
||||
}
|
||||
.onChange(of: useLegacyMap) { newMapUseLegacy in
|
||||
UserDefaults.mapUseLegacy = newMapUseLegacy
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
#if canImport(TipKit)
|
||||
import TipKit
|
||||
#endif
|
||||
|
||||
func generateChannelKey(size: Int) -> String {
|
||||
var keyData = Data(count: size)
|
||||
|
|
@ -40,7 +43,11 @@ struct Channels: View {
|
|||
var body: some View {
|
||||
|
||||
VStack {
|
||||
|
||||
List {
|
||||
if #available(iOS 17.0, macOS 14.0, *) {
|
||||
TipView(CreateChannelsTip(), arrowEdge: .bottom)
|
||||
}
|
||||
if node != nil && node?.myInfo != nil {
|
||||
ForEach(node?.myInfo?.channels?.array as? [ChannelEntity] ?? [], id: \.self) { (channel: ChannelEntity) in
|
||||
Button(action: {
|
||||
|
|
@ -91,7 +98,9 @@ struct Channels: View {
|
|||
if node?.myInfo?.channels?.array.count ?? 0 < 8 && node != nil {
|
||||
|
||||
Button {
|
||||
let key = generateChannelKey(size: 32)
|
||||
channelKeySize = 16
|
||||
let key = generateChannelKey(size: channelKeySize)
|
||||
|
||||
channelName = ""
|
||||
channelIndex = Int32(node!.myInfo!.channels!.array.count)
|
||||
channelRole = 2
|
||||
|
|
@ -201,18 +210,21 @@ struct Channels: View {
|
|||
.disabled(channelKeySize <= 0)
|
||||
}
|
||||
HStack {
|
||||
Text("Role")
|
||||
Spacer()
|
||||
Picker("Channel Role", selection: $channelRole) {
|
||||
if channelRole == 1 {
|
||||
if channelRole == 1 {
|
||||
Picker("Channel Role", selection: $channelRole) {
|
||||
Text("Primary").tag(1)
|
||||
} else {
|
||||
Text("Disabled").tag(0)
|
||||
Text("Secondary").tag(2)
|
||||
}
|
||||
.pickerStyle(.automatic)
|
||||
.disabled(true)
|
||||
} else {
|
||||
Text("Channel Role")
|
||||
Spacer()
|
||||
Picker("Channel Role", selection: $channelRole) {
|
||||
Text("Disabled").tag(0)
|
||||
Text("Secondary").tag(2)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(channelRole == 1)
|
||||
}
|
||||
Toggle("Uplink Enabled", isOn: $uplink)
|
||||
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ struct StoreForwardConfig: View {
|
|||
}
|
||||
|
||||
if isRouter {
|
||||
Section(header: Text("options")) {
|
||||
Section(header: Text("Router Options")) {
|
||||
Toggle(isOn: $heartbeat) {
|
||||
Label("storeforward.heartbeat", systemImage: "waveform.path.ecg")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,27 +117,10 @@ struct Settings: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Text("Configuring Node \(node?.user?.longName ?? "unknown".localized)")
|
||||
Text("Connected Node \(node?.user?.longName ?? "unknown".localized)")
|
||||
}
|
||||
}
|
||||
Section("radio.configuration") {
|
||||
NavigationLink {
|
||||
ShareChannels(node: nodes.first(where: { $0.num == preferredNodeNum }))
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("share.channels")
|
||||
}
|
||||
.tag(SettingsSidebar.shareChannels)
|
||||
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
|
||||
NavigationLink {
|
||||
UserConfig(node: nodes.first(where: { $0.num == selectedNode }))
|
||||
} label: {
|
||||
Image(systemName: "person.crop.rectangle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("user")
|
||||
}
|
||||
.tag(SettingsSidebar.userConfig)
|
||||
NavigationLink {
|
||||
LoRaConfig(node: nodes.first(where: { $0.num == selectedNode }))
|
||||
} label: {
|
||||
|
|
@ -155,6 +138,25 @@ struct Settings: View {
|
|||
}
|
||||
.tag(SettingsSidebar.channelConfig)
|
||||
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
|
||||
NavigationLink {
|
||||
ShareChannels(node: nodes.first(where: { $0.num == preferredNodeNum }))
|
||||
} label: {
|
||||
Image(systemName: "qrcode")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("share.channels")
|
||||
}
|
||||
.tag(SettingsSidebar.shareChannels)
|
||||
.disabled(selectedNode > 0 && selectedNode != preferredNodeNum)
|
||||
}
|
||||
Section("device.configuration") {
|
||||
NavigationLink {
|
||||
UserConfig(node: nodes.first(where: { $0.num == selectedNode }))
|
||||
} label: {
|
||||
Image(systemName: "person.crop.rectangle.fill")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
Text("user")
|
||||
}
|
||||
.tag(SettingsSidebar.userConfig)
|
||||
NavigationLink {
|
||||
BluetoothConfig(node: nodes.first(where: { $0.num == selectedNode }))
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@
|
|||
"timestamp"="Timestamp";
|
||||
"tip.bluetooth.connect.title"="Connected LoRa Radio";
|
||||
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
|
||||
"tip.channels.create.title"="Manage Channels";
|
||||
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
|
||||
"tip.channels.share.title"="Sharing Meshtastic Channels";
|
||||
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
|
||||
"tip.messages.title"="Messages";
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
"device.role.routerclient"="Combination of both ROUTER and CLIENT. Not for mobile devices.";
|
||||
"direct.messages"="Direct Messages";
|
||||
"dismiss.keyboard"="Dismiss";
|
||||
"display"="Display (Device Screen)";
|
||||
"display"="Display";
|
||||
"display.config"="Display Config";
|
||||
"distance"="Distance";
|
||||
"disconnect"="Disconnect";
|
||||
|
|
@ -270,7 +270,7 @@
|
|||
"serial.mode.txtmsg"="Text Message";
|
||||
"serial.mode.nmea"="NMEA Positions";
|
||||
"settings"="Settings";
|
||||
"share.channels"="Share Channels QR Code";
|
||||
"share.channels"="Share QR Code";
|
||||
"share.position"="Share Position";
|
||||
"subscribed"="Subscribed to mesh";
|
||||
"select.contact"="Select a Contact";
|
||||
|
|
@ -297,9 +297,11 @@
|
|||
"timeout"="Timeout";
|
||||
"timestamp"="Timestamp";
|
||||
"tip.bluetooth.connect.title"="Connected Radio";
|
||||
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
|
||||
"tip.bluetooth.connect.message"="Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
|
||||
"tip.channels.create.title"="Manage Channels";
|
||||
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/tips/)";
|
||||
"tip.channels.share.title"="Sharing Meshtastic Channels";
|
||||
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
|
||||
"tip.channels.share.message"="A Meshtastic QR code contains the LoRa config and channel values needed to communicate. Most mesh activity takes place on the required Primary channel. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. Other channels are for private groups, each with its own key.";
|
||||
"tip.messages.title"="Messages";
|
||||
"tip.messages.message"="You can send and receive channel (group chats) and direct messages. From any message you can long press to see available actions like copy, reply, tapback and delete as well as delivery details.";
|
||||
"twitter"="Twitter";
|
||||
|
|
|
|||
|
|
@ -297,8 +297,9 @@
|
|||
"timeout"="זמן קצוב";
|
||||
"timestamp"="שעה/תאריך";
|
||||
"tip.bluetooth.connect.title"="מכשיר מחובר";
|
||||
מראה מידע אודות מכשיר המשטסטיק המחובר כעת לבלוטוס. ניתן לגרור שמאלה להתנתקות או לחיצה ארוכה לראות סטטיסטיקה או להתחיל פעילות.
|
||||
"tip.bluetooth.connect.message"="מראה מידע אודות מכשיר המשטסטיק המחובר כעת לבלוטוס. ניתן לגרור שמאלה להתנתקות או לחיצה ארוכה לראות סטטיסטיקה או להתחיל פעילות.";
|
||||
"tip.channels.create.title"="Manage Channels";
|
||||
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
|
||||
"tip.channels.share.title"="משתף ערוצי משטסטיק";
|
||||
"tip.channels.share.message"="במשטסטיק יש עד 8 ערוצים. הראשון הינו הראשי והינו היכן שרוב הפעילות מתבצעת והכרחי. אם לא תשתף את הערוץ הראשי שלך הערוץ הראשון שלך נהיה הערוץ הראשי ברשת השניה. הוא מדבר בערוץ הראשי שלו במשני שלך. ערוץ בעל השם 'admin' הינו לשליטה מרחוק. ערוצים נוספים הינם לקבוצות פרטיות, כל אחת עם מפתח הצפנה משלה.";
|
||||
"tip.messages.title"="הודעות";
|
||||
|
|
|
|||
|
|
@ -292,6 +292,8 @@
|
|||
"timestamp"="Znacznik czasu";
|
||||
"tip.bluetooth.connect.title"="Connected LoRa Radio";
|
||||
"tip.bluetooth.connect.message"="Shows information for the Lora radio currently connected via bluetooth. You can swipe left to disconnect the radio and long press to view stats or start the live activity.";
|
||||
"tip.channels.create.title"="Manage Channels";
|
||||
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
|
||||
"tip.channels.share.title"="Sharing Meshtastic Channels";
|
||||
"tip.channels.share.message"="In a Meshtastic LoRa Mesh there are up to 8 channels. The first one is the Primary channel where most activity happens and is required. If you don't share your primary channel your first shared channel becomes the primary channel on the other network. It talks on its primary and your secondary channel. A channel with the name 'admin' controls nodes remotely. Other channels are for private groups, each with its own key.";
|
||||
"tip.messages.title"="Messages";
|
||||
|
|
|
|||
|
|
@ -291,6 +291,8 @@
|
|||
"timestamp"="时间戳";
|
||||
"tip.bluetooth.connect.title"="连接到 LoRa 电台";
|
||||
"tip.bluetooth.connect.message"="显示当前通过蓝牙连接的 Lora 电台的信息。您可以向左滑动断开电台,长按查看统计信息或开始实时活动。";
|
||||
"tip.channels.create.title"="Manage Channels";
|
||||
"tip.channels.create.message"="Most data on your mesh is sent over the primary channel. You can set up secondary channels to create additional messaging groups secured by their own key. [Channel config tips](https://meshtastic.org/docs/configuration/radio/channels/)";
|
||||
"tip.channels.share.title"="共享 Meshtastic 频道";
|
||||
"tip.channels.share.message"="在 Meshtastic 网络中最多有 8 个频道。第一个频道是主频道,大多数活动都发生在这里,也是必需的。如果您不共享主频道,您的第一个共享频道就会成为其他网络的主频道。它会在其主频道和您的辅助频道上对话。名称为 admin 的频道可远程控制节点。其他频道用于私人群组,每个群组都有自己的密钥。";
|
||||
"tip.messages.title"="消息";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue