Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions ios-quiz-challenge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# iOS Quiz Challenge

Aplicativo iOS de quiz desenvolvido em Swift para o iOS Developer Challenge.

## Requisitos

- Xcode 26 ou superior
- iOS Simulator com iOS 26 ou superior
- CocoaPods 1.17 ou superior
- Ruby disponível no ambiente local

## Como Rodar

1. Instale as dependências:

```sh
cd ios-quiz-challenge
pod install
```

2. Abra o workspace:

```sh
open ios-quiz-challenge.xcworkspace
```

3. Selecione o scheme `ios-quiz-challenge`.

4. Rode o app em um simulador iOS.

## Como Testar

Pelo Xcode, use o scheme:

- `ios-quiz-challenge-UnitTests`

## Funcionalidades

- Cadastro de nickname antes de iniciar o quiz.
- Fluxo de 10 perguntas de múltipla escolha.
- Perguntas carregadas via `GET /question`.
- Respostas validadas via `POST /answer?questionId=$id`.
- Feedback visual de resposta correta/incorreta antes da próxima pergunta.
- Timer de 120 segundos para o quiz.
- Finalização automática quando o timer chega a zero.
- Pontuação final calculada por `acertos * segundosRestantes`.
- Tela de resultado com opção de reiniciar o quiz.
- Ranking com persistência local.

## Decisões De Produto

- A persistência mantém o melhor score por nickname, em vez de armazenar todas as tentativas. Essa decisão privilegia uma experiência de ranking mais clara para o usuário.
- Os snapshot tests são unit-style, instanciando views/componentes diretamente com mocks/spies, sem navegação e2e pelo app.

## Arquitetura

O app é organizado por feature, foi decidido mante-los no target principal por questão de comodidade, mas poderia ter sido utilizada a mesma estratégia de modularização dos modulos dNetwork e DynaUI, as features mapeadas são:

- `Entry`: cadastro e carregamento do nickname.
- `Quiz`: fluxo de perguntas, respostas, timer e pontuação.
- `Result`: exibição e salvamento do resultado.
- `Ranking`: listagem dos melhores scores.
- `Shared`: componentes visuais compartilhados.

Cada feature segue uma separação de responsabilidades inspirada em VIP/Clean Architecture:

- `Configurator`: montagem da feature e injeção de dependências.
- `Interactor`: regras de negócio e orquestração de use cases.
- `Presenter`: formatação de dados para exibição.
- `Router`: navegação.
- `View/ViewState`: SwiftUI e estado observável.
- `Domain`: use cases e contratos.
- `Service`: comunicação HTTP e DTOs.

## Dependências

O projeto usa CocoaPods com pods locais:

- `DynaUI`: componentes visuais reutilizáveis.
- `dNetwork`: cliente HTTP baseado em `URLSession`.
- `dDependencies`: registro e resolução simples de dependências.

## Persistência

Os dados são persistidos com `UserDefaults` por meio de `UserDefaultsPlayerRepository`.

São armazenados:

- nickname atual;
- melhores scores por nickname.

## API

Host utilizado:

```text
https://quiz-api-bwi5hjqyaq-uc.a.run.app
```

Endpoints:

- `GET /question`
- `POST /answer?questionId=$id`

## Observações

- Para simular a tela de erro, uma forma simples é alterar temporariamente o path `question` para um endpoint inválido em `QuizService.swift`.
- Se `xcodebuild` falhar com erro de `xcode-select`, selecione uma instalação completa do Xcode:

```sh
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
```
30 changes: 30 additions & 0 deletions ios-quiz-challenge/ios-quiz-challenge-UnitTests.xctestplan
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"configurations" : [
{
"id" : "22091426-329E-450B-BC3D-E1B4673828BA",
"name" : "Test Scheme Action",
"options" : {

}
}
],
"defaultOptions" : {
"performanceAntipatternCheckerEnabled" : true,
"targetForVariableExpansion" : {
"containerPath" : "container:ios-quiz-challenge.xcodeproj",
"identifier" : "6C73BA4E300410E900C231BC",
"name" : "ios-quiz-challenge"
}
},
"testTargets" : [
{
"parallelizable" : false,
"target" : {
"containerPath" : "container:ios-quiz-challenge.xcodeproj",
"identifier" : "6C73BA5D300410EB00C231BC",
"name" : "ios-quiz-challengeTests"
}
}
],
"version" : 1
}
21 changes: 21 additions & 0 deletions ios-quiz-challenge/ios-quiz-challenge/DynaUI/DynaUI.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Pod::Spec.new do |spec|
spec.name = 'DynaUI'
spec.version = '0.1.0'
spec.summary = 'SwiftUI components for the DynaUI Design System.'
spec.description = 'A lightweight Design System built with reusable SwiftUI components.'

