1
0

add claude

This commit is contained in:
2025-06-01 21:39:53 +09:00
parent 3459231bba
commit 4246f718ef
80 changed files with 7249 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234551234567890ABCDEF /* AiCardApp.swift */; };
1A1234581234567890ABCDEF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234571234567890ABCDEF /* ContentView.swift */; };
1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A1234591234567890ABCDEF /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
1A1234521234567890ABCDEF /* AiCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AiCard.app; sourceTree = BUILT_PRODUCTS_DIR; };
1A1234551234567890ABCDEF /* AiCardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiCardApp.swift; sourceTree = "<group>"; };
1A1234571234567890ABCDEF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
1A1234591234567890ABCDEF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1A12344F1234567890ABCDEF /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1A1234491234567890ABCDEF = {
isa = PBXGroup;
children = (
1A1234541234567890ABCDEF /* AiCard */,
1A1234531234567890ABCDEF /* Products */,
);
sourceTree = "<group>";
};
1A1234531234567890ABCDEF /* Products */ = {
isa = PBXGroup;
children = (
1A1234521234567890ABCDEF /* AiCard.app */,
);
name = Products;
sourceTree = "<group>";
};
1A1234541234567890ABCDEF /* AiCard */ = {
isa = PBXGroup;
children = (
1A1234551234567890ABCDEF /* AiCardApp.swift */,
1A1234571234567890ABCDEF /* ContentView.swift */,
1A1234591234567890ABCDEF /* Assets.xcassets */,
);
path = AiCard;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1A1234511234567890ABCDEF /* AiCard */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */;
buildPhases = (
1A12344E1234567890ABCDEF /* Sources */,
1A12344F1234567890ABCDEF /* Frameworks */,
1A1234501234567890ABCDEF /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = AiCard;
productName = AiCard;
productReference = 1A1234521234567890ABCDEF /* AiCard.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
1A12344A1234567890ABCDEF /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
1A1234511234567890ABCDEF = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 1A1234491234567890ABCDEF;
productRefGroup = 1A1234531234567890ABCDEF /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1A1234511234567890ABCDEF /* AiCard */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1A1234501234567890ABCDEF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1A12344E1234567890ABCDEF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1A1234581234567890ABCDEF /* ContentView.swift in Sources */,
1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1A12345E1234567890ABCDEF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
1A12345F1234567890ABCDEF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
1A1234611234567890ABCDEF /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
1A1234621234567890ABCDEF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A12345E1234567890ABCDEF /* Debug */,
1A12345F1234567890ABCDEF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1A1234611234567890ABCDEF /* Debug */,
1A1234621234567890ABCDEF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 1A12344A1234567890ABCDEF /* Project object */;
}

View File

@@ -0,0 +1,16 @@
import SwiftUI
@main
struct AiCardApp: App {
@StateObject private var authManager = AuthManager()
@StateObject private var cardManager = CardManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authManager)
.environmentObject(cardManager)
.preferredColorScheme(.dark)
}
}
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var authManager: AuthManager
@State private var selectedTab = 0
var body: some View {
if authManager.isAuthenticated {
TabView(selection: $selectedTab) {
GachaView()
.tabItem {
Label("ガチャ", systemImage: "sparkles")
}
.tag(0)
CollectionView()
.tabItem {
Label("コレクション", systemImage: "square.grid.3x3")
}
.tag(1)
ProfileView()
.tabItem {
Label("プロフィール", systemImage: "person.circle")
}
.tag(2)
}
.accentColor(Color(hex: "fff700"))
} else {
LoginView()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,90 @@
import Foundation
enum CardRarity: String, Codable, CaseIterable {
case normal = "normal"
case rare = "rare"
case superRare = "super_rare"
case kira = "kira"
case unique = "unique"
var displayName: String {
switch self {
case .normal: return "ノーマル"
case .rare: return "レア"
case .superRare: return "スーパーレア"
case .kira: return "キラ"
case .unique: return "ユニーク"
}
}
var gradientColors: [String] {
switch self {
case .normal: return ["666666", "333333"]
case .rare: return ["4a90e2", "16213e"]
case .superRare: return ["9c27b0", "0f0c29"]
case .kira: return ["ffd700", "414345"]
case .unique: return ["ff00ff", "1a0033"]
}
}
}
struct Card: Identifiable, Codable {
let id: Int
let cp: Int
let status: CardRarity
let skill: String?
let ownerDid: String
let obtainedAt: Date
let isUnique: Bool
let uniqueId: String?
private enum CodingKeys: String, CodingKey {
case id
case cp
case status
case skill
case ownerDid = "owner_did"
case obtainedAt = "obtained_at"
case isUnique = "is_unique"
case uniqueId = "unique_id"
}
}
struct CardDrawResult: Codable {
let card: Card
let isNew: Bool
let animationType: String
private enum CodingKeys: String, CodingKey {
case card
case isNew = "is_new"
case animationType = "animation_type"
}
}
// Card master data
struct CardInfo {
let id: Int
let name: String
let color: String
let description: String
static let all: [Int: CardInfo] = [
0: CardInfo(id: 0, name: "アイ", color: "fff700", description: "世界の最小単位"),
1: CardInfo(id: 1, name: "夢幻", color: "b19cd9", description: "意識が物質を作る"),
2: CardInfo(id: 2, name: "光彩", color: "ffd700", description: "存在は光に向かう"),
3: CardInfo(id: 3, name: "中性子", color: "cacfd2", description: "中性子"),
4: CardInfo(id: 4, name: "太陽", color: "ff6b35", description: "太陽"),
5: CardInfo(id: 5, name: "夜空", color: "1a1a2e", description: "夜空"),
6: CardInfo(id: 6, name: "", color: "e3f2fd", description: ""),
7: CardInfo(id: 7, name: "", color: "ffd93d", description: ""),
8: CardInfo(id: 8, name: "超究", color: "6c5ce7", description: "超究"),
9: CardInfo(id: 9, name: "", color: "a8e6cf", description: ""),
10: CardInfo(id: 10, name: "破壊", color: "ff4757", description: "破壊"),
11: CardInfo(id: 11, name: "地球", color: "4834d4", description: "地球"),
12: CardInfo(id: 12, name: "天の川", color: "9c88ff", description: "天の川"),
13: CardInfo(id: 13, name: "創造", color: "00d2d3", description: "創造"),
14: CardInfo(id: 14, name: "超新星", color: "ff9ff3", description: "超新星"),
15: CardInfo(id: 15, name: "世界", color: "54a0ff", description: "存在と世界は同じもの")
]
}

View File

@@ -0,0 +1,25 @@
import Foundation
struct User: Codable {
let did: String
let handle: String
}
struct LoginRequest: Codable {
let identifier: String
let password: String
}
struct LoginResponse: Codable {
let accessToken: String
let tokenType: String
let did: String
let handle: String
private enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case tokenType = "token_type"
case did
case handle
}
}

View File

@@ -0,0 +1,125 @@
import Foundation
import Combine
enum APIError: Error {
case invalidURL
case noData
case decodingError
case networkError(String)
case unauthorized
}
class APIClient {
static let shared = APIClient()
#if DEBUG
private let baseURL = "http://localhost:8000/api/v1"
#else
private let baseURL = "https://api.card.syui.ai/api/v1"
#endif
private var cancellables = Set<AnyCancellable>()
private init() {}
private var authToken: String? {
get { UserDefaults.standard.string(forKey: "authToken") }
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
}
private func request<T: Decodable>(_ endpoint: String,
method: String = "GET",
body: Data? = nil,
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
guard let url = URL(string: "\(baseURL)\(endpoint)") else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated, let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = body
}
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if !(200...299).contains(httpResponse.statusCode) {
throw APIError.networkError("Status code: \(httpResponse.statusCode)")
}
return data
}
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
if error is DecodingError {
return APIError.decodingError
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.networkError(error.localizedDescription)
}
}
.eraseToAnyPublisher()
}
// MARK: - Auth
func login(identifier: String, password: String) -> AnyPublisher<LoginResponse, APIError> {
let loginRequest = LoginRequest(identifier: identifier, password: password)
guard let body = try? JSONEncoder().encode(loginRequest) else {
return Fail(error: APIError.decodingError).eraseToAnyPublisher()
}
return request("/auth/login", method: "POST", body: body, authenticated: false)
.handleEvents(receiveOutput: { [weak self] (response: LoginResponse) in
self?.authToken = response.accessToken
})
.eraseToAnyPublisher()
}
func logout() -> AnyPublisher<Void, APIError> {
request("/auth/logout", method: "POST")
.map { (_: [String: String]) in () }
.handleEvents(receiveCompletion: { [weak self] _ in
self?.authToken = nil
})
.eraseToAnyPublisher()
}
func verify() -> AnyPublisher<User, APIError> {
request("/auth/verify")
}
// MARK: - Cards
func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher<CardDrawResult, APIError> {
let body = try? JSONEncoder().encode([
"user_did": userDid,
"is_paid": isPaid
])
return request("/cards/draw", method: "POST", body: body)
}
func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> {
request("/cards/user/\(userDid)")
}
func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> {
request("/cards/unique")
}
}

