Clean up messages detail view, automatically scroll to bottom of message list when loading

This commit is contained in:
Garth Vander Houwen 2021-09-26 20:12:38 -07:00
parent 2c13a9ed95
commit 029ac8556f
11 changed files with 217 additions and 56 deletions

View file

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
DD23A50F26FD1B4400D9B90C /* PeripheralModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */; };
DD23A51326FEF5D500D9B90C /* MessageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD23A51226FEF5D500D9B90C /* MessageData.swift */; };
DD47E3CC26F0E51D00029299 /* NodeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CB26F0E51D00029299 /* NodeDetail.swift */; };
DD47E3CE26F103C600029299 /* NodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CD26F103C600029299 /* NodeList.swift */; };
DD47E3D026F1073F00029299 /* NodeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD47E3CF26F1073F00029299 /* NodeRow.swift */; };
@ -64,6 +65,7 @@
/* Begin PBXFileReference section */
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeripheralModel.swift; sourceTree = "<group>"; };
DD23A51226FEF5D500D9B90C /* MessageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageData.swift; sourceTree = "<group>"; };
DD47E3CB26F0E51D00029299 /* NodeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetail.swift; sourceTree = "<group>"; };
DD47E3CD26F103C600029299 /* NodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeList.swift; sourceTree = "<group>"; };
DD47E3CF26F1073F00029299 /* NodeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeRow.swift; sourceTree = "<group>"; };
@ -260,6 +262,7 @@
DD836AEC26F858F900ABCC23 /* MeshData.swift */,
DD836AEE26F85D8D00ABCC23 /* NodeInfoModel.swift */,
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
DD23A51226FEF5D500D9B90C /* MessageData.swift */,
);
path = Model;
sourceTree = "<group>";
@ -449,6 +452,7 @@
DD90860E26F69BAE00DC5189 /* NodeMap.swift in Sources */,
DD47E3DB26F3901B00029299 /* MessageList.swift in Sources */,
DDAF8C6926ED0D070058C060 /* deviceonly.pb.swift in Sources */,
DD23A51326FEF5D500D9B90C /* MessageData.swift in Sources */,
DD836AED26F858F900ABCC23 /* MeshData.swift in Sources */,
DDAF8C6B26ED0DD80058C060 /* environmental_measurement.pb.swift in Sources */,
DD90860C26F684AF00DC5189 /* BatteryIcon.swift in Sources */,
@ -637,10 +641,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.14;
MARKETING_VERSION = 1.15;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@ -663,10 +668,11 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.14;
MARKETING_VERSION = 1.15;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};

View file