spec.homepage = 'https://github.com/kiyo92/DynaUI'
spec.license = { :type => 'MIT' }
spec.author = 'João Marcus'

spec.source = {
:git => 'https://github.com/kiyo92/DynaUI.git',
:tag => spec.version.to_s
}

spec.ios.deployment_target = '26.0'
spec.swift_versions = ['5.9', '6.0']

spec.source_files = 'Sources/**/*.swift'
spec.frameworks = 'SwiftUI'
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//
// DynaOptionButton.swift
// Pods
//
// Created by João Marcus Dionisio Araujo on 12/07/26.
//

import SwiftUI

public struct DynaOptionButton: View {

public enum State: Equatable {
case idle
case selected
case correct
case incorrect
}

private let title: String
private let state: State
private let isEnabled: Bool
private let action: () -> Void

public init(
_ title: String,
state: State = .idle,
isEnabled: Bool = true,
action: @escaping () -> Void
) {
self.title = title
self.state = state
self.isEnabled = isEnabled
self.action = action
}

public var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.multilineTextAlignment(.center)
.foregroundColor(foregroundColor)
.padding(.horizontal, 16)
.frame(maxWidth: .infinity)
.frame(minHeight: 44)
}
.buttonStyle(
DynaOptionButtonStyle(
backgroundColor: backgroundColor,
borderColor: borderColor
)
)
.disabled(!isEnabled)
.accessibilityLabel(title)
.accessibilityAddTraits(
state == .selected ? .isSelected : []
)
}
}

private extension DynaOptionButton {

var backgroundColor: Color {
switch state {
case .idle:
return .white

case .selected:
return Color(
red: 0.42,
green: 0.21,
blue: 1
)

case .correct:
return Color(
red: 0.16,
green: 0.68,
blue: 0.35
)

case .incorrect:
return Color(
red: 0.91,
green: 0.22,
blue: 0.27
)
}
}

var foregroundColor: Color {
switch state {
case .idle:
return .black

case .selected, .correct, .incorrect:
return .white
}
}

var borderColor: Color {
.black
}
}

private struct DynaOptionButtonStyle: ButtonStyle {

let backgroundColor: Color
let borderColor: Color

func makeBody(configuration: Configuration) -> some View {
configuration.label
.background {
ZStack {
RoundedRectangle(
cornerRadius: 17,
style: .continuous
)
.fill(Color.black)
.offset(
y: configuration.isPressed ? 1 : 4
)

RoundedRectangle(
cornerRadius: 17,
style: .continuous
)
.fill(backgroundColor)

RoundedRectangle(
cornerRadius: 17,
style: .continuous
)
.stroke(
borderColor,
lineWidth: 2
)
}
}
.contentShape(
RoundedRectangle(
cornerRadius: 17,
style: .continuous
)
)
.offset(
y: configuration.isPressed ? 3 : 0
)
.scaleEffect(
configuration.isPressed ? 0.99 : 1
)
.padding(.bottom, 4)
.animation(
.easeOut(duration: 0.08),
value: configuration.isPressed
)
}
}

struct DynaOptionButton_Previews: PreviewProvider {

static var previews: some View {
VStack(spacing: 16) {
DynaOptionButton("Husky") {}

DynaOptionButton(
"Pitbull",
state: .selected
) {}

DynaOptionButton(
"Shiba Inu",
state: .correct
) {}

DynaOptionButton(
"Golden Retriever",
state: .incorrect
) {}
}
.padding(24)
.background(Color.gray.opacity(0.15))
.previewLayout(.sizeThatFits)
}
}
Loading