Um cliente GitHub nativo para iOS construído com SwiftUI, focado em aprendizado de tecnologias modernas e arquitetura profissional.
- Sobre o Projeto
- Features Implementadas
- Stack Técnica
- Arquitetura
- Conceitos Aprendidos
- Como Rodar
- Estrutura do Projeto
- Aprendizados Detalhados
- Próximos Passos
DevHub é um cliente GitHub para iOS que permite visualizar repositórios, favoritar repos offline, buscar e filtrar com histórico persistente, ver perfil e autenticar via OAuth 2.0.
Este projeto foi desenvolvido com foco em aprendizado técnico e boas práticas, servindo como material de estudos para iOS Development.
- Projeto Xcode + Git + SPM
- OAuth 2.0 com GitHub
- Keychain para tokens
- Design System (AppTheme)
- GraphQL manual (sem Apollo)
- Tela de perfil com dados reais
- Componentes reutilizáveis
- Trending repos
- SwiftData offline
- Sistema de favoritos CRUD
- Pull to refresh
- FavoritesView completa
- Busca local real-time
- Ordenação (data/nome/stars)
- Empty states
- FIX: Crash EXC_BAD_ACCESS
- SearchView com debounce (500ms)
- Integração GraphQL Search API
- Filtros avançados (linguagem, stars, ordenação)
- Histórico de buscas (SwiftData)
- 5 estados de UI (empty/loading/results/error/no-results)
- Badge de filtros ativos
- FIX: SearchFilters não encontrado
- FIX: Decodificação JSON
- FIX: Repository incompatível
- Swift 5.9
- SwiftUI 5.0
- SwiftData (iOS 17+)
- Combine (Debounce)
- Firebase/Auth
- Alamofire
- KeychainSwiftView (SwiftUI)
↓ Observa @Published
ViewModel (ObservableObject + @MainActor)
↓ Chama métodos
Service / Repository
↓ Retorna
Model (Struct Codable)
Exemplo:
// MODEL
struct Repository: Codable {
let name: String
let stars: Int
}
// SERVICE
class GitHubService {
func fetchRepos() async throws -> [Repository] { }
}
// VIEWMODEL
@MainActor
class ReposViewModel: ObservableObject {
@Published var repos: [Repository] = []
func load() async {
repos = try await service.fetchRepos()
}
}
// VIEW
struct ReposView: View {
@StateObject var viewModel = ReposViewModel()
var body: some View {
List(viewModel.repos) { repo in
Text(repo.name)
}
}
}Fluxo:
1. App abre GitHub no navegador
2. User autoriza
3. GitHub retorna código
4. App troca código por token
5. Token salvo no Keychain
6. Todas requests usam: Authorization: Bearer {token}
Por que Keychain?
- ✅ Criptografia AES-256
- ✅ Secure Enclave
- ❌ UserDefaults NÃO é seguro!
REST vs GraphQL:
REST:
GET /user → Over-fetching
GET /repos → 2 requests
GraphQL:
query {
user { name }
repos { name stars }
}
→ 1 request, dados exatos
Search API do GitHub:
query SearchRepositories($searchQuery: String!) {
search(query: $searchQuery, type: REPOSITORY, first: 30) {
repositoryCount
edges {
node {
... on Repository {
id
name
stargazerCount
}
}
}
}
}CoreData vs SwiftData:
// CoreData: ~30 linhas
let entity = NSEntityDescription.entity...
let favorite = NSManagedObject...
favorite.setValue(name, forKey: "name")
try context.save()
// SwiftData: ~10 linhas
@Model
class Favorite {
var name: String
}
context.insert(favorite)
try context.save()70% menos código!
Sprint 4.2 - Múltiplos Modelos:
// DevHubApp.swift
let schema = Schema([
FavoriteRepository.self,
SearchHistory.self // ← Novo!
])DIFERENÇA CRÍTICA:
// ❌ ERRADO - Causou crash!
@State var viewModel: ViewModel?
// @State NÃO observa classes
// ✅ CORRETO
@StateObject var viewModel = ViewModel()
// @StateObject cria E observaQuando usar:
@State→ Int, String, Bool@StateObject→ ViewModels (cria)@ObservedObject→ ViewModels (recebe)
Problema Real:
// ❌ CAUSOU CRASH
class Service {
private let context: ModelContext // Referência forte!
init(context: ModelContext) {
self.context = context // Captura
}
}
// SwiftUI recria View → context deallocado → CRASH!Solução:
// ✅ CORRETO
class Service {
init() { } // Sem captura
func fetch(context: ModelContext) {
// Recebe por parâmetro
}
}// ❌ RUIM
class ViewModel {
let service = Service() // Acoplado
}
// ✅ BOM
class ViewModel {
let service: ServiceProtocol
init(service: ServiceProtocol) {
self.service = service
}
}
// Produção:
ViewModel(service: RealService())
// Testes:
ViewModel(service: MockService())Antes (Closures):
fetchUser { result in
switch result {
case .success(let user):
print(user)
case .failure(let error):
print(error)
}
}Depois (Async/Await):
do {
let user = try await fetchUser()
print(user)
} catch {
print(error)
}// ❌ SEM
Text("Título")
.font(.system(size: 24))
.foregroundColor(.blue)
// ✅ COM
Text("Título")
.font(AppTheme.Typography.title)
.foregroundColor(AppTheme.Colors.primary)Consistência visual!
Problema: Buscar a cada tecla = spam na API
Solução:
$searchText
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.sink { query in
Task {
await performSearch(query: query)
}
}
.store(in: &cancellables)Resultado: Só busca 500ms DEPOIS que o usuário parou de digitar!
Problema: API retorna "errors" mas código tentava decodificar "data" → crash
Solução:
// 1. Verificar se há erro ANTES de decodificar
if let errors = json["errors"] as? [[String: Any]] {
let messages = errors.compactMap { $0["message"] as? String }
throw ServiceError.networkError(messages.joined(separator: ", "))
}
// 2. SÓ AGORA decodificar
let response = try decoder.decode(Response.self, from: data)Logs detalhados:
print("📡 Status code: \(httpResponse.statusCode)")
print("📄 JSON bruto: \(jsonString.prefix(500))...")
print("❌ Erro GraphQL: \(errorMessages)")git clone https://github.com/jeff77araujo/DevHub.git
cd DevHub- Acesse: https://github.com/settings/developers
- Clique em New OAuth App
- Preencha:
- Application name: DevHub
- Homepage URL: https://github.com/jeff77araujo/DevHub
- Authorization callback URL:
devhub://callback
- Copie Client ID e Client Secret
Crie o arquivo Secrets.swift em DevHub/Core/Security/:
// Secrets.swift
enum Secrets {
static let githubClientId = "SEU_CLIENT_ID_AQUI"
static let githubClientSecret = "SEU_CLIENT_SECRET_AQUI"
}.gitignore por segurança!
# SPM instala automaticamente ao abrir o projeto
open DevHub.xcodeproj# No Xcode:
Cmd + RDevHub/
├── App/
│ └── DevHubApp.swift
│
├── Core/
│ ├── Security/
│ │ ├── Secrets.swift
│ │ └── KeychainManager.swift
│ ├── Repositories/
│ │ ├── GitHubReposService.swift
│ │ └── GitHubSearchService.swift ← Sprint 4.2
│ └── Services/
│ └── FavoritesService.swift
│
├── Features/
│ ├── Auth/
│ │ ├── AuthService.swift
│ │ └── LoginView.swift
│ ├── Home/
│ │ └── HomeView.swift
│ ├── Profile/
│ │ ├── ProfileViewModel.swift
│ │ └── ProfileView.swift
│ ├── Repos/
│ │ ├── ReposViewModel.swift
│ │ └── ReposListView.swift
│ ├── Favorites/
│ │ ├── FavoritesViewModel.swift
│ │ └── FavoritesView.swift
│ └── Search/ ← Sprint 4.2
│ ├── SearchViewModel.swift
│ ├── SearchView.swift
│ └── SearchFiltersView.swift
│
├── Models/
│ ├── User.swift
│ ├── Repository.swift
│ ├── FavoriteRepository.swift
│ ├── SearchHistory.swift ← Sprint 4.2
│ └── SearchFilters.swift ← Sprint 4.2
│
├── UI/
│ ├── Components/
│ │ ├── RepoCard.swift
│ │ ├── ProfileHeader.swift
│ │ └── EmptyStateView.swift
│ └── Theme/
│ └── AppTheme.swift
│
└── MainTabView.swift
Diagnóstico:
1. Stack trace → FavoritesService.swift linha 13
2. Problema: self.modelContext = modelContext
3. Referência forte causava acesso a memória deallocada
Solução:
// ANTES (❌):
private let modelContext: ModelContext
// DEPOIS (✅):
func fetch(context: ModelContext) { }Lição: Evitar captura de ModelContext em properties.
Causa:
@State private var viewModel: ViewModel?
// @State NÃO observa ObservableObjectSolução:
@StateObject private var viewModel = ViewModel()Lição: Property wrappers corretos são CRÍTICOS.
Erro:
Cannot find type 'SearchFilters' in scope
Causa: Arquivo SearchFilters.swift não foi criado na primeira versão.
Solução: Criar modelo completo com enums:
struct SearchFilters {
var language: String?
var minStars: Int?
var sortBy: SortOption
enum Language: String, CaseIterable {
case all = ""
case swift = "Swift"
case python = "Python"
// ...
}
enum StarsFilter: Int, CaseIterable {
case any = 0
case oneK = 1000
case fiveK = 5000
// ...
}
}Erro:
keyNotFound(CodingKeys(stringValue: "data", intValue: nil))
Causa: API retornava "errors" mas código tentava decodificar "data" direto.
Solução:
// 1. Verificar erros ANTES
if let errors = json["errors"] as? [[String: Any]] {
let errorMessages = errors.compactMap { $0["message"] as? String }
throw ServiceError.networkError(errorMessages.joined(separator: ", "))
}
// 2. SÓ AGORA decodificar
let searchResponse = try decoder.decode(SearchRepositoriesResponse.self, from: data)Logs adicionados:
- Status HTTP
- JSON bruto (primeiros 500 chars)
- Mensagens de erro GraphQL
- Debug detalhado de
DecodingError
Erro:
Extra arguments at positions #3, #10 in call
Missing argument for parameter 'url' in call
Cannot find 'Owner' in scope
Causa: Inicializador do Repository era diferente do esperado pelo search.
Solução:
Ajustar mapeamento no GitHubSearchService:
let repo = Repository(
id: edge.node.id,
name: edge.node.name,
owner: edge.node.owner.login, // String, não struct Owner
ownerAvatarURL: edge.node.owner.avatarUrl,
description: edge.node.description,
url: edge.node.url,
language: edge.node.primaryLanguage?.name,
languageColor: edge.node.primaryLanguage?.color,
stargazersCount: edge.node.stargazerCount,
forksCount: edge.node.forkCount,
updatedAt: edge.node.updatedAt
)GraphQL Manual vs Apollo:
- ✅ Queries simples
- ✅ Menos dependências
- ✅ Controle total
- ❌ Apollo tem setup complexo
SwiftData vs CoreData:
- ✅ 70% menos código
- ✅ Type-safe
- ✅ Macros automáticas
- ❌ iOS 17+ apenas
MVVM vs VIPER:
- ✅ Padrão SwiftUI
- ✅ Testável
- ✅ Menos boilerplate
- ❌ VIPER é overkill
Debounce 500ms vs Tempo Real:
- ✅ Evita spam na API
- ✅ Melhor UX (não fica "piscando")
- ✅ Rate limit do GitHub
- ❌ Delay mínimo (imperceptível)
- RepoDetailView
- README rendering (Markdown)
- Issues count
- Contributors
- Linguagens (gráfico)
- Unit tests (ViewModels)
- Mock services
- Coverage 80%+
- CI/CD setup
- Pagination (infinite scroll)
- Cache de imagens
- Animações (skeleton loading)
- Dark mode otimizado
- Haptic feedback
📝 Linhas de Código: ~4.400+
📁 Arquivos: 55+
🎨 Componentes: 9
📱 Telas: 9
⏱️ Tempo: ~15h
🐛 Bugs Corrigidos: 15
Sprint 4.2 adicionou:
- +600 linhas de código
- +8 arquivos
- +2 componentes (SearchView, SearchFiltersView)
- +3h desenvolvimento
- +5 bugs corrigidos
- SwiftUI Tutorials
- SwiftData Docs
- GitHub GraphQL
- GitHub Search API
- Hacking with Swift
- Combine Framework
Jeff Araujo
- GitHub: @jeff77araujo
- LinkedIn: Jeff Araujo