Core data for favourite buttons - ios

I want to make it so you can favourite a "landmark" in one view (LandmarkDetail), and access a list of all the "landmarks" in another view with the ones I've favourited highlighted. First I used "#AppStorrage" but I was told too to use Core Data for it instead. So far I have the favourite button working in the LandmarkDetail view with "#AppStorage" but apparently I need to change that so it uses Core Data.
I've look around to get an understanding of how to do it with Core Data but I could really use a helping hand if anyone can help. I've already seen and read some tutorials about core data and how to set it up, but I can't find anything for my specific problem where I pull in data from a JSON and I need Core Data to handle the favourite feature.
Here is my code for the favourite button
struct FavoriteButton: View {
#AppStorage ("isFavorite") var isFavorite: Bool = false
var body: some View {
Button {
isFavorite.toggle()
} label: {
Label("Toggle Favorite", systemImage: isFavorite ? "star.fill" : "star")
.labelStyle(.iconOnly)
.foregroundColor(isFavorite ? .yellow : .gray)
}
}
}
Code from the landmark detail view
struct LandmarkDetail: View {
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton()
}
}
}
}
}
Code for the rows in the list view
This is the one not working yet, so far it just pulls the data from a JSON.
MODEL
import Foundation
import SwiftUI
import CoreLocation
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
var park: String
var state: String
var description: String
var isFavorite: Bool
var isFeatured: Bool
var category: Category
enum Category: String, CaseIterable, Codable {
case lakes = "Lakes"
case rivers = "Rivers"
case mountains = "Mountains"
}
private var imageName: String
var image: Image{
Image(imageName)
}
var featureImage: Image? {
isFeatured ? Image(imageName + "_feature") : nil
}
private var coordinates: Coordinates
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: coordinates.latitude,
longitude: coordinates.longitude)
}
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
}
import Foundation
import Combine
final class ModelData: ObservableObject {
#Published var landmarks: [Landmark] = load("landmarkData.json")
var hikes: [Hike] = load("hikeData.json")
#Published var profile = Profile.default
var features: [Landmark] {
landmarks.filter { $0.isFeatured }
}
var categories: [String: [Landmark]] {
Dictionary(
grouping: landmarks,
by: { $0.category.rawValue }
)
}
}
func load<T: Decodable>(_ filename: String) -> T {
let data: Data
guard let file = Bundle.main.url(forResource: filename, withExtension: nil)
else {
fatalError("Couldn't find \(filename) in main bundle.")
}
do {
data = try Data(contentsOf: file)
} catch {
fatalError("Couldn't load \(filename) from main bundle:\n\(error)")
}
do {
let decoder = JSONDecoder()
return try decoder.decode(T.self, from: data)
} catch {
fatalError("Couldn't parse \(filename) as \(T.self):\n\(error)")
}
}

First of all you need to create a .xcdatamodeld file named Landmarks. You can create it by pressing right button on the principal folder of your project and searching Data Model. After you need to create a new Entity named Landmark. You can add attributes showed in your model like id, name, park, etc... with their types. After you need to create a new Swift file in which you can create you Core Data Controller like this:
class DataController: ObservableObject {
let container = NSPersistentContainer(name: "Landmarks")
init() {
container.loadPersistentStores { description, error in
if let error = error {
print("Core Data failed to load: \(error.localizedDescription)")
}
}
}
}
Successively, you need to add to your LandmarksApp.swift file the following code:
struct LandmarksApp: App {
#StateObject private var dataController = DataController()
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.managedObjectContext, dataController.container.viewContext)
}
}
}
Continue adding this to your LandmarkDetail view:
struct LandmarkDetail: View {
#Environment(\.managedObjectContext) var moc
var body: some View {
ScrollView {
VStack(alignment: .leading) {
HStack {
Text(landmark.name)
.font(.title)
FavoriteButton()
}
}
}
}
}
To create a new item and add to Core Data you can write:
let landmark = Landmark(context: moc)
landmark.id = id
landmark.name = name
landmark.park = park
etc...
try? moc.save()
For your JSON data you can create a function that convert all JSON data in Core Data following these steps.

Related

Issue passing data from API call in SwiftUI MVVM pattern

