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/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/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/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))
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,