View File

@@ -0,0 +1,87 @@
import Foundation
import Combine
import SwiftUI
class AuthManager: ObservableObject {
@Published var isAuthenticated = false
@Published var currentUser: User?
@Published var isLoading = false
@Published var errorMessage: String?
private var cancellables = Set<AnyCancellable>()
private let apiClient = APIClient.shared
init() {
checkAuthStatus()
}
private func checkAuthStatus() {
isLoading = true
apiClient.verify()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure = completion {
self?.isAuthenticated = false
self?.currentUser = nil
}
},
receiveValue: { [weak self] user in
self?.isAuthenticated = true
self?.currentUser = user
}
)
.store(in: &cancellables)
}
func login(identifier: String, password: String) {
isLoading = true
errorMessage = nil
apiClient.login(identifier: identifier, password: password)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] response in
self?.isAuthenticated = true
self?.currentUser = User(did: response.did, handle: response.handle)
}
)
.store(in: &cancellables)
}
func logout() {
isLoading = true
apiClient.logout()
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
self?.isAuthenticated = false
self?.currentUser = nil
UserDefaults.standard.removeObject(forKey: "authToken")
},
receiveValue: { _ in }
)
.store(in: &cancellables)
}
private func getErrorMessage(from error: APIError) -> String {
switch error {
case .unauthorized:
return "認証情報が正しくありません"
case .networkError:
return "ネットワークエラーが発生しました"
default:
return "エラーが発生しました"
}
}
}

