Compare commits

...
16 Commits
23 changed files with 844 additions and 36 deletions

No files matched your search

@@ -10,5 +10,13 @@
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>2C96045430463C2E0003A2A8</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
+20
View File
@@ -0,0 +1,20 @@
//
// AppRouter.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 01/09/2026.
//
import SwiftUI
@Observable
class AppRouter {
var selectedTab: AppTab = .home
}
enum AppTab: Hashable {
case home
case worldsList
}
@@ -0,0 +1,23 @@
//
// CreateWorldButton.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 02/09/2026.
//
import SwiftUI
struct CreateWorldButton : View {
var body: some View {
NavigationLink{
CreateWorldView()
} label : {
Label("Créer un monde", systemImage: "plus")
.font(.headline)
.padding(.vertical, 14)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: 300)
.buttonStyle(.glassProminent)
}
}
-25
View File
@@ -1,25 +0,0 @@
//
// MainTabView.swift
//
//
// Created by Giovanni Josserand on 01/09/2026.
//
import SwiftUI
struct MainTabView: View {
var body : some View {
TabView {
NavigationStack {
HomeView()
}.tabItem {
Label("Accueil", systemImage: "house")
}
NavigationStack {
WorldListView()
}.tabItem {
Label("Mes mondes", systemImage: "globe.europe.africa")
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//
// WorldCardView.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 03/09/2026.
//
import SwiftUI
struct WorldCard : View {
var world : World
var isSelected = false
var body : some View {
ZStack(alignment: .bottom) {
LinearGradient(
colors: [.clear, .black.opacity(0.5)],
startPoint: .top,
endPoint: .bottom
)
HStack(alignment: .bottom) {
Text(world.name)
.foregroundStyle(.white)
.font(.headline)
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 28))
.foregroundStyle(.white, .indigo)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 6)
}
.frame(height: 250)
.background {
if let dataImage = world.illustrationData,
let uiImage = UIImage(data: dataImage) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
}else {
Image(systemName: "globe.europe.africa")
.resizable()
.scaledToFill()
}
}
.clipShape(RoundedRectangle(cornerRadius: 20))
.contentShape(RoundedRectangle(cornerRadius: 20))
}
}
+29
View File
@@ -0,0 +1,29 @@
//
// MainTabView.swift
//
//
// Created by Giovanni Josserand on 01/09/2026.
//
import SwiftUI
struct MainTabView: View {
@Environment(AppRouter.self) private var router
var body : some View {
@Bindable var router = router
TabView(selection: $router.selectedTab) {
NavigationStack {
HomeView()
}
.tabItem {Label("Accueil", systemImage: "house")}
.tag(AppTab.home)
NavigationStack {
WorldListView()
}
.tabItem {Label("Mes mondes", systemImage: "globe.europe.africa")}
.tag(AppTab.worldsList)
}
}
}
@@ -0,0 +1,27 @@
//
// AttributeDefinition.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 06/09/2026.
//
import SwiftData
import Foundation
@Model
class AttributeDefinition {
var id: UUID
var createdAt: Date
var updatedAt: Date
var name : String
var type : AttributeType
init(name: String, type: AttributeType){
self.id = UUID()
self.createdAt = Date()
self.updatedAt = Date()
self.name = name
self.type = type
}
}
+14
View File
@@ -0,0 +1,14 @@
//
// AttributeType.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 06/09/2026.
//
enum AttributeType : nonisolated Codable {
case string
case int
case double
case bool
case date
}
+33
View File
@@ -0,0 +1,33 @@
//
// Entity.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 05/09/2026.
//
import SwiftData
import Foundation
@Model
class Entity {
var id : UUID
var createdAt : Date
var updatedAt : Date
var name : String
var world : World
var worldEntityType : WorldEntityType
@Relationship var enabledLocalAttributeDefinitions = [AttributeDefinition]()
@Relationship var personalAttributeDefinitions = [AttributeDefinition]()
init(name: String, world: World, worldEntityType: WorldEntityType){
self.id = UUID()
self.createdAt = Date()
self.updatedAt = Date()
self.name = name
self.world = world
self.worldEntityType = worldEntityType
}
}
+27
View File
@@ -0,0 +1,27 @@
//
// EntityType.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 05/09/2026.
//
import SwiftData
import Foundation
@Model
class EntityType {
var id : UUID
var createdAt : Date
var updatedAt : Date
var name : String
@Relationship var globalAttributeDefinitions = [AttributeDefinition]()
init(name: String) {
self.id = UUID()
self.createdAt = Date()
self.updatedAt = Date()
self.name = name
}
}
+31
View File
@@ -0,0 +1,31 @@
//
// Relation.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 06/09/2026.
//
import SwiftData
import Foundation
@Model
class Relation{
var id : UUID
var createdAt : Date
var updatedAt : Date
var label : String
var source : RelationEndpoint
var target : RelationEndpoint
var world : World
init(label: String, source : RelationEndpoint, target : RelationEndpoint, world : World){
self.id = UUID()
self.createdAt = Date()
self.updatedAt = Date()
self.label = label
self.source = source
self.target = target
self.world = world
}
}
@@ -0,0 +1,14 @@
//
// RelationEndpoint.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 06/09/2026.
//
import Foundation
enum RelationEndpoint : nonisolated Codable, Equatable {
case entityType(id: UUID)
case worldEntityType(id: UUID)
case entity(id: UUID)
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftData
import Foundation
struct DeleteModel {
let modelContext: ModelContext
// MARK: - Entity
func delete(_ entity: Entity) {
deleteRelations(.entity(id: entity.id))
modelContext.delete(entity)
}
// MARK: - WorldEntityType
func delete(_ worldEntityType: WorldEntityType) {
deleteRelations(.worldEntityType(id: worldEntityType.id))
let entities = fetchEntities(for: worldEntityType)
for entity in entities {
delete(entity)
}
modelContext.delete(worldEntityType)
}
// MARK: - EntityType
func delete(_ entityType: EntityType) {
deleteRelations(.entityType(id: entityType.id))
let worldEntityTypes = fetchWorldEntityTypes(using: entityType)
for worldEntityType in worldEntityTypes {
delete(worldEntityType)
}
modelContext.delete(entityType)
}
// MARK: - World
func delete(_ world: World) {
for relation in world.relations {
modelContext.delete(relation)
}
let worldEntityTypes = fetchWorldEntityTypes(in: world)
for worldEntityType in worldEntityTypes {
delete(worldEntityType)
}
modelContext.delete(world)
}
// MARK: - Relations
private func deleteRelations(_ endpoint: RelationEndpoint) {
let relations = fetchAllRelations()
for relation in relations {
if relation.source == endpoint || relation.target == endpoint {
modelContext.delete(relation)
}
}
}
// MARK: - Fetch
private func fetchAllRelations() -> [Relation] {
let descriptor = FetchDescriptor<Relation>()
return (try? modelContext.fetch(descriptor)) ?? []
}
private func fetchEntities(for worldEntityType: WorldEntityType) -> [Entity] {
let worldEntityTypeID = worldEntityType.id
let descriptor = FetchDescriptor<Entity>(
predicate: #Predicate {
$0.worldEntityType.id == worldEntityTypeID
}
)
return (try? modelContext.fetch(descriptor)) ?? []
}
private func fetchWorldEntityTypes(in world: World) -> [WorldEntityType] {
let worldID = world.id
let descriptor = FetchDescriptor<WorldEntityType>(
predicate: #Predicate {
$0.world.id == worldID
}
)
return (try? modelContext.fetch(descriptor)) ?? []
}
private func fetchWorldEntityTypes(using entityType: EntityType) -> [WorldEntityType] {
let entityTypeID = entityType.id
let descriptor = FetchDescriptor<WorldEntityType>(
predicate: #Predicate {
$0.entityType.id == entityTypeID
}
)
return (try? modelContext.fetch(descriptor)) ?? []
}
}
+40
View File
@@ -0,0 +1,40 @@
//
// World.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 01/09/2026.
//
import SwiftData
import Foundation
@Model
class World {
var id : UUID
var createdAt : Date
var updatedAt : Date
var name : String
var overview : String
@Attribute(.externalStorage) var illustrationData : Data?
@Relationship var relations = [Relation]()
init(name: String, overview: String, illustrationData: Data?) {
self.id = UUID()
self.name = name
self.overview = overview
self.createdAt = Date()
self.updatedAt = Date()
self.illustrationData = illustrationData
}
func duplicate() -> World {
let newWorld = World(
name: self.name, overview: self.overview, illustrationData: self.illustrationData
)
return newWorld
}
}
+29
View File
@@ -0,0 +1,29 @@
//
// WorldEntityType.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 06/09/2026.
//
import SwiftData
import Foundation
@Model
class WorldEntityType {
var id : UUID
var createdAt : Date
var updatedAt : Date
var world : World
var entityType : EntityType
@Relationship var enabledGlobalAttributeDefinitions = [AttributeDefinition]()
@Relationship var localAttributeDefinitions = [AttributeDefinition]()
init(world: World, entityType: EntityType){
self.id = UUID()
self.createdAt = Date()
self.updatedAt = Date()
self.world = world
self.entityType = entityType
}
}
@@ -0,0 +1,37 @@
//
// IllustrationSection.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 05/09/2026.
//
import SwiftUI
import PhotosUI
struct IllustrationSection : View {
@Binding var selectedItem: PhotosPickerItem?
@Binding var selectedImageData: Data?
var body: some View {
Section("Illustration") {
if let selectedImageData, let uiImage = UIImage(data: selectedImageData) {
Image(uiImage: uiImage)
.resizable()
.scaledToFill()
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 10))
.listRowInsets(EdgeInsets())
}
PhotosPicker(
selection: $selectedItem,
matching: .any(of: [.images, .not(.screenshots)])
) {
Label(
selectedImageData == nil ? "Sélectionner une photo" : "Modifier la photo",
systemImage: "photo"
)
}
}
}
}
@@ -0,0 +1,25 @@
//
// InformationsSection.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 05/09/2026.
//
import SwiftUI
struct InformationsSection : View {
@Binding var worldName : String
@Binding var worldOverview : String
var body: some View {
Section("Informations") {
TextField("Nom du monde", text: $worldName)
TextField(
"Description",
text: $worldOverview,
axis: .vertical
)
}
}
}
@@ -0,0 +1,88 @@
//
// CreateWorldView.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 01/09/2026.
//
//
// CreateWorldView.swift
// WorldBuilder
//
import SwiftUI
import SwiftData
import PhotosUI
struct CreateWorldView: View {
@State private var worldName = ""
@State private var worldOverview = ""
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@Environment(AppRouter.self) private var router
@State private var showSaveError = false
@State private var saveErrorMessage = ""
@State private var selectedItem: PhotosPickerItem?
@State private var selectedImageData: Data?
var body: some View {
Form {
InformationsSection(
worldName: $worldName,
worldOverview: $worldOverview
)
IllustrationSection(
selectedItem: $selectedItem,
selectedImageData: $selectedImageData
)
Section {
Button("Créer le monde") {
let world = World(
name: worldName,
overview: worldOverview,
illustrationData: selectedImageData
)
context.insert(world)
do {
try context.save()
router.selectedTab = .worldsList
dismiss()
} catch {
saveErrorMessage =
"Impossible de sauvegarder le monde : \(error.localizedDescription)"
showSaveError = true
}
}
.disabled(worldName.isEmpty)
.padding(.horizontal, 24)
.padding(.vertical, 12)
.background(Color.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.frame(maxWidth: .infinity, alignment: .center)
}
.listRowBackground(Color.clear)
}
.navigationTitle("Créer un monde")
.navigationBarTitleDisplayMode(.inline)
.alert("Erreur", isPresented: $showSaveError) {
Button("OK") { }
} message: {
Text(saveErrorMessage)
}
.onChange(of: selectedItem) { _, newItem in
Task {
if let data = try? await newItem?.loadTransferable(type: Data.self) {
selectedImageData = data
}
}
}
}
}
+1 -1
View File
@@ -9,6 +9,6 @@ import SwiftUI
struct HomeView : View {
var body: some View {
Text("Home")
CreateWorldButton()
}
}
@@ -0,0 +1,39 @@
//
// EmptyWorldView.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 02/09/2026.
//
import SwiftUI
struct EmptyWorld : View {
var body : some View {
VStack(spacing: 20) {
ZStack {
Circle()
.fill(.indigo.opacity(0.12))
.frame(width: 100, height: 100)
Image(systemName: "globe.europe.africa")
.font(.system(size: 42))
.foregroundStyle(.indigo)
}
VStack {
Text("Ton univers commence ici.")
.font(.title3.bold())
Text("Crée ton premier monde et commence à construire ton univers.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: 300)
CreateWorldButton()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -0,0 +1,91 @@
//
// WorldListToolbarView.swift
// WorldBuilder
//
// Created by Giovanni Josserand on 03/09/2026.
//
import SwiftUI
import SwiftData
struct WorldListToolbar : ViewModifier {
@Binding var isSelecting : Bool
@Binding var selectedWorlds : Set<World.ID>
@Environment(\.modelContext) private var context
let worldsIsEmpty : Bool
let worlds : [World]
func body(content: Content) -> some View {
content
.toolbar{
if !worldsIsEmpty {
ToolbarItem(placement: .topBarLeading) {
if isSelecting {
Button("Annuler") {
selectedWorlds.removeAll()
isSelecting = false
}
} else {
Menu {
//Faire le tri
}label: {
Button {
//
}label: {
Image(systemName: "line.3.horizontal.decrease")
}
}
}
}
}
if !isSelecting {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink {
CreateWorldView()
} label: {
Image(systemName: "plus")
}
}
}
if isSelecting && !selectedWorlds.isEmpty {
ToolbarItemGroup(placement: .bottomBar) {
Button {
let worldsToAction = worlds.filter { selectedWorlds.contains($0.id) }
for world in worldsToAction {
context.insert(world.duplicate())
}
} label: {
Image(systemName: "plus.square.on.square")
}
.disabled(selectedWorlds.isEmpty)
Spacer()
Button(role: .destructive) {
let worldsToAction = worlds.filter { selectedWorlds.contains($0.id) }
let shouldExitSelection = worldsToAction.count == worlds.count
for world in worldsToAction {
DeleteModel(modelContext: context).delete(world)
}
selectedWorlds.removeAll()
if shouldExitSelection {
isSelecting = false
}
} label: {
Image(systemName: "trash")
}
.disabled(selectedWorlds.isEmpty)
}
}
}
}
}
@@ -1,14 +1,91 @@
//
// WorldListView.swift
//
//
// Created by Giovanni Josserand on 01/09/2026.
//
//
// WorldListView.swift
//
//
// Created by Giovanni Josserand on 01/09/2026.
//
import SwiftUI
import SwiftData
struct WorldListView: View {
@Environment(\.modelContext) private var context
@Query(sort: \World.createdAt, order: .reverse)
private var worlds: [World]
let columns = [GridItem(.flexible()), GridItem(.flexible())]
@State private var isSelecting = false
@State private var selectedWorlds = Set<World.ID>()
import SwiftUI
struct WorldListView: View {
var body: some View {
Text("My worlds")
VStack(alignment: .leading, spacing: 24){
if worlds.isEmpty {
EmptyWorld()
}else{
ScrollView {
LazyVGrid(columns: columns){
ForEach(worlds) {world in
Group {
if isSelecting {
WorldCard(
world: world,
isSelected: selectedWorlds.contains(world.id)
)
.onTapGesture {
if selectedWorlds.contains(world.id) {
selectedWorlds.remove(world.id)
} else {
selectedWorlds.insert(world.id)
}
}
}else {
NavigationLink{
WorldOverviewView()
} label: {
WorldCard(world: world)
}
.buttonStyle(.plain)
}
}
.contextMenu{
Button {
isSelecting = true
selectedWorlds.insert(world.id)
} label: {
Label("Sélectionner", systemImage: "checkmark.circle")
}
Button {
context.insert(world.duplicate())
} label: {
Label("Dupliquer", systemImage: "plus.square.on.square")
}
Button(role: .destructive){
DeleteModel(modelContext: context).delete(world)
}label : {
Label("Supprimer", systemImage: "trash")
}
}
}
}
}
.scrollIndicators(.hidden)
}
}
.navigationTitle("Mes mondes")
.navigationBarTitleDisplayMode(.inline)
.padding(.horizontal, 8)
.frame(maxHeight: .infinity, alignment: .topLeading)
.modifier(
WorldListToolbar(
isSelecting: $isSelecting,
selectedWorlds: $selectedWorlds,
worldsIsEmpty: worlds.isEmpty,
worlds: worlds
)
)
}
}
}
+5
View File
@@ -6,12 +6,17 @@
//
import SwiftUI
import SwiftData
@main
struct WorldBuilderApp: App {
@State private var router = AppRouter()
var body: some Scene {
WindowGroup {
ContentView()
.environment(router)
}
.modelContainer(for: World.self)
}
}