Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db92487782 | |||
| 3e8d5eb025 |
+10
-15
@@ -1,5 +1,5 @@
|
|||||||
//
|
//
|
||||||
// ContentView.swift
|
// AllNotesScreen.swift
|
||||||
// Peered
|
// Peered
|
||||||
//
|
//
|
||||||
// Created by Oskar Chybowski on 11/05/2025.
|
// Created by Oskar Chybowski on 11/05/2025.
|
||||||
@@ -7,18 +7,14 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension EnvironmentValues {
|
struct AllNotesScreen: View {
|
||||||
@Entry var ownPeer: OwnPeer = .fallback
|
@AppStorage("peered_username") private var username: String?
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView: View {
|
|
||||||
@AppStorage("peered_username") private var username: String = "fallback_user"
|
|
||||||
@State private var notes = [Note]()
|
@State private var notes = [Note]()
|
||||||
@State private var notesClient: NoteEditingSessionClient?
|
@State private var notesClient: NoteEditingSessionClient?
|
||||||
@State private var ownPeer: OwnPeer?
|
@State private var ownPeer: OwnPeer?
|
||||||
|
|
||||||
var isUsernameValid: Bool {
|
var isUsernameValid: Bool {
|
||||||
username != "fallback_user" && !username.isEmpty
|
username.map(\.isEmpty) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -44,7 +40,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.ownPeer, ownPeer ?? .fallback)
|
|
||||||
.navigationTitle("Peered")
|
.navigationTitle("Peered")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
Button("Create note") {
|
Button("Create note") {
|
||||||
@@ -55,20 +50,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
notes = NotesStorage().loadNotes()
|
notes = NotesStorage().loadNotes()
|
||||||
if isUsernameValid {
|
if let username, isUsernameValid {
|
||||||
setupSession()
|
setupSession(username: username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: username) { _, newUsername in
|
.onChange(of: username) { _, newUsername in
|
||||||
guard isUsernameValid else { return }
|
guard let newUsername, isUsernameValid else { return }
|
||||||
setupSession()
|
setupSession(username: newUsername)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: .constant(!isUsernameValid)) {
|
.sheet(isPresented: .constant(!isUsernameValid)) {
|
||||||
SetUserNameBottomSheetView(username: $username)
|
SetUserNameBottomSheetView(username: $username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSession() {
|
private func setupSession(username: String) {
|
||||||
notesClient?.stopBrowsingForNotes()
|
notesClient?.stopBrowsingForNotes()
|
||||||
let peer = OwnPeer(peer: .init(displayName: username))
|
let peer = OwnPeer(peer: .init(displayName: username))
|
||||||
ownPeer = peer
|
ownPeer = peer
|
||||||
@@ -78,5 +73,5 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
ContentView()
|
AllNotesScreen()
|
||||||
}
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct ManageMembersView: View {
|
struct ManageMembersScreen: View {
|
||||||
@Bindable var noteAdvertiser: NoteEditingSessionServer
|
@Bindable var noteAdvertiser: NoteEditingSessionServer
|
||||||
let noteTitle: String
|
let noteTitle: String
|
||||||
@Binding var noteContent: String
|
@Binding var noteContent: String
|
||||||
@@ -16,9 +16,7 @@ struct Peer: Identifiable {
|
|||||||
case invitationPending
|
case invitationPending
|
||||||
}
|
}
|
||||||
|
|
||||||
var id: String {
|
var id: String { mcPeer.displayName }
|
||||||
mcPeer.displayName
|
|
||||||
}
|
|
||||||
let mcPeer: MCPeerID
|
let mcPeer: MCPeerID
|
||||||
var state: ConnectionState
|
var state: ConnectionState
|
||||||
}
|
}
|
||||||
@@ -47,6 +45,7 @@ final class NoteEditingSessionServer: NSObject {
|
|||||||
|
|
||||||
func stopServer() {
|
func stopServer() {
|
||||||
browser.stopBrowsingForPeers()
|
browser.stopBrowsingForPeers()
|
||||||
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
func invite(peer: Peer, to note: NoteInvitation.NoteContent) {
|
func invite(peer: Peer, to note: NoteInvitation.NoteContent) {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NoteTextEditor: View {
|
||||||
|
@Binding var text: String
|
||||||
|
let remoteText: String?
|
||||||
|
@State private var selection: TextSelection? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TextEditor(text: $text, selection: $selection)
|
||||||
|
.onChange(of: remoteText) { oldValue, newValue in
|
||||||
|
guard let newValue, newValue != text else { return }
|
||||||
|
|
||||||
|
let previousRemote = oldValue ?? text
|
||||||
|
|
||||||
|
// Where the remote edit begins
|
||||||
|
let changeStart = commonPrefixUTF16Length(previousRemote, newValue)
|
||||||
|
|
||||||
|
// How many UTF-16 units were inserted (positive) or removed (negative)
|
||||||
|
let delta = newValue.utf16.count - previousRemote.utf16.count
|
||||||
|
|
||||||
|
// Apply remote text first
|
||||||
|
text = newValue
|
||||||
|
|
||||||
|
// Get current cursor offset
|
||||||
|
guard
|
||||||
|
let selection,
|
||||||
|
case .selection(let range) = selection.indices,
|
||||||
|
let cursorPos = range.lowerBound.samePosition(in: text.utf16)
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let cursorUTF16 = text.utf16.distance(
|
||||||
|
from: text.utf16.startIndex,
|
||||||
|
to: cursorPos
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only shift if the change happened strictly before the cursor
|
||||||
|
guard changeStart < cursorUTF16 else { return }
|
||||||
|
|
||||||
|
let newCursorUTF16 = max(0, min(cursorUTF16 + delta, newValue.utf16.count))
|
||||||
|
let newUTF16 = newValue.utf16
|
||||||
|
let newUTF16Index = newUTF16.index(newUTF16.startIndex, offsetBy: newCursorUTF16)
|
||||||
|
guard let newIndex = newUTF16Index.samePosition(in: newValue) else { return }
|
||||||
|
|
||||||
|
self.selection = TextSelection(range: newIndex..<newIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commonPrefixUTF16Length(_ a: String, _ b: String) -> Int {
|
||||||
|
var count = 0
|
||||||
|
let aUTF16 = a.utf16
|
||||||
|
let bUTF16 = b.utf16
|
||||||
|
let minLen = min(aUTF16.count, bUTF16.count)
|
||||||
|
while count < minLen {
|
||||||
|
let aIdx = aUTF16.index(aUTF16.startIndex, offsetBy: count)
|
||||||
|
let bIdx = bUTF16.index(bUTF16.startIndex, offsetBy: count)
|
||||||
|
guard aUTF16[aIdx] == bUTF16[bIdx] else { break }
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
struct NoteMessage: Codable {
|
||||||
|
let senderID: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
@@ -36,8 +36,10 @@ struct NotesStorage {
|
|||||||
|
|
||||||
func createNote(name: String) {
|
func createNote(name: String) {
|
||||||
let currentNotes = loadNotes()
|
let currentNotes = loadNotes()
|
||||||
var proposedName = name
|
|
||||||
var index: Int? = nil
|
var index: Int? = nil
|
||||||
|
var proposedName: String {
|
||||||
|
index.map { name + " \($0)" } ?? name
|
||||||
|
}
|
||||||
|
|
||||||
while currentNotes.contains(where: { $0.name == proposedName }) {
|
while currentNotes.contains(where: { $0.name == proposedName }) {
|
||||||
if let _index = index {
|
if let _index = index {
|
||||||
@@ -46,12 +48,6 @@ struct NotesStorage {
|
|||||||
index = 1
|
index = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proposedName = if let index {
|
|
||||||
"\(proposedName) \(index)"
|
|
||||||
} else {
|
|
||||||
proposedName
|
|
||||||
}
|
|
||||||
let pathToWrite = URL.documentsDirectory.appendingPathComponent(proposedName).appendingPathExtension(for: .text)
|
let pathToWrite = URL.documentsDirectory.appendingPathComponent(proposedName).appendingPathExtension(for: .text)
|
||||||
try! String().write(to: pathToWrite, atomically: true, encoding: .utf8)
|
try! String().write(to: pathToWrite, atomically: true, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
struct PeeredApp: App {
|
struct PeeredApp: App {
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
AllNotesScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SetUserNameBottomSheetView: View {
|
struct SetUserNameBottomSheetView: View {
|
||||||
@State private var proposedUsername: String = ""
|
@State private var proposedUsername: String = ""
|
||||||
@Binding var username: String
|
@Binding var username: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|||||||
@@ -1,9 +1,75 @@
|
|||||||
|
#import "../style.typ"
|
||||||
#set heading(numbering: "1.1")
|
#set heading(numbering: "1.1")
|
||||||
|
|
||||||
= Implementacja <implementacja>
|
= Implementacja <implementacja>
|
||||||
== Projekt architektury systemu <projekt>
|
== Projekt architektury systemu <projekt>
|
||||||
|
Przygotowana implementacja bazuje na architekturze Model-View. Jest to podejście, gdzie cała logika biznesowa jest zawarta w modelach, które bezpośrednio są przekazywane do warstwy prezentacji (View). Na tej warstwie jest wykonywane odpowiednie formatowanie danych, gdzie też brane pod uwagę są preferencje zapisu i językowe użytkownika. Model-View to uproszczony wariant popularnej w aplikacjach mobilnych architektury Model-View-ViewModel (MVVM), gdzie ViewModel jest warstwą zajmującą się przekształcaniem modelów biznesowych na gotowe do prezentacji obiekty. Warstwa View wtedy zajmuje się przede wszystkim definiowaniem struktury interfejsu użytkownika oraz logiką związaną z dostępnością (wsparcie dla funkcjonalności czytników ekranów, skalowaniem interfejsu). Przygotowana implementacja nie zawiera złożonej logiki prezentacji ani rozbudowanego graficznego interfejsu użytkownika, więc w celu uproszczenia kodu zdecydowałem się na porzucenie użycia warstwy ViewModelu.
|
||||||
|
|
||||||
|
Warstwa prezentacji aplikacji opisująca początkowy interfejs użytkownika jest zaimplementowana w `AllNotesScreen`. Ekran jest widoczny dla użytkownika, gdy ustawił swoją nazwę użytkownika w sieci peer-to-peer. Składa się z listy podzielonej na dwie sekcje - notatek których użytkownik jest autorem, oraz notatek do których użytkownik został zaproszony. Nad listą znajduje się przycisk stworzenia nowej notatki. Naciśnięcie na którąkolwiek z istniejących notatek prowadzi do otwarcia jej zawartości. W zależności od tego, czy użytkownik jest właścicielem danej notatki czy współtwórcą, interfejsem do edycji tej notatki są odpowiednio obiekty `NoteEditorScreen` oraz `SharedNoteEditor`. Z ekranu `NoteEditorScreen` możemy przejść do `ManageMembersScreen`, który jest listą użytkowników zaproszonych i możliwych do zaproszenia do edytowania aktualnej notatki.
|
||||||
|
|
||||||
== Model danych
|
== Model danych
|
||||||
|
Podstawowym obiektem reprezentującym notatkę w systemie plików jest `Note`. Składa się on z URL (Universal Resource Locator), czyli ścieżki prowadzącej do pełnej zawartości notatki użytkownika, która jest przechowywana w pliku tekstowym o rozszerzeniu `txt`. Dodatkowo przechowujemy w tym obiekcie jeszcze `name`, które pełni funkcję ułatwionego dostępu do przyjaznej nazwy, jednocześnie będąc nazwą pliku w lokalnym systemie plików oraz przyjazną nazwą prezentowaną w liście notatek do których użytkownik został zaproszony. Obiekt implementuje protokół `Identifiable`, który gwarantuje możliwość identyfikacji obiektu w zbiorze zawierającym wiele jego instancji, np. w tablicy. Ta cecha jest wymagana i wykorzystywana przez SwiftUI, by móc rozróżniać rodzaje zmian na komponentach interfejsów graficznych zawierających wiele kopii takiego obiektu, np. `List` lub `ForEach`. Umożliwia to dokonanie decyzji co do sposobu animacji zmian na ekranie użytkownika, ponieważ framework będzie mógł rozróżnić usunięcie i wstawienie nowego obiektu, od zmiany parametrów tej samej instancji.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct Note: Identifiable {
|
||||||
|
var id: URL { path }
|
||||||
|
|
||||||
|
let name: String
|
||||||
|
let path: URL
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`NoteMessage` to obiekt zawierający zawartość notatki, którą wysyłamy do użytkowników, którzy przyjęli zaproszenie do edycji notatki. Znajdziemy w niej pola `SenderID`, które jest identyfikatorem właściciela notatki oraz `content`, czyli właściwą zawartość notatki. Jest ona również używana do wysyłania każdej aktualizacji do wszystkich użytkowników. Obiekt implementuje protokół `Codable`, który jest uniwersalnym interfejsem kodowania danych. Daje to nam wbudowane wsparcie kodowowania do formatów JSON i XML. Mamy możliwość również pisania własnych koderów, które umożliwią zamianę obiektów implementujących `Codable` do wybranych przez nas, innych formatów danych.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct NoteMessage: Codable {
|
||||||
|
let senderID: String
|
||||||
|
let content: String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do reprezentacji zaproszeń stworzyłem obiekt `NoteInvitation`. Przechowuje on informacje potrzebne do obsługi całego procesu zaproszeń między użytkownikami systemu. Składa się z `invitatorID`, który jest wyspecjalizowanym obiektem frameworku `MultipeerConnectivity` i umożliwia identyfikację użytkownika w czasie komunikacji peer to peer. `note` to obiekt reprezentujący notatkę, którą otrzymujemy w zaproszeniu. Składa się on z tytułu i treści, które były aktualne w momencie zapraszania użytkownika. Ostatnim i jednocześnie prywatnym parametrem jest `invitationHandler`, który jest typem funkcji przyjmującej wartość boolowską (prawda/fałsz). Jest on wykorzystywany do wstrzyknięcia logiki akceptacji notatki, która wymaga kilku operacji w logice klasy obsługującej przyjmowanie zaproszeń. Dzięki takiemu podejściu tworzymy luźną zależność do skomplikowanego obiektu na dalszych etapach systemu. `NoteInvitation` zawiera również metody `accept()` oraz `decline()`, które odpowiadają za wykonanie akcji zaproszenia, które pod spodem odpowiednio używają parametru `invitationHandler`, który można wykonać tylko raz, ze względu na specyfikę `Multipeer Connectivity`, a następnie usuwamy go z pamięci.
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct NoteInvitation: Identifiable {
|
||||||
|
struct NoteContent: Codable {
|
||||||
|
let title: String
|
||||||
|
let noteSnapshot: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: MCPeerID { invitatorID }
|
||||||
|
var noteName: String { note.title }
|
||||||
|
let invitatorID: MCPeerID
|
||||||
|
let note: NoteContent
|
||||||
|
private var invitationHandler: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
invitatorID: MCPeerID,
|
||||||
|
note: NoteContent,
|
||||||
|
invitationHandler: ((Bool) -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.invitatorID = invitatorID
|
||||||
|
self.note = note
|
||||||
|
self.invitationHandler = invitationHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func accept() {
|
||||||
|
invitationHandler?(true)
|
||||||
|
invitationHandler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func decline() {
|
||||||
|
invitationHandler?(false)
|
||||||
|
invitationHandler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ostatnim obiektem jest struktura `Peer`, która jest opisem aktualnego stanu połączenia wykrytego innej instancji systemu w pobliżu użytkownika.
|
||||||
|
|
||||||
== Warstwa sieciowa i komunikacja P2P
|
== Warstwa sieciowa i komunikacja P2P
|
||||||
|
|
||||||
|
|
||||||
== Odkrywanie innych urządzeń
|
== Odkrywanie innych urządzeń
|
||||||
== Transportowanie danych
|
== Transportowanie danych
|
||||||
== Algorytm rozwiązywania konfliktów
|
== Algorytm rozwiązywania konfliktów
|
||||||
|
|||||||
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user