View File

@@ -0,0 +1,73 @@
import Foundation
import Combine
import SwiftUI
class CardManager: ObservableObject {
@Published var userCards: [Card] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var currentDraw: CardDrawResult?
@Published var isDrawing = false
private var cancellables = Set<AnyCancellable>()
private let apiClient = APIClient.shared
func loadUserCards(userDid: String) {
isLoading = true
apiClient.getUserCards(userDid: userDid)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] cards in
self?.userCards = cards
}
)
.store(in: &cancellables)
}
func drawCard(userDid: String, isPaid: Bool = false) {
isDrawing = true
errorMessage = nil
apiClient.drawCard(userDid: userDid, isPaid: isPaid)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
self?.isDrawing = false
self?.errorMessage = self?.getErrorMessage(from: error)
}
},
receiveValue: { [weak self] result in
self?.currentDraw = result
//
}
)
.store(in: &cancellables)
}
func completeCardDraw() {
if let newCard = currentDraw?.card {
userCards.append(newCard)
}
currentDraw = nil
isDrawing = false
}
private func getErrorMessage(from error: APIError) -> String {
switch error {
case .unauthorized:
return "認証が必要です"
case .networkError:
return "ネットワークエラーが発生しました"
default:
return "エラーが発生しました"
}
}
}

View File

@@ -0,0 +1,28 @@
import SwiftUI
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}

View File

@@ -0,0 +1,247 @@
import SwiftUI
struct CardView: View {
let card: Card
let isRevealing: Bool
@State private var isFlipped = false
init(card: Card, isRevealing: Bool = false) {
self.card = card
self.isRevealing = isRevealing
}
var body: some View {
ZStack {
// Card background
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(
gradient: Gradient(colors: gradientColors),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 200, height: 280)
// Card content
VStack(spacing: 16) {
// Header
HStack {
Text("#\(card.id)")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
Spacer()
Text("CP: \(card.cp)")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
}
Spacer()
// Card name and icon
VStack(spacing: 12) {
// Card icon (could be an image)
Circle()
.fill(Color(hex: cardInfo.color))
.frame(width: 60, height: 60)
.overlay(
Text(cardInfo.name.prefix(1))
.font(.title)
.fontWeight(.bold)
.foregroundColor(.white)
)
Text(cardInfo.name)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
.multilineTextAlignment(.center)
if card.isUnique {
UniqueBadge()
}
}
Spacer()
// Skill
if let skill = card.skill, !skill.isEmpty {
Text(skill)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.3))
.cornerRadius(8)
}
// Rarity
Text(card.status.displayName)
.font(.caption)
.foregroundColor(.white.opacity(0.7))
.textCase(.uppercase)
.tracking(1)
}
.padding(20)
// Special effects
if card.status == .kira {
KiraEffect()
} else if card.status == .unique {
UniqueEffect()
}
}
.rotation3DEffect(
.degrees(isRevealing && !isFlipped ? 180 : 0),
axis: (x: 0, y: 1, z: 0)
)
.onAppear {
if isRevealing {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut(duration: 0.8)) {
isFlipped = true
}
}
}
}
.scaleEffect(isRevealing ? 1.1 : 1.0)
.shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5)
}
private var cardInfo: CardInfo {
CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "")
}
private var gradientColors: [Color] {
card.status.gradientColors.map { Color(hex: $0) }
}
}
struct UniqueBadge: View {
@State private var phase: CGFloat = 0
var body: some View {
Text("UNIQUE")
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff"),
Color(hex: "00ffff")
]),
startPoint: .leading,
endPoint: .trailing
)
.hueRotation(.degrees(phase))
)
.cornerRadius(12)
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
phase = 360
}
}
}
}
struct KiraEffect: View {
@State private var sparkles: [SparkleData] = []
var body: some View {
ZStack {
ForEach(sparkles, id: \.id) { sparkle in
Image(systemName: "sparkle")
.foregroundColor(.yellow)
.font(.system(size: sparkle.size))
.position(x: sparkle.x, y: sparkle.y)
.opacity(sparkle.opacity)
}
}
.onAppear {
generateSparkles()
}
}
private func generateSparkles() {
for i in 0..<10 {
let sparkle = SparkleData(
id: i,
x: CGFloat.random(in: 20...180),
y: CGFloat.random(in: 20...260),
size: CGFloat.random(in: 8...16),
opacity: Double.random(in: 0.3...0.8)
)
sparkles.append(sparkle)
DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0...2)) {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
if let index = sparkles.firstIndex(where: { $0.id == sparkle.id }) {
sparkles[index].opacity = sparkles[index].opacity > 0.5 ? 0.2 : 0.8
}
}
}
}
}
}
struct UniqueEffect: View {
@State private var pulseScale: CGFloat = 1.0
var body: some View {
RoundedRectangle(cornerRadius: 16)
.stroke(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff"),
Color(hex: "00ffff"),
Color(hex: "ff00ff")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
.scaleEffect(pulseScale)
.opacity(0.8)
.onAppear {
withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) {
pulseScale = 1.05
}
}
}
}
struct SparkleData {
let id: Int
let x: CGFloat
let y: CGFloat
let size: CGFloat
var opacity: Double
}
struct CardView_Previews: PreviewProvider {
static var previews: some View {
let sampleCard = Card(
id: 0,
cp: 100,
status: .unique,
skill: "サンプルスキル",
ownerDid: "did:plc:example",
obtainedAt: Date(),
isUnique: true,
uniqueId: "unique-123"
)
VStack {
CardView(card: sampleCard)
CardView(card: sampleCard, isRevealing: true)
}
.padding()
.background(Color.black)
}
}