been going back and forth for 2 days trying to figure this out before posting and still hitting a wall.
Created an API specific class, a ViewModel, and a View and trying to shuttle data back and forth and while I see the API call is successful and I decode it without issue on logs, it never reflects on the UI or View.
As far as I see I appear to be trying to access the data before it's actually available. All help greatly appreciated!
API Class:
import Combine
import Foundation
class CrunchbaseApi:ObservableObject
{
#Published var companies:[Company] = [Company]()
#Published var singleCompany:Company?
func retrieve(company:String) async
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
func retrieveCompanyList()
{
//declare
}
}
ViewModel:
import Combine
import Foundation
class CompanyViewModel: ObservableObject
{
var crunchbase:CrunchbaseApi = CrunchbaseApi()
#Published var singleCompany:Company?
func retrieveCompany(company:String) async
{
await self.crunchbase.retrieve(company: company)
self.singleCompany = crunchbase.singleCompany
}
}
View:
import SwiftUI
struct CompanyView: View
{
#State var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
}
}
}
Your assumption about accessing the data to early is correct. But there are more things going on here.
just declaring a function async like your retrieve func doesn´t make it async.
using a nested Observable class with #Published will not update the view
Observable classes should have either an #StateObject or an #ObservableObject property wrapper. Depending on if the class is injected or created in the view
Possible solution:
Move the function into the viewmodel:
class CompanyViewModel: ObservableObject
{
#Published var singleCompany:Company?
func retrieve(company:String)
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
}
Change the View to hold the viewmodel as #StateObject, also add an .onApear modifier to load the data:
struct CompanyView: View
{
#StateObject var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
.onAppear {
companyViewModel.retrieve(company: "whatever")
}
}
}
}

How to set up SwiftUI app to pass inputted value as parameter into URL string