@ -13,7 +13,6 @@ struct MeshtasticClientApp: App {
@ObservedObject private var meshData: MeshData = MeshData()
@ObservedObject private var bleManager: BLEManager = BLEManager()
//@ObservedObject var meshData: MeshData
var body: some Scene {
WindowGroup {
ContentView()

View file

@ -0,0 +1,40 @@
import Foundation
class MessageData: ObservableObject {
private static var documentsFolder: URL {
do {
return try FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
} catch {
fatalError("Can't find documents directory.")
}
}
private static var fileURL: URL {
return documentsFolder.appendingPathComponent("messages.data")
}
@Published var messages: [MessageModel] = []
func load() {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let data = try? Data(contentsOf: Self.fileURL) else {
#if DEBUG
DispatchQueue.main.async {
self?.messages = MessageModel.data
}
#endif
return
}
guard let messageList = try? JSONDecoder().decode([MessageModel].self, from: data) else {
fatalError("Can't decode saved node data.")
}
DispatchQueue.main.async {
self?.messages = messageList
}
}
}
}

View file

@ -6,13 +6,13 @@
//
import Foundation
struct MessageModel : Identifiable
struct MessageModel : Identifiable, Codable
{
let id: UUID
var messageId: UInt32
var messageTimestamp: Int64
var fromUserId: String
var toUserId: String
var fromUserId: UInt32
var toUserId: UInt32
var fromUserLongName: String
var toUserLongName: String
var fromUserShortName: String
@ -21,7 +21,7 @@ struct MessageModel : Identifiable
var messagePayload: String
var direction: String
init(id: UUID = UUID(), messageId: UInt32, messageTimeStamp: Int64, fromUserId: String, toUserId: String, fromUserLongName: String, toUserLongName: String, fromUserShortName: String, toUserShortName: String, receivedACK: Bool, messagePayload: String, direction: String)
init(id: UUID = UUID(), messageId: UInt32, messageTimeStamp: Int64, fromUserId: UInt32, toUserId: UInt32, fromUserLongName: String, toUserLongName: String, fromUserShortName: String, toUserShortName: String, receivedACK: Bool, messagePayload: String, direction: String)
{
self.id = id
self.messageId = messageId
@ -38,3 +38,51 @@ struct MessageModel : Identifiable
}
}
extension MessageModel {
static var data: [MessageModel] {
[
// Put dev test data here
MessageModel(messageId: 3773493287, messageTimeStamp: 1632407404, fromUserId: 4064715620, toUserId: 4294967295, fromUserLongName: "TLORA V1 #1", toUserLongName: "Unknown 1", fromUserShortName: "T#", toUserShortName: "U1", receivedACK: false, messagePayload: "I sent a super great message with amazing text", direction: "received"),
MessageModel(messageId: 3773493338, messageTimeStamp: 1632643652, fromUserId: 2930161432, toUserId: 4294967295, fromUserLongName: "TBEAM ARMY GREEN", toUserLongName: "Unknown 1", fromUserShortName: "TAG", toUserShortName: "U1", receivedACK: false, messagePayload: "It was the best message", direction: "received"),
MessageModel(messageId: 3773493338, messageTimeStamp: 1632643652, fromUserId: 2930161432, toUserId: 4294967295, fromUserLongName: "TBEAM ARMY GREEN", toUserLongName: "Unknown 1", fromUserShortName: "TAG", toUserShortName: "U1", receivedACK: false, messagePayload: "SwiftUI is great, but it has been lacking of specific native controls, even though that gets much better year by year. One of them was the text view. When SwiftUI was first released, it had no native equivalent of the text view; implementing a custom UIViewRepresentable type to contain UITextView was the only way to go. But since iOS 14, SwiftUI introduces TextEditor, a brand new view to write multi-line text.", direction: "received")
]
}
}
extension MessageModel {
struct Data {
var id: UUID
var messageId: UInt32
var messageTimestamp: Int64
var fromUserId: UInt32
var toUserId: UInt32
var fromUserLongName: String
var toUserLongName: String
var fromUserShortName: String
var toUserShortName: String
var receivedACK: Bool
var messagePayload: String
var direction: String
}
var data: Data {
return Data(id: id, messageId: messageId, messageTimestamp: messageTimestamp, fromUserId: fromUserId, toUserId: toUserId, fromUserLongName: fromUserLongName, toUserLongName: toUserLongName, fromUserShortName: fromUserShortName, toUserShortName: toUserShortName, receivedACK: receivedACK, messagePayload: messagePayload, direction: direction)
}
mutating func update(from data: Data) {
messageId = data.messageId
messageTimestamp = data.messageTimestamp
fromUserId = data.fromUserId
toUserId = data.toUserId
fromUserLongName = data.fromUserLongName
toUserLongName = data.toUserLongName
fromUserShortName = data.fromUserShortName
toUserShortName = data.toUserShortName
receivedACK = data.receivedACK
messagePayload = data.messagePayload
direction = data.direction
}
}

View file

@ -17,12 +17,12 @@ struct ContentView: View {
var body: some View {
TabView(selection: $selection) {
//MessageList()
// .tabItem {
// Label("Messages", systemImage: "text.bubble")
// .symbolRenderingMode(.hierarchical)
// }
// .tag(Tab.messages)
MessageList()
.tabItem {
Label("Messages", systemImage: "text.bubble")
.symbolRenderingMode(.hierarchical)
}
.tag(Tab.messages)
NodeList()
.tabItem {
Label("Nodes", systemImage: "flipphone")

View file

@ -7,18 +7,27 @@ struct MessageBubble: View {
var shortName: String
var body: some View {
VStack(alignment: isCurrentUser ? .leading : .trailing) {
HStack {
VStack {
HStack (alignment: .top) {
CircleText(text: shortName, color: isCurrentUser ? Color.blue : Color(.darkGray)).padding(.all, 5)
VStack (alignment: .leading) {
Text(contentMessage)
.padding(10)
.foregroundColor(.white)
.background(isCurrentUser ? Color.blue : Color(.darkGray))
.cornerRadius(10)
HStack (spacing: 4) {
let messageDate = Date(timeIntervalSince1970: TimeInterval(time))
Text(messageDate, style: .date).font(.caption2).foregroundColor(.gray)
Text(messageDate, style: .time).font(.caption2).foregroundColor(.gray)
}
.padding(.bottom, 10)
}
Spacer()
}.padding(isCurrentUser ? .leading : .trailing, 70)
}
}.padding(.bottom, 1)
}
}

View file

@ -4,42 +4,100 @@ import CoreLocation
struct MessageDetail: View {
enum Field: Hashable {
case messageText
}
@State var typingMessage: String = ""
@FocusState private var focusedField: Field?
@ObservedObject var messageData: MessageData = MessageData()
@EnvironmentObject var bleManager: BLEManager
@Namespace var topId
@Namespace var bottomId
var body: some View {
// NavigationView {
VStack(alignment: .leading) {
ScrollView {
GeometryReader { bounds in
VStack {
ScrollViewReader { scrollView in
MessageBubble(contentMessage: "I sent a super great message with amazing text", isCurrentUser: true, time: 1, shortName: "GVH")
MessageBubble(contentMessage: "It was amazing to read such a fantastical text", isCurrentUser: false, time: 1, shortName: "RS1")
MessageBubble(contentMessage: "It was the best message", isCurrentUser: false, time: 1, shortName: "RDN")
MessageBubble(contentMessage: "This is a terse response to an amazing text", isCurrentUser: true, time: 1, shortName: "GVH")
MessageBubble(contentMessage: "yo", isCurrentUser: true, time: 1, shortName: "GVH")
MessageBubble(contentMessage: "I sent a super great message with amazing text", isCurrentUser: true, time: 1, shortName: "GVH")
MessageBubble(contentMessage: "It was amazing to read such a fantastical text", isCurrentUser: false, time: 1, shortName: "RS1")
MessageBubble(contentMessage: "It was the best message", isCurrentUser: false, time: 1, shortName: "RDN")
MessageBubble(contentMessage: "This is a terse response to an amazing text", isCurrentUser: true, time: 1, shortName: "GVH")
MessageBubble(contentMessage: "yo", isCurrentUser: true, time: 1, shortName: "GVH")
}.padding([.top, .leading])
HStack (alignment: .bottom) {
TextField("Message", text: $typingMessage)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(minHeight: CGFloat(30))
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill").font(.title).foregroundColor(.blue)
ScrollView {
Text("Hidden Top Anchor")
.hidden()
.frame(height: 0)
.id(topId)
ForEach(messageData.messages.sorted(by: { $0.messageTimestamp < $1.messageTimestamp })) { message in
MessageBubble(contentMessage: message.messagePayload, isCurrentUser: false, time: Int32(message.messageTimestamp), shortName: message.fromUserShortName)
}
}.padding(5)
.onAppear(perform: { scrollView.scrollTo(bottomId) } )
Text("Hidden Bottom Anchor")
.hidden()
.frame(height: 0)
.id(bottomId)
}
.padding([.top, .leading])
}
HStack {
if focusedField != nil {
Button("Dismiss Keyboard") {
focusedField = nil
}
.fixedSize()
.frame(height: 15, alignment: .center)
.padding(.top, 10)
}
}
HStack (alignment: .top) {
ZStack {
TextEditor(text: $typingMessage)
.onChange(of: typingMessage, perform: { value in
let size = value.utf8.count
if size >= 200 {
print("too big!")
}
print(size)
})
.padding(.horizontal)
.focused($focusedField, equals: .messageText)
.multilineTextAlignment(.leading)
.frame(minHeight: 120, maxHeight: 120)
Text(typingMessage).opacity(0).padding(.all, 2)
}
.overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 2))
.padding(.top)
Button(action: sendMessage) {
Image(systemName: "arrow.up.circle.fill").font(.largeTitle).foregroundColor(.blue)
}
.padding(.top)
}.padding([.leading, .bottom])
}
.navigationTitle("CHANNEL - Primary")
.navigationBarTitleDisplayMode(.inline)
//}
//.navigationViewStyle//(StackNavigationViewStyle())
}
.navigationTitle("CHANNEL - Primary")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing:
ZStack {
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedNode != nil) ? bleManager.connectedNode.user.longName : ((bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.name : "Unknown") ?? "Unknown")
}
)
.onAppear{
messageData.load()
}
}
}

View file

@ -7,16 +7,15 @@ struct MessageList: View {
@State var typingMessage: String = ""
@EnvironmentObject var bleManager: BLEManager
@EnvironmentObject var meshData: MeshData
var body: some View {
NavigationView {
GeometryReader { bounds in
List{
NavigationLink(destination: MessageDetail()) {
NavigationLink(destination: MessageDetail()) {
List{
HStack {

View file

@ -57,7 +57,7 @@ struct NodeDetail: View {
HStack {
VStack(alignment: .center) {
Text("AKA").font(.title2)
Text("AKA").font(.title2).fixedSize()
CircleText(text: node.user.shortName, color: Color.blue)
.offset(y:10)
}
@ -69,7 +69,7 @@ struct NodeDetail: View {
.font(.title)
.foregroundColor(.blue)
.symbolRenderingMode(.hierarchical)
Text("SNR").font(.title2)
Text("SNR").font(.title2).fixedSize()
Text(String(node.snr ?? 0))
.font(.title2)
.foregroundColor(.gray)
@ -77,7 +77,7 @@ struct NodeDetail: View {
Divider()
VStack(alignment: .center) {
BatteryIcon(batteryLevel: node.position.batteryLevel, font: .title, color: Color.blue)
Text("Battery").font(.title2)
Text("Battery").font(.title2).fixedSize()
Text(String(node.position.batteryLevel!) + "%")
.font(.title2)
.foregroundColor(.gray)

View file

@ -60,7 +60,9 @@ struct NodeMap: View {
.navigationBarTitleDisplayMode(.inline)
}
.navigationViewStyle(StackNavigationViewStyle())
.onAppear{
meshData.load()
}
}
}

View file

@ -10,7 +10,7 @@ struct NodeRow: View {
HStack() {
CircleText(text: node.user.shortName, color: Color.blue).offset(y: 1).padding(.trailing, 5)
Text(node.user.longName).font(.title)
Text(node.user.longName).font(.title2)
}
.padding([.trailing])