View File

@@ -0,0 +1,341 @@
import SwiftUI
struct CollectionView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var selectedCard: Card?
@State private var searchText = ""
@State private var selectedRarity: CardRarity?
@State private var showingFilters = false
var filteredCards: [Card] {
var cards = cardManager.userCards
// Search filter
if !searchText.isEmpty {
cards = cards.filter { card in
let cardInfo = CardInfo.all[card.id]
return cardInfo?.name.localizedCaseInsensitiveContains(searchText) ?? false
}
}
// Rarity filter
if let selectedRarity = selectedRarity {
cards = cards.filter { $0.status == selectedRarity }
}
return cards.sorted { $0.obtainedAt > $1.obtainedAt }
}
var body: some View {
NavigationView {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 0) {
// Search and filter bar
VStack(spacing: 12) {
HStack {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("カードを検索...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
if !searchText.isEmpty {
Button(action: {
searchText = ""
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(12)
.background(Color.white.opacity(0.1))
.cornerRadius(12)
// Filter button
Button(action: {
showingFilters.toggle()
}) {
Image(systemName: "line.3.horizontal.decrease.circle")
.font(.title2)
.foregroundColor(Color(hex: "fff700"))
}
}
// Filter chips
if showingFilters {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "すべて",
isSelected: selectedRarity == nil
) {
selectedRarity = nil
}
ForEach(CardRarity.allCases, id: \.self) { rarity in
FilterChip(
title: rarity.displayName,
isSelected: selectedRarity == rarity
) {
selectedRarity = selectedRarity == rarity ? nil : rarity
}
}
}
.padding(.horizontal)
}
}
}
.padding(.horizontal)
.padding(.top)
// Collection stats
CollectionStatsView(cards: cardManager.userCards)
.padding(.horizontal)
.padding(.vertical, 8)
// Card grid
if cardManager.isLoading {
Spacer()
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color(hex: "fff700")))
Spacer()
} else if filteredCards.isEmpty {
Spacer()
EmptyCollectionView(hasCards: !cardManager.userCards.isEmpty)
Spacer()
} else {
ScrollView {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 16),
GridItem(.flexible(), spacing: 16)
],
spacing: 20
) {
ForEach(filteredCards) { card in
CardView(card: card)
.scaleEffect(0.8)
.onTapGesture {
selectedCard = card
}
}
}
.padding(.horizontal)
.padding(.bottom, 100)
}
}
}
}
.navigationTitle("コレクション")
.navigationBarTitleDisplayMode(.large)
.onAppear {
if let userDid = authManager.currentUser?.did {
cardManager.loadUserCards(userDid: userDid)
}
}
.sheet(item: $selectedCard) { card in
CardDetailView(card: card)
}
}
}
}
struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.caption)
.fontWeight(isSelected ? .bold : .medium)
.foregroundColor(isSelected ? .black : .white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
isSelected
? Color(hex: "fff700")
: Color.white.opacity(0.1)
)
.cornerRadius(16)
}
}
}
struct CollectionStatsView: View {
let cards: [Card]
private var stats: (total: Int, unique: Int, completion: Double) {
let total = cards.count
let uniqueCards = Set(cards.map { $0.id }).count
let completion = Double(uniqueCards) / 16.0 * 100
return (total, uniqueCards, completion)
}
var body: some View {
HStack(spacing: 20) {
StatItem(title: "総枚数", value: "\(stats.total)")
StatItem(title: "種類", value: "\(stats.unique)/16")
StatItem(title: "完成度", value: String(format: "%.1f%%", stats.completion))
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
}
struct StatItem: View {
let title: String
let value: String
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.headline)
.fontWeight(.bold)
.foregroundColor(Color(hex: "fff700"))
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
struct EmptyCollectionView: View {
let hasCards: Bool
var body: some View {
VStack(spacing: 16) {
Image(systemName: hasCards ? "magnifyingglass" : "square.stack.3d.up")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(hasCards ? "検索結果がありません" : "カードがありません")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
Text(hasCards ? "検索条件を変更してください" : "ガチャでカードを引いてみましょう")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
struct CardDetailView: View {
let card: Card
@Environment(\.presentationMode) var presentationMode
private var cardInfo: CardInfo {
CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "")
}
var body: some View {
NavigationView {
ZStack {
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Card display
CardView(card: card)
.scaleEffect(1.2)
.padding(.top, 20)
// Card details
VStack(alignment: .leading, spacing: 16) {
DetailRow(title: "ID", value: "#\(card.id)")
DetailRow(title: "名前", value: cardInfo.name)
DetailRow(title: "CP", value: "\(card.cp)")
DetailRow(title: "レアリティ", value: card.status.displayName)
if let skill = card.skill, !skill.isEmpty {
DetailRow(title: "スキル", value: skill)
}
if card.isUnique, let uniqueId = card.uniqueId {
DetailRow(title: "ユニークID", value: uniqueId)
}
DetailRow(
title: "取得日時",
value: DateFormatter.localizedString(
from: card.obtainedAt,
dateStyle: .medium,
timeStyle: .short
)
)
}
.padding(.horizontal)
}
.padding(.bottom, 100)
}
}
.navigationTitle(cardInfo.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(
trailing: Button("閉じる") {
presentationMode.wrappedValue.dismiss()
}
)
}
}
}
struct DetailRow: View {
let title: String
let value: String
var body: some View {
HStack {
Text(title)
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.white)
}
.padding(.vertical, 4)
}
}
struct CollectionView_Previews: PreviewProvider {
static var previews: some View {
CollectionView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,310 @@
import SwiftUI
struct GachaAnimationView: View {
let drawResult: CardDrawResult
let onComplete: () -> Void
@State private var phase: AnimationPhase = .opening
@State private var packScale: CGFloat = 0
@State private var packOpacity: Double = 0
@State private var cardScale: CGFloat = 0
@State private var cardOpacity: Double = 0
@State private var showCard = false
@State private var effectOpacity: Double = 0
enum AnimationPhase {
case opening
case revealing
case complete
}
var body: some View {
ZStack {
// Dark overlay
Rectangle()
.fill(Color.black.opacity(0.9))
.ignoresSafeArea()
.onTapGesture {
if phase == .complete {
onComplete()
}
}
// Background effects based on card rarity
if phase != .opening {
backgroundEffect
.opacity(effectOpacity)
}
// Pack animation
if phase == .opening {
GachaPackView()
.scaleEffect(packScale)
.opacity(packOpacity)
}
// Card reveal
if showCard {
CardView(card: drawResult.card, isRevealing: true)
.scaleEffect(cardScale)
.opacity(cardOpacity)
}
// Complete state overlay
if phase == .complete {
VStack {
Spacer()
VStack(spacing: 16) {
if drawResult.isNew {
Text("新しいカードを獲得!")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(Color(hex: "fff700"))
}
Text("タップして続ける")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
}
.padding(.bottom, 50)
}
}
}
.onAppear {
startAnimation()
}
}
private var backgroundEffect: some View {
Group {
switch drawResult.animationType {
case "unique":
UniqueBackgroundEffect()
case "kira":
KiraBackgroundEffect()
case "rare":
RareBackgroundEffect()
default:
EmptyView()
}
}
}
private func startAnimation() {
// Phase 1: Pack appears
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
packScale = 1.0
packOpacity = 1.0
}
// Phase 2: Pack disappears, card appears
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeOut(duration: 0.3)) {
packOpacity = 0
}
phase = .revealing
showCard = true
withAnimation(.spring(response: 0.8, dampingFraction: 0.6)) {
cardScale = 1.0
cardOpacity = 1.0
effectOpacity = 1.0
}
}
// Phase 3: Animation complete
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
phase = .complete
}
}
}
struct GachaPackView: View {
@State private var glowIntensity: Double = 0.5
var body: some View {
ZStack {
// Pack background
RoundedRectangle(cornerRadius: 20)
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "667eea"),
Color(hex: "764ba2")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 150, height: 200)
// Pack glow
RoundedRectangle(cornerRadius: 20)
.stroke(Color.white, lineWidth: 2)
.frame(width: 150, height: 200)
.blur(radius: 10)
.opacity(glowIntensity)
// Pack label
VStack {
Image(systemName: "sparkles")
.font(.title)
.foregroundColor(.white)
Text("ai.card")
.font(.headline)
.fontWeight(.bold)
.foregroundColor(.white)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
glowIntensity = 1.0
}
}
}
}
struct UniqueBackgroundEffect: View {
@State private var particles: [ParticleData] = []
@State private var burstScale: CGFloat = 0
var body: some View {
ZStack {
// Radial burst
Circle()
.fill(
RadialGradient(
gradient: Gradient(colors: [
Color(hex: "ff00ff").opacity(0.8),
Color.clear
]),
center: .center,
startRadius: 0,
endRadius: 200
)
)
.scaleEffect(burstScale)
.onAppear {
withAnimation(.easeOut(duration: 1)) {
burstScale = 3
}
}
// Floating particles
ForEach(particles, id: \.id) { particle in
Circle()
.fill(Color(hex: particle.color))
.frame(width: particle.size, height: particle.size)
.position(x: particle.x, y: particle.y)
.opacity(particle.opacity)
}
}
.onAppear {
generateParticles()
}
}
private func generateParticles() {
for i in 0..<20 {
let particle = ParticleData(
id: i,
x: CGFloat.random(in: 0...UIScreen.main.bounds.width),
y: CGFloat.random(in: 0...UIScreen.main.bounds.height),
size: CGFloat.random(in: 4...12),
color: ["ff00ff", "00ffff", "ffffff"].randomElement() ?? "ffffff",
opacity: Double.random(in: 0.3...0.8)
)
particles.append(particle)
}
}
}
struct KiraBackgroundEffect: View {
@State private var sparkleOffset: CGFloat = -100
var body: some View {
ZStack {
ForEach(0..<5, id: \.self) { i in
Rectangle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color.clear,
Color.yellow.opacity(0.3),
Color.clear
]),
startPoint: .leading,
endPoint: .trailing
)
)
.frame(width: 2, height: UIScreen.main.bounds.height)
.rotationEffect(.degrees(45))
.offset(x: sparkleOffset + CGFloat(i * 50))
}
}
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
sparkleOffset = UIScreen.main.bounds.width + 100
}
}
}
}
struct RareBackgroundEffect: View {
@State private var rippleScale: CGFloat = 0
var body: some View {
ZStack {
ForEach(0..<3, id: \.self) { i in
Circle()
.stroke(Color.blue.opacity(0.3), lineWidth: 2)
.scaleEffect(rippleScale)
.opacity(1 - rippleScale)
.animation(
.easeOut(duration: 2)
.delay(Double(i) * 0.3)
.repeatForever(autoreverses: false),
value: rippleScale
)
}
}
.onAppear {
rippleScale = 3
}
}
}
struct ParticleData {
let id: Int
let x: CGFloat
let y: CGFloat
let size: CGFloat
let color: String
let opacity: Double
}
struct GachaAnimationView_Previews: PreviewProvider {
static var previews: some View {
let sampleResult = CardDrawResult(
card: Card(
id: 0,
cp: 500,
status: .unique,
skill: "サンプルスキル",
ownerDid: "did:plc:example",
obtainedAt: Date(),
isUnique: true,
uniqueId: "unique-123"
),
isNew: true,
animationType: "unique"
)
GachaAnimationView(drawResult: sampleResult) {
print("Animation complete")
}
}
}