I am attempting to build a basic SwiftUI weather app. The app allows the user to search weather by city name, using the OpenWeatherMap API. I configured the inputted city name from the text field to be injected into name: "" in WeatherModel, inside the fetchWeather() function in the viewModel. I then configured the OpenWeatherMap URL string to take in searchedCity.name as a parameter (see viewModel below). This setup seems to work fine, as I am able to search for weather by city name. However, I want to seek feedback as to whether or not the practice of passing searchCity.name directly into the URL (in the viewModel) is correct. In regards to:
let searchedCity = WeatherModel(...
... I am not sure what to do with the CurrentWeather and WeatherInfo inside that instance of WeatherModel. Since I'm only using "searchedCity" to pass the name of the city into the URL, how should "CurrentWeather.init(temp: 123.00)" and "weather: [WeatherInfo.init(description: "")]" be set? Is it correct to implement values for temp and description, such as 123 and ""?
Here is my full code below:
ContentView
import SwiftUI
struct ContentView: View {
// Whenever something in the viewmodel changes, the content view will know to update the UI related elements
#StateObject var viewModel = WeatherViewModel()
// #State private var textField = ""
var body: some View {
NavigationView {
VStack {
TextField("Enter City Name", text: $viewModel.enterCityName).textFieldStyle(.roundedBorder)
Button(action: {
viewModel.fetchWeather()
viewModel.enterCityName = ""
}, label: {
Text("Search")
.padding(10)
.background(Color.green)
.foregroundColor(Color.white)
.cornerRadius(10)
})
Text(viewModel.title)
.font(.system(size: 32))
Text(viewModel.temp)
.font(.system(size: 44))
Text(viewModel.descriptionText)
.font(.system(size: 24))
Spacer()
}
.navigationTitle("Weather MVVM")
}.padding()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Model
import Foundation
// Data, Model should mirror the JSON layout
//Codable is the property needed to convert JSON into a struct
struct WeatherModel: Codable {
let name: String
let main: CurrentWeather
let weather: [WeatherInfo]
}
struct CurrentWeather: Codable {
let temp: Float
}
struct WeatherInfo: Codable {
let description: String
}
ViewModel
import Foundation
class WeatherViewModel: ObservableObject {
//everytime these properties are updated, any view holding onto an instance of this viewModel will go ahead and updated the respective UI
#Published var title: String = "-"
#Published var temp: String = "-"
#Published var descriptionText: String = "-"
#Published var enterCityName: String = ""
init() {
fetchWeather()
}
func fetchWeather() {
let searchedCity = WeatherModel(name: enterCityName, main: CurrentWeather.init(temp: 123.00), weather: [WeatherInfo.init(description: "")])
guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(searchedCity.name)&units=imperial&appid=<myAPIKey>") else {
return
}
let task = URLSession.shared.dataTask(with: url) { data, _, error in
// get data
guard let data = data, error == nil else {
return
}
//convert data to model
do {
let model = try JSONDecoder().decode(WeatherModel.self, from: data)
DispatchQueue.main.async {
self.title = model.name
self.temp = "\(model.main.temp)"
self.descriptionText = model.weather.first?.description ?? "No Description"
}
}
catch {
print(error)
}
}
task.resume()
}
}
There are many ways to do what you ask, the following code is just one approach. Since you only need the city name to get the result, just use only that in the url string. Also using your WeatherModel in the WeatherViewModel avoids duplicating the data into various intermediate variables.
PS: do not post your secret appid key in your url.
import Foundation
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
#StateObject var viewModel = WeatherViewModel()
#State private var cityName = "" // <-- use this to get the city name
var body: some View {
NavigationView {
VStack {
TextField("Enter City Name", text: $cityName).textFieldStyle(.roundedBorder)
Button(action: {
viewModel.fetchWeather(for: cityName) // <-- let the model fetch the results
cityName = ""
}, label: {
Text("Search")
.padding(10)
.background(Color.green)
.foregroundColor(Color.white)
.cornerRadius(10)
})
// --- display the results ---
Text(viewModel.cityWeather.name).font(.system(size: 32))
Text("\(viewModel.cityWeather.main.temp)").font(.system(size: 44))
Text(viewModel.cityWeather.firstWeatherInfo()).font(.system(size: 24))
Spacer()
}
.navigationTitle("Weather MVVM")
}.navigationViewStyle(.stack)
}
}
class WeatherViewModel: ObservableObject {
// use your WeatherModel that you get from the fetch results
#Published var cityWeather: WeatherModel = WeatherModel()
func fetchWeather(for cityName: String) {
guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?q=\(cityName)&units=imperial&appid=YOURKEY") else { return }
let task = URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
let model = try JSONDecoder().decode(WeatherModel.self, from: data)
DispatchQueue.main.async {
self.cityWeather = model
}
}
catch {
print(error) // <-- need to deal with errors here
}
}
task.resume()
}
}
struct WeatherModel: Codable {
var name: String = ""
var main: CurrentWeather = CurrentWeather()
var weather: [WeatherInfo] = []
func firstWeatherInfo() -> String {
return weather.count > 0 ? weather[0].description : ""
}
}
struct CurrentWeather: Codable {
var temp: Float = 0.0
}
struct WeatherInfo: Codable {
var description: String = ""
}
I want to seek feedback as to whether or not the practice of passing searchCity.name directly into the URL (in the viewModel) is correct.e
You should alway avoid to pass fake values to an object/class like Int(123).
Instead you should use nullable structures or classes.
I don't see the need of create a whole WeatherModel instance just to read one property from it, one property that you already have in a viewmodel's enterCityName property. Just use the viewmodel's enterCityName property instead.

Swift Alamofire list view state

