From c1f2ef3bc527f38eff57b72db9b2dc5b380dfa73 Mon Sep 17 00:00:00 2001 From: danilpapa Date: Thu, 28 May 2026 20:47:18 +0300 Subject: [PATCH 1/2] integrate firebase and error handling --- iosApp/iosApp.xcodeproj/project.pbxproj | 52 ++++++++ .../xcshareddata/swiftpm/Package.resolved | 123 ++++++++++++++++++ .../Application/GoogleService-Info.plist | 30 +++++ iosApp/iosApp/Helper/ToastModifier.swift | 2 +- .../Events/EventDetail/EventDetailView.swift | 79 +---------- .../PendingEvents/PendingEventListView.swift | 4 +- .../FriendProfile/FriendProfileView.swift | 4 +- .../iosApp/Modules/Friends/FriendsView.swift | 6 +- iosApp/iosApp/Modules/Main/MainView.swift | 4 +- .../Modules/Register/RegisterView.swift | 4 +- .../core/domain/model/UserFriendlyError.kt | 16 +-- .../mobile/core/network/SafeApiCall.kt | 23 ++-- .../data/repository/AuthRepositoryImpl.kt | 2 +- 13 files changed, 241 insertions(+), 108 deletions(-) create mode 100644 iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 iosApp/iosApp/Application/GoogleService-Info.plist diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index cd0a9cf..9b4b1f0 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,6 +6,13 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + CFC629B12FC77769005D9A8B /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CFC629B02FC77769005D9A8B /* FirebaseAnalytics */; }; + CFC629B32FC77769005D9A8B /* FirebaseAnalyticsCore in Frameworks */ = {isa = PBXBuildFile; productRef = CFC629B22FC77769005D9A8B /* FirebaseAnalyticsCore */; }; + CFC629B52FC77769005D9A8B /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = CFC629B42FC77769005D9A8B /* FirebaseCore */; }; + CFC629B72FC77769005D9A8B /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = CFC629B62FC77769005D9A8B /* FirebaseCrashlytics */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 860863FDCFFA0938077FC65C /* Mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -46,6 +53,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CFC629B12FC77769005D9A8B /* FirebaseAnalytics in Frameworks */, + CFC629B72FC77769005D9A8B /* FirebaseCrashlytics in Frameworks */, + CFC629B52FC77769005D9A8B /* FirebaseCore in Frameworks */, + CFC629B32FC77769005D9A8B /* FirebaseAnalyticsCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -93,6 +104,10 @@ ); name = iosApp; packageProductDependencies = ( + CFC629B02FC77769005D9A8B /* FirebaseAnalytics */, + CFC629B22FC77769005D9A8B /* FirebaseAnalyticsCore */, + CFC629B42FC77769005D9A8B /* FirebaseCore */, + CFC629B62FC77769005D9A8B /* FirebaseCrashlytics */, ); productName = iosApp; productReference = 860863FDCFFA0938077FC65C /* Mobile.app */; @@ -122,6 +137,9 @@ ); mainGroup = 0DD46B45F0A3957853DDAEE9; minimizedProjectReferenceProxies = 1; + packageReferences = ( + CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 20E61D6599E621B90D885377 /* Products */; projectDirPath = ""; @@ -396,6 +414,40 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.14.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CFC629B02FC77769005D9A8B /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + CFC629B22FC77769005D9A8B /* FirebaseAnalyticsCore */ = { + isa = XCSwiftPackageProductDependency; + package = CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalyticsCore; + }; + CFC629B42FC77769005D9A8B /* FirebaseCore */ = { + isa = XCSwiftPackageProductDependency; + package = CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCore; + }; + CFC629B62FC77769005D9A8B /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = CFC629AF2FC77769005D9A8B /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = F28693CE96F271F88E90B136 /* Project object */; } diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..980b016 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,123 @@ +{ + "originHash" : "a1569f9895aa2be8e24832f98525d5da4eb90b5d158a82691c15b47eb72a13d7", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk.git", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + } + ], + "version" : 3 +} diff --git a/iosApp/iosApp/Application/GoogleService-Info.plist b/iosApp/iosApp/Application/GoogleService-Info.plist new file mode 100644 index 0000000..309806b --- /dev/null +++ b/iosApp/iosApp/Application/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyDokvSEOTrLCyf8CgdkD7dLDQLMw8h6GjM + GCM_SENDER_ID + 220601164196 + PLIST_VERSION + 1 + BUNDLE_ID + friends.mobile.Mobile + PROJECT_ID + friends-meet-plan + STORAGE_BUCKET + friends-meet-plan.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:220601164196:ios:98377d90bf9b4883946131 + + \ No newline at end of file diff --git a/iosApp/iosApp/Helper/ToastModifier.swift b/iosApp/iosApp/Helper/ToastModifier.swift index c1fa1cc..eb22807 100644 --- a/iosApp/iosApp/Helper/ToastModifier.swift +++ b/iosApp/iosApp/Helper/ToastModifier.swift @@ -22,7 +22,7 @@ struct ToastModifier: ViewModifier { .foregroundColor(.white) .padding(.horizontal, 16) .padding(.vertical, 10) - .background(Color.black.opacity(0.8)) + .background(Color.primary.opacity(0.8)) .cornerRadius(8) .padding() diff --git a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift index 68aaea4..a4ad217 100644 --- a/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift +++ b/iosApp/iosApp/Modules/Events/EventDetail/EventDetailView.swift @@ -44,7 +44,7 @@ struct EventDetailView: View { var body: some View { if reducer.isLoading { - LoadingStateView() + LoadingView() } else if let errorMessage = reducer.errorMessage { VStack(spacing: DesignTheme.Spacing.lg) { Image(systemName: "exclamationmark.triangle.fill") @@ -64,7 +64,7 @@ struct EventDetailView: View { VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { Text(reducer.title) .font(DesignTheme.Typography.heading) - .foregroundColor(.black) + .foregroundColor(.primary) .lineLimit(3) } @@ -74,7 +74,6 @@ struct EventDetailView: View { } } - // Details Section with Icons VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { DetailRowWithIcon(icon: "calendar", label: "Date", value: reducer.date) @@ -87,12 +86,11 @@ struct EventDetailView: View { } } - // Description Section if let description = reducer.description, !description.isEmpty { VStack(alignment: .leading, spacing: DesignTheme.Spacing.sm) { Text("Description") .font(DesignTheme.Typography.button) - .foregroundColor(.black) + .foregroundColor(.primary) Text(description) .font(DesignTheme.Typography.body) @@ -107,7 +105,7 @@ struct EventDetailView: View { VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { Text("Participants") .font(DesignTheme.Typography.button) - .foregroundColor(.black) + .foregroundColor(.primary) if reducer.participants.isEmpty { Text("No participants yet") @@ -147,63 +145,6 @@ struct EventDetailView: View { } } -// MARK: - Loading State - -private struct LoadingStateView: View { - var body: some View { - VStack(spacing: DesignTheme.Spacing.xl) { - VStack(spacing: DesignTheme.Spacing.lg) { - // Skeleton event image placeholder - RoundedRectangle(cornerRadius: DesignTheme.CornerRadius.medium) - .fill(Color(.systemGray6)) - .frame(height: 200) - .shimmer() - - VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { - // Skeleton title - RoundedRectangle(cornerRadius: DesignTheme.CornerRadius.small) - .fill(Color(.systemGray6)) - .frame(height: 24) - .frame(maxWidth: .infinity, alignment: .leading) - .shimmer() - - // Skeleton subtitle - RoundedRectangle(cornerRadius: DesignTheme.CornerRadius.small) - .fill(Color(.systemGray6)) - .frame(height: 16) - .frame(maxWidth: 200, alignment: .leading) - .shimmer() - } - - VStack(alignment: .leading, spacing: DesignTheme.Spacing.md) { - ForEach(0..<3, id: \.self) { _ in - RoundedRectangle(cornerRadius: DesignTheme.CornerRadius.small) - .fill(Color(.systemGray6)) - .frame(height: 16) - .shimmer() - } - } - - Spacer() - } - .padding(DesignTheme.Spacing.lg) - - VStack(spacing: DesignTheme.Spacing.sm) { - ProgressView() - .tint(DesignTheme.accentColor) - - Text("Loading event details...") - .font(DesignTheme.Typography.bodySmall) - .foregroundColor(.secondary) - } - } - .frame(maxHeight: .infinity) - .padding(DesignTheme.Spacing.lg) - } -} - -// MARK: - Event Image View - private struct EventImageView: View { var body: some View { VStack { @@ -217,8 +158,6 @@ private struct EventImageView: View { } } -// MARK: - Detail Row with Icon - private struct DetailRowWithIcon: View { let icon: String let label: String @@ -238,7 +177,7 @@ private struct DetailRowWithIcon: View { Text(value) .font(DesignTheme.Typography.body) - .foregroundColor(.black) + .foregroundColor(.primary) .lineLimit(nil) } @@ -250,8 +189,6 @@ private struct DetailRowWithIcon: View { } } -// MARK: - Participant Avatar Row - private struct ParticipantAvatarRow: View { let participant: EventParticipant let statusColor: (String) -> Color @@ -259,14 +196,11 @@ private struct ParticipantAvatarRow: View { var body: some View { HStack(spacing: DesignTheme.Spacing.md) { - // Avatar with initials and status badge ZStack(alignment: .bottomTrailing) { - // Avatar circle Circle() .fill(AvatarColorPalette.color(for: colorIndex)) .frame(width: 48, height: 48) - // Initials Text(participant.username.prefix(1).uppercased()) .font(DesignTheme.Typography.button) .foregroundColor(.white) @@ -277,11 +211,10 @@ private struct ParticipantAvatarRow: View { .offset(x: 2, y: 2) } - // User info VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { Text(participant.username) .font(DesignTheme.Typography.body) - .foregroundColor(.black) + .foregroundColor(.primary) HStack(spacing: DesignTheme.Spacing.sm) { Text(participant.role.capitalized) diff --git a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift index ab60a60..536e1f3 100644 --- a/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift +++ b/iosApp/iosApp/Modules/Events/PendingEvents/PendingEventListView.swift @@ -99,7 +99,7 @@ struct PendingEventListView: View { VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { Text(event.title) .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) .lineLimit(1) Text(event.date) @@ -162,7 +162,7 @@ struct PendingEventListView: View { VStack(spacing: DesignTheme.Spacing.xs) { Text("No Pending Invitations") .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) Text("You're all caught up!") .font(DesignTheme.Typography.bodySmall) diff --git a/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift b/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift index 07f6370..1ea945c 100644 --- a/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift +++ b/iosApp/iosApp/Modules/Friends/FriendProfile/FriendProfileView.swift @@ -54,7 +54,7 @@ struct FriendProfileView: View { .foregroundColor(DesignTheme.error) Text("Failed to load profile") .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) Text("Please try again") .font(DesignTheme.Typography.bodySmall) .foregroundColor(.gray) @@ -97,7 +97,7 @@ struct FriendProfileView: View { VStack(spacing: DesignTheme.Spacing.xs) { Text(user.username) .font(DesignTheme.Typography.heading) - .foregroundColor(.black) + .foregroundColor(.primary) if let bio = user.bio, !bio.isEmpty { Text(bio) diff --git a/iosApp/iosApp/Modules/Friends/FriendsView.swift b/iosApp/iosApp/Modules/Friends/FriendsView.swift index 4a30881..5ae584f 100644 --- a/iosApp/iosApp/Modules/Friends/FriendsView.swift +++ b/iosApp/iosApp/Modules/Friends/FriendsView.swift @@ -137,7 +137,7 @@ struct FriendsView: View { VStack(spacing: DesignTheme.Spacing.sm) { Text("No users found") .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) Text("Try searching with a different name") .font(DesignTheme.Typography.bodySmall) .foregroundColor(.secondary) @@ -147,7 +147,7 @@ struct FriendsView: View { VStack(spacing: DesignTheme.Spacing.sm) { Text(emptyStateTitle()) .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) Text(emptyStateSubtitle()) .font(DesignTheme.Typography.bodySmall) .foregroundColor(.secondary) @@ -223,7 +223,7 @@ private struct UserRowView: View { VStack(alignment: .leading, spacing: DesignTheme.Spacing.xs) { Text(user.username) .font(DesignTheme.Typography.captionSemibold) - .foregroundColor(.black) + .foregroundColor(.primary) if let bio = user.bio, !bio.isEmpty { Text(bio) diff --git a/iosApp/iosApp/Modules/Main/MainView.swift b/iosApp/iosApp/Modules/Main/MainView.swift index faa80b2..d4c3ae6 100644 --- a/iosApp/iosApp/Modules/Main/MainView.swift +++ b/iosApp/iosApp/Modules/Main/MainView.swift @@ -204,7 +204,7 @@ private extension MainView { Circle() .fill(DesignTheme.accentColor) .shadow( - color: .black.opacity(0.15), + color: .primary.opacity(0.15), radius: 10, x: 0, y: 6 @@ -391,7 +391,7 @@ struct EventRowView: View { RoundedRectangle(cornerRadius: 18) .fill(Color(.systemBackground)) .shadow( - color: .black.opacity(0.06), + color: .primary.opacity(0.06), radius: 8, x: 0, y: 4 diff --git a/iosApp/iosApp/Modules/Register/RegisterView.swift b/iosApp/iosApp/Modules/Register/RegisterView.swift index 6a904df..403c9b3 100644 --- a/iosApp/iosApp/Modules/Register/RegisterView.swift +++ b/iosApp/iosApp/Modules/Register/RegisterView.swift @@ -20,7 +20,7 @@ struct RegisterView: View { VStack(alignment: .leading, spacing: 6) { Text("Create Account") .font(DesignTheme.Typography.heading) - .foregroundColor(.black) + .foregroundColor(.primary) } Spacer() Button(action: { dismiss() }) { @@ -45,8 +45,6 @@ struct RegisterView: View { )) .formSecureField() - FormInfoMessage(message: "At least 8 characters") - if let error = reducer.errorMessage { FormErrorMessage(message: error) } diff --git a/shared/src/commonMain/kotlin/friends/mobile/core/domain/model/UserFriendlyError.kt b/shared/src/commonMain/kotlin/friends/mobile/core/domain/model/UserFriendlyError.kt index da20099..70a948f 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/core/domain/model/UserFriendlyError.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/core/domain/model/UserFriendlyError.kt @@ -1,14 +1,14 @@ package friends.mobile.core.domain.model object ErrorMessages { - const val NETWORK = "Ошибка сети. Проверьте соединение" - const val UNAUTHORIZED = "Необходимо войти в аккаунт" - const val FORBIDDEN = "Доступ к данным запрещён" - const val NOT_FOUND = "Данные не найдены" - const val CONFLICT = "Конфликт данных: действие невозможно в текущем состоянии" - const val CLIENT_ERROR = "Ошибка запроса. Попробуйте снова" - const val SERVER_ERROR = "Сервер временно недоступен" - const val UNKNOWN = "Произошла неизвестная ошибка" + const val NETWORK = "No internet connection. Please check your connection." + const val UNAUTHORIZED = "Your session has expired. Please log in again." + const val FORBIDDEN = "You don't have permission to perform this action." + const val NOT_FOUND = "The requested resource was not found." + const val CONFLICT = "This action cannot be completed. Try again." + const val CLIENT_ERROR = "Please check your input and try again." + const val SERVER_ERROR = "Server error. Please try again later." + const val UNKNOWN = "Something went wrong. Please try again." } sealed class UserFriendlyError(open val message: String) { diff --git a/shared/src/commonMain/kotlin/friends/mobile/core/network/SafeApiCall.kt b/shared/src/commonMain/kotlin/friends/mobile/core/network/SafeApiCall.kt index 02e8ce7..fd54aea 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/core/network/SafeApiCall.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/core/network/SafeApiCall.kt @@ -26,17 +26,14 @@ suspend fun safeApiCall(apiCall: suspend () -> T): ResultWrapper { e.response.body() } catch (_: Exception) { when (code) { - in CLIENT_ERROR_RANGE -> { - "Ошибка запроса" - } - - in SERVER_ERROR_RANGE -> { - "Ошибка сервера" - } - - else -> { - "HTTP error" - } + 400 -> "Please check your input and try again." + 401 -> "Your session has expired. Please log in again." + 403 -> "You don't have permission to perform this action." + 404 -> "The requested resource was not found." + 409 -> "This action cannot be completed. Try again." + in CLIENT_ERROR_RANGE -> "Request error. Please try again." + in SERVER_ERROR_RANGE -> "Server error. Please try again later." + else -> "An error occurred. Please try again." } } @@ -45,14 +42,14 @@ suspend fun safeApiCall(apiCall: suspend () -> T): ResultWrapper { ResultWrapper.Error( ApiError( code = NO_CONNECTION_ERROR_CODE, - message = "Нет подключения к интернету: ${e.message}", + message = "No internet connection. Please check your connection.", ), ) } catch (e: TimeoutCancellationException) { ResultWrapper.Error( ApiError( code = UNKNOWN_ERROR_CODE, - message = e.message ?: "Превышено время ожидания", + message = "Request timed out. Please try again.", ), ) } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/auth/data/repository/AuthRepositoryImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/auth/data/repository/AuthRepositoryImpl.kt index 74c8458..735c533 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/auth/data/repository/AuthRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/auth/data/repository/AuthRepositoryImpl.kt @@ -43,7 +43,7 @@ internal class AuthRepositoryImpl( override suspend fun refresh(): ResultWrapper { val current = storage.getSession() - ?: return ResultWrapper.Error(ApiError(HTTP_UNAUTHORIZED, "No active session")) + ?: return ResultWrapper.Error(ApiError(HTTP_UNAUTHORIZED, "Your session has expired. Please log in again.")) return safeApiCall { val response = api.refresh(RefreshRequestDto(current.token.refreshToken)) From d0599277ff4e03851736f6a676b06741313b75e2 Mon Sep 17 00:00:00 2001 From: danilpapa Date: Fri, 29 May 2026 19:08:05 +0300 Subject: [PATCH 2/2] setup local DB --- gradle/libs.versions.toml | 1 + .../kotlin/friends/mobile/core/CommonKmp.kt | 2 + .../mobile/core/db/ProfileCacheStorage.kt | 38 ++++++++++++++++++ .../mobile/core/db/di/DatabaseModule.kt | 13 +++++++ .../data/repository/ProfileRepositoryImpl.kt | 39 ++++++++++++++----- .../feature/profile/di/ProfileModule.kt | 4 +- .../feature/profile/domain/model/Profile.kt | 3 ++ 7 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 shared/src/commonMain/kotlin/friends/mobile/core/db/ProfileCacheStorage.kt create mode 100644 shared/src/commonMain/kotlin/friends/mobile/core/db/di/DatabaseModule.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ee165c7..7b1463a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ kotlin = "2.3.0" material3 = "1.10.0-alpha05" androidx-navigation = "2.8.5" accompanist = "0.34.0" +sqlite-bundled = "2.5.1" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } diff --git a/shared/src/commonMain/kotlin/friends/mobile/core/CommonKmp.kt b/shared/src/commonMain/kotlin/friends/mobile/core/CommonKmp.kt index 0409a3f..2a73b1a 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/core/CommonKmp.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/core/CommonKmp.kt @@ -1,6 +1,7 @@ package friends.mobile.core import friends.mobile.core.config.Configuration +import friends.mobile.core.db.di.databaseModule import friends.mobile.core.di.platformModule import friends.mobile.core.di.qualifierModule import friends.mobile.core.network.networkModule @@ -21,6 +22,7 @@ object CommonKmp { createConfiguration(configuration), qualifierModule, platformModule, + databaseModule, networkModule, featureModule, ) diff --git a/shared/src/commonMain/kotlin/friends/mobile/core/db/ProfileCacheStorage.kt b/shared/src/commonMain/kotlin/friends/mobile/core/db/ProfileCacheStorage.kt new file mode 100644 index 0000000..5be0b4f --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/core/db/ProfileCacheStorage.kt @@ -0,0 +1,38 @@ +package friends.mobile.core.db + +import com.russhwolf.settings.Settings +import friends.mobile.feature.profile.domain.model.Profile +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private const val PROFILE_CACHE_KEY = "profile_cache" + +interface ProfileCacheStorage { + suspend fun getProfile(): Profile? + suspend fun saveProfile(profile: Profile) + suspend fun clearProfile() +} + +class ProfileCacheStorageImpl( + private val settings: Settings, + private val json: Json, +) : ProfileCacheStorage { + override suspend fun getProfile(): Profile? { + val cached = settings.getStringOrNull(PROFILE_CACHE_KEY) ?: return null + return try { + json.decodeFromString(cached) + } catch (e: Exception) { + null + } + } + + override suspend fun saveProfile(profile: Profile) { + val serialized = json.encodeToString(profile) + settings.putString(PROFILE_CACHE_KEY, serialized) + } + + override suspend fun clearProfile() { + settings.remove(PROFILE_CACHE_KEY) + } +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/core/db/di/DatabaseModule.kt b/shared/src/commonMain/kotlin/friends/mobile/core/db/di/DatabaseModule.kt new file mode 100644 index 0000000..907a135 --- /dev/null +++ b/shared/src/commonMain/kotlin/friends/mobile/core/db/di/DatabaseModule.kt @@ -0,0 +1,13 @@ +package friends.mobile.core.db.di + +import com.russhwolf.settings.Settings +import friends.mobile.core.db.ProfileCacheStorage +import friends.mobile.core.db.ProfileCacheStorageImpl +import kotlinx.serialization.json.Json +import org.koin.dsl.module + +val databaseModule = module { + single { + ProfileCacheStorageImpl(get(), get()) + } +} diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/data/repository/ProfileRepositoryImpl.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/data/repository/ProfileRepositoryImpl.kt index 623fa8d..148c91e 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/data/repository/ProfileRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/data/repository/ProfileRepositoryImpl.kt @@ -1,5 +1,6 @@ package friends.mobile.feature.profile.data.repository +import friends.mobile.core.db.ProfileCacheStorage import friends.mobile.core.domain.model.ResultWrapper import friends.mobile.core.network.safeApiCall import friends.mobile.feature.profile.data.mapper.ProfileMapper @@ -8,25 +9,45 @@ import friends.mobile.feature.profile.data.remote.dto.UpdateProfileRequestDto import friends.mobile.feature.profile.domain.model.Profile import friends.mobile.feature.profile.domain.repository.ProfileRepository +private const val NO_CONNECTION_ERROR_CODE = -1 + internal class ProfileRepositoryImpl( private val api: ProfileApi, private val mapper: ProfileMapper, + private val cacheStorage: ProfileCacheStorage, ) : ProfileRepository { - override suspend fun getMe(): ResultWrapper = safeApiCall { - mapper.mapToDomain(api.getMe()) + override suspend fun getMe(): ResultWrapper { + val result = safeApiCall { mapper.mapToDomain(api.getMe()) } + return when (result) { + is ResultWrapper.Success -> { + cacheStorage.saveProfile(result.data) + result + } + is ResultWrapper.Error -> { + if (result.error.code == NO_CONNECTION_ERROR_CODE) { + cacheStorage.getProfile()?.let { ResultWrapper.Success(it) } ?: result + } else result + } + } } override suspend fun updateProfile( username: String?, avatarUrl: String?, bio: String? - ): ResultWrapper = safeApiCall { - val body = UpdateProfileRequestDto( - username = username, - avatarUrl = avatarUrl, - bio = bio - ) - mapper.mapToDomain(api.patchMe(body)) + ): ResultWrapper { + val result = safeApiCall { + val body = UpdateProfileRequestDto( + username = username, + avatarUrl = avatarUrl, + bio = bio + ) + mapper.mapToDomain(api.patchMe(body)) + } + if (result is ResultWrapper.Success) { + cacheStorage.saveProfile(result.data) + } + return result } } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/di/ProfileModule.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/di/ProfileModule.kt index b86ed94..e162677 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/di/ProfileModule.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/di/ProfileModule.kt @@ -1,5 +1,6 @@ package friends.mobile.feature.profile.di +import friends.mobile.core.db.ProfileCacheStorage import friends.mobile.core.di.QualifierAuthClient import friends.mobile.feature.profile.data.mapper.ProfileMapper import friends.mobile.feature.profile.data.remote.ProfileApi @@ -22,7 +23,8 @@ val profileModule = module { single { ProfileRepositoryImpl( api = get(), - mapper = get() + mapper = get(), + cacheStorage = get() ) } single { GetMeUseCaseImpl(get()) } diff --git a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt index f5b4d83..4bab213 100644 --- a/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt +++ b/shared/src/commonMain/kotlin/friends/mobile/feature/profile/domain/model/Profile.kt @@ -1,5 +1,8 @@ package friends.mobile.feature.profile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class Profile( val id: String, val username: String,