View File

@@ -0,0 +1,190 @@
import SwiftUI
struct GachaView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var showingAnimation = false
var body: some View {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
// Title
VStack(spacing: 16) {
Text("カードを引く")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.white)
if let user = authManager.currentUser {
Text("@\(user.handle)")
.font(.subheadline)
.foregroundColor(Color(hex: "fff700"))
}
}
Spacer()
// Gacha buttons
VStack(spacing: 20) {
GachaButton(
title: "通常ガチャ",
subtitle: "無料でカードを1枚引く",
colors: [Color(hex: "667eea"), Color(hex: "764ba2")],
action: {
drawCard(isPaid: false)
},
isLoading: cardManager.isDrawing
)
GachaButton(
title: "プレミアムガチャ",
subtitle: "レア確率アップ!",
colors: [Color(hex: "f093fb"), Color(hex: "f5576c")],
action: {
drawCard(isPaid: true)
},
isLoading: cardManager.isDrawing,
isPremium: true
)
}
.padding(.horizontal, 32)
if let errorMessage = cardManager.errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
.padding()
}
Spacer()
}
.padding()
// Gacha animation overlay
if let currentDraw = cardManager.currentDraw {
GachaAnimationView(
drawResult: currentDraw,
onComplete: {
cardManager.completeCardDraw()
}
)
.transition(.opacity)
.zIndex(1000)
}
}
.onAppear {
if let userDid = authManager.currentUser?.did {
cardManager.loadUserCards(userDid: userDid)
}
}
}
private func drawCard(isPaid: Bool) {
guard let userDid = authManager.currentUser?.did else { return }
cardManager.drawCard(userDid: userDid, isPaid: isPaid)
}
}
struct GachaButton: View {
let title: String
let subtitle: String
let colors: [Color]
let action: () -> Void
let isLoading: Bool
let isPremium: Bool
init(title: String, subtitle: String, colors: [Color], action: @escaping () -> Void, isLoading: Bool, isPremium: Bool = false) {
self.title = title
self.subtitle = subtitle
self.colors = colors
self.action = action
self.isLoading = isLoading
self.isPremium = isPremium
}
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Text(title)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Text(subtitle)
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
.frame(maxWidth: .infinity)
.frame(height: 80)
.background(
ZStack {
LinearGradient(
gradient: Gradient(colors: colors),
startPoint: .leading,
endPoint: .trailing
)
if isPremium {
// Shimmer effect for premium
ShimmerView()
}
}
)
.cornerRadius(16)
.shadow(color: colors.first?.opacity(0.3) ?? .clear, radius: 10, x: 0, y: 5)
.overlay(
Group {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
}
)
}
.disabled(isLoading)
.scaleEffect(isLoading ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: isLoading)
}
}
struct ShimmerView: View {
@State private var phase: CGFloat = 0
var body: some View {
LinearGradient(
gradient: Gradient(colors: [
.clear,
.white.opacity(0.2),
.clear
]),
startPoint: .leading,
endPoint: .trailing
)
.rotationEffect(.degrees(45))
.offset(x: phase)
.onAppear {
withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) {
phase = 300
}
}
}
}
struct GachaView_Previews: PreviewProvider {
static var previews: some View {
GachaView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

View File

@@ -0,0 +1,157 @@
import SwiftUI
struct LoginView: View {
@EnvironmentObject var authManager: AuthManager
@State private var identifier = ""
@State private var password = ""
@State private var showingPassword = false
var body: some View {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 40) {
// Logo and title
VStack(spacing: 20) {
Text("ai.card")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ff00ff")
]),
startPoint: .leading,
endPoint: .trailing
)
)
Text("atprotoベースカードゲーム")
.font(.title3)
.foregroundColor(.secondary)
}
// Login form
VStack(spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text("ハンドル または DID")
.font(.caption)
.foregroundColor(.secondary)
TextField("your.bsky.social", text: $identifier)
.textFieldStyle(CustomTextFieldStyle())
.autocapitalization(.none)
.disableAutocorrection(true)
}
VStack(alignment: .leading, spacing: 8) {
Text("アプリパスワード")
.font(.caption)
.foregroundColor(.secondary)
HStack {
if showingPassword {
TextField("アプリパスワード", text: $password)
} else {
SecureField("アプリパスワード", text: $password)
}
Button(action: {
showingPassword.toggle()
}) {
Image(systemName: showingPassword ? "eye.slash" : "eye")
.foregroundColor(.secondary)
}
}
.textFieldStyle(CustomTextFieldStyle())
Text("メインパスワードではなく、アプリパスワードを使用してください")
.font(.caption2)
.foregroundColor(.secondary)
}
if let errorMessage = authManager.errorMessage {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
.padding(.horizontal)
}
Button(action: {
authManager.login(identifier: identifier, password: password)
}) {
HStack {
if authManager.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .black))
.scaleEffect(0.8)
}
Text(authManager.isLoading ? "ログイン中..." : "ログイン")
.font(.headline)
.foregroundColor(.black)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ffd700")
]),
startPoint: .leading,
endPoint: .trailing
)
)
.cornerRadius(12)
}
.disabled(authManager.isLoading || identifier.isEmpty || password.isEmpty)
.opacity(authManager.isLoading || identifier.isEmpty || password.isEmpty ? 0.6 : 1.0)
}
.padding(.horizontal, 32)
Spacer()
VStack(spacing: 12) {
Text("ai.cardはatprotoアカウントを使用します")
.font(.caption)
.foregroundColor(.secondary)
Text("データはあなたのPDSに保存されます")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
}
}
}
struct CustomTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration
.padding()
.background(Color.white.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.2), lineWidth: 1)
)
}
}
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView()
.environmentObject(AuthManager())
}
}