I'm trying to create a list view with some data I am pulling from an API. I'm struggling to understand how to take the data from the API response and putting it into the state for my app to use. Below is the content view in my application that is pulling the data.
import SwiftUI
import Alamofire
struct ContentView: View {
#State var results = [Bottle]()
var body: some View {
List(results, id: \.id) { item in
VStack(alignment: .leading) {
Text(item.name)
}
}.onAppear(perform: loadData)
}
func loadData() {
let request = AF.request("https://bevy-staging.herokuapp.com")
request.responseJSON { (data) in
print(data)
}
}
}
I've tried adding this to the result block
AF.request("https://bevy-staging.herokuapp.com/").responseJSON { response in
guard let data = response.data else { return }
if let response = try? JSONDecoder().decode([Bottle].self, from: data) {
DispatchQueue.main.async {
self.results = response
}
return
}
}
However nothing populates in my view and I get the following error.
nw_protocol_get_quic_image_block_invoke dlopen libquic failed
Why am I receiving this error and how can I get my data to display in the list vew?
Here is the model I am working with.
struct Bottle: Decodable {
var id: String
var name: String
var price: String
var sku: String
var size: String
var origination: String
var varietal: String
var brand_bottle: String
}
You need to add "?" to model data that can have null data, for all model rows which can obtain "null" need to use "?" or JSONDecoder wouldn't decode data to your model. Inside the model your rows "origination, varietal, brand_bottle" have "String" data type but from the server, you obtain "null", thus JSONDecoder can't recognize data.
You can check responses use services like "http://jsonviewer.stack.hu/" or any other.
Need to modify model data like below:
struct Bottle: Decodable {
var id: String
var name: String
var price: String
var sku: String
var size: String
var origination: String?
var varietal: String?
var brand_bottle: String?
}
I did recreate your project and all work well code below:
import SwiftUI
import Alamofire
struct ContentView: View {
#State var results = [Bottle]()
var body: some View {
List(results, id: \.id) { item in
VStack(alignment: .leading) {
Text(item.name)
}
}.onAppear(perform: loadData)
}
func loadData() {
AF.request("https://bevy-staging.herokuapp.com/").responseJSON { response in
guard let data = response.data else { return }
if let response = try? JSONDecoder().decode([Bottle].self, from: data) {
DispatchQueue.main.async {
self.results = response
}
return
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct Bottle: Decodable {
var id: String
var name: String
var price: String
var sku: String
var size: String
var origination: String?
var varietal: String?
var brand_bottle: String?
}

Error deleting records from a SwiftUI List and Realm

Has anyone been able to successfully integrate Realm with SwiftUI, especially deleting records/rows from a SwiftUI List? I have tried a few different methods but no matter what I do I get the same error. After reading some related threads I found out that other people have the same issue.
The following code successfully presents all of the items from Realm in a SwiftUI List, I can create new ones and they show up in the List as expected, my issues is when I try to delete records from the List by either manually pressing a button or by left-swiping to delete the selected row, I get an Index is out of bounds error.
Any idea what could be causing the error?
Here is my code:
Realm Model
class Dog: Object {
#objc dynamic var name = ""
#objc dynamic var age = 0
#objc dynamic var createdAt = NSDate()
#objc dynamic var userID = UUID().uuidString
override static func primaryKey() -> String? {
return "userID"
}
}
SwiftUI Code
class BindableResults<Element>: ObservableObject where Element: RealmSwift.RealmCollectionValue {
var results: Results<Element>
private var token: NotificationToken!
init(results: Results<Element>) {
self.results = results
lateInit()
}
func lateInit() {
token = results.observe { [weak self] _ in
self?.objectWillChange.send()
}
}
deinit {
token.invalidate()
}
}
struct DogRow: View {
var dog = Dog()
var body: some View {
HStack {
Text(dog.name)
Text("\(dog.age)")
}
}
}
struct ContentView : View {
#ObservedObject var dogs = BindableResults(results: try! Realm().objects(Dog.self))
var body: some View {
VStack{
List{
ForEach(dogs.results, id: \.name) { dog in
DogRow(dog: dog)
}.onDelete(perform: deleteRow )
}
Button(action: {
try! realm.write {
realm.delete(self.dogs.results[0])
}
}){
Text("Delete User")
}
}
}
private func deleteRow(with indexSet: IndexSet){
indexSet.forEach ({ index in
try! realm.write {
realm.delete(self.dogs.results[index])
}
})
}
}
Error
Terminating app due to uncaught exception ‘RLMException’, reason: ‘Index 23 is out of bounds (must be less than 23).’
Of course, the 23 changes depending on how many items are in the Realm database, in this case, I had 24 records when I swiped and tapped the delete button.
FYI - The error points to the AppDelegate file with a Thread 1: signal SIGABRT.
Here is an example of how i do this. This is without realm operations but i hope u get the idea where you can put the realm stuff. (I also almost never use the realm objects directly but instead convert them to structs or classes.)
import Foundation
import Realm
import Combine
import SwiftUI
struct dogs: Hashable {
let name: String
}
class RealmObserverModel: ObservableObject {
var didChange = PassthroughSubject<Void, Never>()
#Published var dogsList: [dogs] = [dogs(name: "Dog 1"), dogs(name: "Dog 2")]
// get your realm objects here and set it to
// the #Publsished var
func getDogs() {
let count = dogsList.count + 1
dogsList.append(dogs(name: "Dog \(count)"))
}
// get your realm objects here and set it to
// the #Publsished var
func deletetDogs() {
_ = dogsList.popLast()
}
}
/// Master View
struct DogView: View {
#EnvironmentObject var observer: RealmObserverModel
var body: some View {
VStack{
DogsListView(dogsList: $observer.dogsList)
HStack{
Button(action: {
self.observer.getDogs()
}) {
Text("Get more dogs")
}
Button(action: {
self.observer.deletetDogs()
}) {
Text("Delete dogs")
}
}
}
}
}
// List Subview wiht Binding
struct DogsListView: View {
#Binding var dogsList: [dogs]
var body: some View {
VStack{
List{
ForEach(dogsList, id:\.self) { dog in
Text("\(dog.name)")
}
}
}
}
}
struct DogView_Previews: PreviewProvider {
static var previews: some View {
DogView().environmentObject(RealmObserverModel())
}
}
Not a great solution but my work around was copying each realm result to a local object/array. I updated my Lists/Views to use the realmLocalData instead of the data returned from the realm object itself.
class ContentViewController: ObservableObject {
private var realmLocalData: [ScheduleModel] = [ScheduleModel]()
private let realm = try! Realm()
func updateData() {
realmLocalData.removeAll()
let predicate = NSPredicate(format: "dateIndex >= %# && dateIndex <= %#", argumentArray: [startDate, endDate])
let data = self.realm.objects(MonthScheduleModel.self).filter(predicate)
for obj in data {
realmLocalData.append(ScheduleModel(realmObj: obj))
}
}
}

Cannot fetch data from Cloud Firestore in SwiftUI Button view

Why does this not work? There is data in Firestore, but it appears the block doesn't execute:
import SwiftUI
import FirebaseFirestore
struct ContentView: View {
var body: some View {
VStack{
Button(
action: {
print("Getting data...")
let db = Firestore.firestore().collection("menu")
let query = db.order(by: "name", descending: true)
query.getDocuments() { snapshot, err in
guard let snapshot = snapshot else {
return
}
print(snapshot)
for doc in snapshot.documents {
print(doc)
}
}
},
label: { Text("Click Me") }
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
You shouldn't add data access logic to a button's action handler. Instead, extract data access logic into a view model (or even a repository), and then add a subscription on the view model's properties like this:
Hypothetical menu item data model:
struct MenuItem: Identifiable {
var id: String = UUID().uuidString
var title: String
}
View Model:
import Foundation
import FirebaseFirestore
class MenuItemsViewModel: ObservableObject {
#Published var menuItems = [MenuItem]()
private var db = Firestore.firestore()
func fetchData() {
db.collection("menuitems").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.menuItems = documents.map { queryDocumentSnapshot -> MenuItem in
let data = queryDocumentSnapshot.data()
let title = data["title"] as? String ?? ""
return MenuItem(id: .init(), title: title)
}
}
}
}
And in your view:
struct MenuItemsListView: View {
#ObservedObject var viewModel = MenuItemsViewModel()
var body: some View {
NavigationView {
List(viewModel.menuItems) { menuItem in
VStack(alignment: .leading) {
Text(menuItem.title)
.font(.headline)
}
}
.navigationBarTitle("Menu")
.onAppear() { // (3)
self.viewModel.fetchData()
}
}
}
}
Data mapping can be further simplified by using Firestore's Codable support:
Here's how you need to update the model:
import FirebaseFirestoreSwift
struct MenuItem: Identifiable, Codable {
#DocumentID var id: String? = UUID().uuidString
var title: String
}
And here is the updated fetchData() method:
func fetchData() {
db.collection("menuitems").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.menuItems = documents.compactMap { (queryDocumentSnapshot) -> MenuItem? in
return try? queryDocumentSnapshot.data(as: MenuItem.self)
}
}
}
If this doesn't work, check the log for any error messages (your security rules might be preventing you from reading data, for example).

Resources