iOS App Bootstrap (SwiftUI)
Native iOS buyer app for Talbino marketplace.
Tech Stack
- Language: Swift 5.9+
- UI Framework: SwiftUI
- Min iOS Version: iOS 15.0
- Architecture: MVVM + Clean Architecture
- Networking: URLSession + Async/Await
- Real-time: Starscream (WebSocket)
- Storage: SwiftData / Core Data
- Dependency Injection: Manual DI
- Push Notifications: Firebase Cloud Messaging
- Analytics: Firebase Analytics
- Image Loading: Kingfisher
Project Structure
Talbino/
├── App/
│ ├── TalbinoApp.swift
│ ├── AppDelegate.swift
│ └── SceneDelegate.swift
├── Features/
│ ├── Auth/
│ │ ├── Views/
│ │ │ ├── PhoneInputView.swift
│ │ │ ├── OTPVerificationView.swift
│ │ │ └── ProfileSetupView.swift
│ │ ├── ViewModels/
│ │ │ └── AuthViewModel.swift
│ │ └── Models/
│ │ └── User.swift
│ ├── Requests/
│ │ ├── Views/
│ │ │ ├── RequestsListView.swift
│ │ │ ├── RequestDetailView.swift
│ │ │ ├── CreateRequestView.swift
│ │ │ └── Components/
│ │ │ └── RequestCard.swift
│ │ ├── ViewModels/
│ │ │ ├── RequestsViewModel.swift
│ │ │ └── CreateRequestViewModel.swift
│ │ └── Models/
│ │ └── BuyRequest.swift
│ ├── Offers/
│ │ ├── Views/
│ │ │ ├── OffersListView.swift
│ │ │ ├── OfferDetailView.swift
│ │ │ └── Components/
│ │ │ └── OfferCard.swift
│ │ ├── ViewModels/
│ │ │ └── OffersViewModel.swift
│ │ └── Models/
│ │ └── Offer.swift
│ ├── Chat/
│ │ ├── Views/
│ │ │ ├── ConversationsListView.swift
│ │ │ ├── ChatView.swift
│ │ │ └── Components/
│ │ │ └── MessageBubble.swift
│ │ ├── ViewModels/
│ │ │ └── ChatViewModel.swift
│ │ └── Models/
│ │ ├── Conversation.swift
│ │ └── Message.swift
│ ├── Deals/
│ │ ├── Views/
│ │ │ ├── DealsListView.swift
│ │ │ ├── DealDetailView.swift
│ │ │ └── RatingView.swift
│ │ ├── ViewModels/
│ │ │ └── DealsViewModel.swift
│ │ └── Models/
│ │ └── Deal.swift
│ └── Profile/
│ ├── Views/
│ │ ├── ProfileView.swift
│ │ ├── SettingsView.swift
│ │ └── BecomeSellerView.swift
│ ├── ViewModels/
│ │ └── ProfileViewModel.swift
│ └── Models/
│ └── SellerProfile.swift
├── Shared/
│ ├── Models/
│ │ ├── APIResponse.swift
│ │ └── APIError.swift
│ ├── Services/
│ │ ├── APIService.swift
│ │ ├── WebSocketService.swift
│ │ ├── AuthService.swift
│ │ ├── NotificationService.swift
│ │ └── StorageService.swift
│ ├── Components/
│ │ ├── LoadingView.swift
│ │ ├── EmptyStateView.swift
│ │ ├── ErrorView.swift
│ │ └── PrimaryButton.swift
│ ├── Extensions/
│ │ ├── View+Extensions.swift
│ │ ├── String+Extensions.swift
│ │ └── Date+Extensions.swift
│ └── Utilities/
│ ├── Constants.swift
│ ├── Localization.swift
│ └── DeepLinkHandler.swift
├── Resources/
│ ├── Assets.xcassets/
│ ├── Localizable.strings (ar)
│ ├── Localizable.strings (en)
│ └── GoogleService-Info.plist
└── TalbinoTests/
├── ViewModelTests/
└── ServiceTests/
Key Screens & Navigation
Navigation Structure
TabView
├── Requests Tab
│ ├── RequestsListView
│ ├── → RequestDetailView
│ │ └── → OffersListView
│ │ └── → OfferDetailView
│ │ ├── → ChatView
│ │ └── → AcceptOfferSheet
│ └── → CreateRequestView
├── Deals Tab
│ ├── DealsListView
│ └── → DealDetailView
│ ├── → ChatView
│ ├── → CompleteDealSheet
│ └── → RatingView
├── Chat Tab
│ ├── ConversationsListView
│ └── → ChatView
└── Profile Tab
├── ProfileView
├── → SettingsView
├── → BecomeSellerView
└── → EditProfileView
Core ViewModels
RequestsViewModel
@MainActor
class RequestsViewModel: ObservableObject {
@Published var requests: [BuyRequest] = []
@Published var isLoading = false
@Published var error: APIError?
private let apiService: APIService
init(apiService: APIService = .shared) {
self.apiService = apiService
}
func fetchRequests() async {
isLoading = true
defer { isLoading = false }
do {
requests = try await apiService.getMyRequests()
} catch {
self.error = error as? APIError
}
}
func createRequest(_ request: CreateRequestInput) async throws {
let newRequest = try await apiService.createRequest(request)
requests.insert(newRequest, at: 0)
}
}
ChatViewModel
@MainActor
class ChatViewModel: ObservableObject {
@Published var messages: [Message] = []
@Published var isConnected = false
private let webSocketService: WebSocketService
private let conversationId: String
init(conversationId: String, webSocketService: WebSocketService = .shared) {
self.conversationId = conversationId
self.webSocketService = webSocketService
}
func connect() {
webSocketService.connect()
webSocketService.subscribe(to: conversationId)
webSocketService.onMessage = { [weak self] message in
self?.messages.append(message)
}
}
func sendMessage(_ text: String) {
webSocketService.sendMessage(text, to: conversationId)
}
}
API Service
class APIService {
static let shared = APIService()
private let baseURL = "https://api.talbino.com/api/v1"
private var accessToken: String?
func request<T: Decodable>(
_ endpoint: String,
method: HTTPMethod = .get,
body: Encodable? = nil
) async throws -> T {
var request = URLRequest(url: URL(string: baseURL + endpoint)!)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.httpError(httpResponse.statusCode)
}
let apiResponse = try JSONDecoder().decode(APIResponse<T>.self, from: data)
return apiResponse.data
}
}
Key Views
CreateRequestView
struct CreateRequestView: View {
@StateObject private var viewModel = CreateRequestViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("المنتج") {
Picker("الفئة", selection: $viewModel.category) {
Text("هاتف").tag("phone")
Text("إكسسوار").tag("accessory")
}
TextField("الماركة", text: $viewModel.brand)
TextField("الموديل", text: $viewModel.model)
Picker("الحالة المفضلة", selection: $viewModel.condition) {
ForEach(Condition.allCases) { condition in
Text(condition.localizedName).tag(condition)
}
}
}
Section("الميزانية") {
TextField("الحد الأدنى", value: $viewModel.budgetMin, format: .number)
.keyboardType(.numberPad)
TextField("الحد الأقصى", value: $viewModel.budgetMax, format: .number)
.keyboardType(.numberPad)
}
Section("الموقع") {
Picker("المنطقة", selection: $viewModel.district) {
ForEach(districts) { district in
Text(district.name).tag(district)
}
}
}
Section("ملاحظات") {
TextEditor(text: $viewModel.notes)
.frame(height: 100)
}
}
.navigationTitle("طلب جديد")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("إلغاء") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("نشر") {
Task {
await viewModel.submit()
dismiss()
}
}
.disabled(!viewModel.isValid)
}
}
}
}
}
OfferCard
struct OfferCard: View {
let offer: Offer
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading) {
Text(offer.seller.name)
.font(.headline)
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text(String(format: "%.1f", offer.seller.ratingAvg))
Text("(\(offer.seller.ratingCount))")
.foregroundColor(.secondary)
}
.font(.caption)
}
Spacer()
VStack(alignment: .trailing) {
Text("\(offer.price) جنيه")
.font(.title2)
.fontWeight(.bold)
Text(offer.availability)
.font(.caption)
.foregroundColor(.secondary)
}
}
if !offer.conditionNotes.isEmpty {
Text(offer.conditionNotes)
.font(.subheadline)
.foregroundColor(.secondary)
}
HStack {
Button("محادثة") {
// Navigate to chat
}
.buttonStyle(.bordered)
Spacer()
Button("قبول العرض") {
// Accept offer
}
.buttonStyle(.borderedProminent)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
}
Localization
Localizable.strings (ar)
/* Common */
"app.name" = "طلبينو";
"common.cancel" = "إلغاء";
"common.save" = "حفظ";
"common.done" = "تم";
"common.loading" = "جاري التحميل...";
/* Requests */
"requests.title" = "طلباتي";
"requests.new" = "طلب جديد";
"requests.empty" = "لا توجد طلبات بعد";
"requests.post_first" = "انشر طلبك الأول";
/* Offers */
"offers.title" = "العروض";
"offers.count" = "%d عروض";
"offers.accept" = "قبول العرض";
"offers.chat" = "محادثة";
/* Chat */
"chat.title" = "المحادثات";
"chat.type_message" = "اكتب رسالة...";
"chat.send" = "إرسال";
Push Notifications
Setup
// AppDelegate.swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FirebaseApp.configure()
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
return true
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
Task {
try await APIService.shared.registerDeviceToken(token, platform: "ios")
}
}
Deep Linking
// Handle push notification tap
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
if let deepLink = userInfo["deep_link"] as? String {
DeepLinkHandler.shared.handle(deepLink)
}
completionHandler()
}
State Management
Using @StateObject, @ObservedObject, and @EnvironmentObject for state management.
Testing Strategy
- Unit Tests: ViewModels, Services
- UI Tests: Critical user flows
- Snapshot Tests: UI components
Build Configuration
Debug
API_BASE_URL = https://api-dev.talbino.com
WS_URL = wss://api-dev.talbino.com/ws
Release
API_BASE_URL = https://api.talbino.com
WS_URL = wss://api.talbino.com/ws
MVP Screens Checklist
- Phone input + OTP verification
- Profile setup
- Requests list
- Create request
- Request detail with offers
- Offer detail
- Chat
- Deals list
- Deal detail
- Complete deal + rating
- Profile & settings
- Become seller application
Dependencies (SPM)
dependencies: [
.package(url: "https://github.com/firebase/firebase-ios-sdk", from: "10.0.0"),
.package(url: "https://github.com/daltoniam/Starscream", from: "4.0.0"),
.package(url: "https://github.com/onevcat/Kingfisher", from: "7.0.0"),
]