View File

@@ -0,0 +1,265 @@
import SwiftUI
struct ProfileView: View {
@EnvironmentObject var authManager: AuthManager
@EnvironmentObject var cardManager: CardManager
@State private var showingLogoutAlert = false
var body: some View {
NavigationView {
ZStack {
// Background
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "0a0a0a"),
Color(hex: "1a1a1a")
]),
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 24) {
// Profile header
if let user = authManager.currentUser {
ProfileHeaderView(user: user)
}
// Collection summary
CollectionSummaryView(cards: cardManager.userCards)
// Menu items
VStack(spacing: 1) {
MenuRow(
icon: "arrow.triangle.2.circlepath",
title: "データ同期",
subtitle: "atproto PDSと同期"
) {
// TODO: Implement sync
}
MenuRow(
icon: "crown",
title: "ユニークカード",
subtitle: "所有しているユニークカード"
) {
// TODO: Show unique cards
}
MenuRow(
icon: "info.circle",
title: "アプリについて",
subtitle: "バージョン情報"
) {
// TODO: Show about
}
}
.background(Color.white.opacity(0.05))
.cornerRadius(12)
Spacer(minLength: 40)
// Logout button
Button(action: {
showingLogoutAlert = true
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("ログアウト")
}
.font(.headline)
.foregroundColor(.red)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(Color.red.opacity(0.1))
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.red.opacity(0.3), lineWidth: 1)
)
}
Spacer(minLength: 100)
}
.padding(.horizontal)
}
}
.navigationTitle("プロフィール")
.navigationBarTitleDisplayMode(.large)
.alert("ログアウト", isPresented: $showingLogoutAlert) {
Button("キャンセル", role: .cancel) { }
Button("ログアウト", role: .destructive) {
authManager.logout()
}
} message: {
Text("ログアウトしますか?")
}
}
}
}
struct ProfileHeaderView: View {
let user: User
var body: some View {
VStack(spacing: 16) {
// Avatar
Circle()
.fill(
LinearGradient(
gradient: Gradient(colors: [
Color(hex: "fff700"),
Color(hex: "ff00ff")
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
.overlay(
Text(user.handle.prefix(1).uppercased())
.font(.title)
.fontWeight(.bold)
.foregroundColor(.black)
)
// User info
VStack(spacing: 4) {
Text("@\(user.handle)")
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.white)
Text(user.did)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
.padding(.vertical, 20)
}
}
struct CollectionSummaryView: View {
let cards: [Card]
private var summary: (total: Int, unique: Int, rarest: CardRarity?) {
let total = cards.count
let uniqueCount = cards.filter { $0.isUnique }.count
let rarest = cards.map { $0.status }.max { lhs, rhs in
rarityOrder(lhs) < rarityOrder(rhs)
}
return (total, uniqueCount, rarest)
}
private func rarityOrder(_ rarity: CardRarity) -> Int {
switch rarity {
case .normal: return 0
case .rare: return 1
case .superRare: return 2
case .kira: return 3
case .unique: return 4
}
}
var body: some View {
VStack(spacing: 16) {
Text("コレクション統計")
.font(.headline)
.foregroundColor(.white)
HStack(spacing: 20) {
SummaryItem(
title: "総カード数",
value: "\(summary.total)",
color: Color(hex: "fff700")
)
SummaryItem(
title: "ユニーク",
value: "\(summary.unique)",
color: Color(hex: "ff00ff")
)
if let rarest = summary.rarest {
SummaryItem(
title: "最高レア",
value: rarest.displayName,
color: Color(hex: rarest.gradientColors.first ?? "ffffff")
)
}
}
}
.padding()
.background(Color.white.opacity(0.05))
.cornerRadius(12)
}
}
struct SummaryItem: View {
let title: String
let value: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Text(value)
.font(.title2)
.fontWeight(.bold)
.foregroundColor(color)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
}
struct MenuRow: View {
let icon: String
let title: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(Color(hex: "fff700"))
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline)
.foregroundColor(.white)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
}
.buttonStyle(PlainButtonStyle())
}
}
struct ProfileView_Previews: PreviewProvider {
static var previews: some View {
ProfileView()
.environmentObject(AuthManager())
.environmentObject(CardManager())
}
}

28
ios/Package.swift Normal file
View File

@@ -0,0 +1,28 @@
// swift-tools-version: 5.7
import PackageDescription
let package = Package(
name: "AiCard",
platforms: [
.iOS(.v16)
],
products: [
.library(
name: "AiCard",
targets: ["AiCard"]
),
],
dependencies: [
// SwiftUI is included by default
],
targets: [
.target(
name: "AiCard",
dependencies: []
),
.testTarget(
name: "AiCardTests",
dependencies: ["AiCard"]
),
]
)

121
ios/README.md Normal file
View File

@@ -0,0 +1,121 @@
# ai.card iOS App
atprotoベースのカードゲーム「ai.card」のiOSアプリです。
## 特徴
- **atproto統合**: 分散型認証とデータ主権
- **リッチなアニメーション**: ガチャの迫力ある演出
- **カードコレクション**: 美しいカード表示とフィルタリング
- **ユニークカードシステム**: 世界で一人だけが所有できるカード
## アーキテクチャ
### MVVM + Combine
```
Views/
├── LoginView # atprotoログイン
├── GachaView # ガチャ画面
├── CollectionView # コレクション画面
├── ProfileView # プロフィール画面
├── CardView # カード表示コンポーネント
└── GachaAnimationView # ガチャアニメーション
Services/
├── APIClient # REST API通信
├── AuthManager # 認証管理
└── CardManager # カード管理
Models/
├── Card # カードデータモデル
└── User # ユーザーデータモデル
```
### 技術スタック
- **UI**: SwiftUI
- **データフロー**: Combine
- **ネットワーク**: URLSession
- **認証**: atproto (JWT)
- **最小対応OS**: iOS 16.0
## セットアップ
### 1. Xcodeプロジェクトを開く
```bash
cd ios/AiCard
open AiCard.xcodeproj
```
### 2. API設定
開発環境では自動的に `localhost:8000` に接続します。
本番環境では `api.card.syui.ai` に接続します。
### 3. ビルド & 実行
- シミュレーターまたは実機でビルド
- atprotoアカウントでログイン
- ガチャを引いてカードを集める
## 主要機能
### 認証
- atproto DIDベース認証
- アプリパスワード使用
- 自動セッション管理
### ガチャシステム
- 通常ガチャ(無料)
- プレミアムガチャ(確率アップ)
- レアリティ別アニメーション
- ユニークカード対応
### カードコレクション
- グリッド表示
- 検索機能
- レアリティフィルタ
- 詳細表示
### プロフィール
- ユーザー情報表示
- コレクション統計
- データ同期機能
## カードシステム
### レアリティ
- **ノーマル**: 基本カード
- **レア**: 少し珍しいカード
- **スーパーレア**: とても珍しいカード
- **キラ**: 光る演出付きカード0.1%
- **ユニーク**: 世界で一人だけ0.0001%
### 視覚効果
- レアリティ別グラデーション
- キラカードのスパークル効果
- ユニークカードのオーラ効果
- 3Dフリップアニメーション
## 今後の実装予定
- [ ] Push通知
- [ ] カード交換機能
- [ ] AI.verse連携
- [ ] ダークモード対応
- [ ] iPad最適化
- [ ] ウィジェット対応
## 注意事項
- iOS 16.0以上が必要
- atprotoアカウントが必要
- インターネット接続が必要