diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 778b2b4..d77182e 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -357,6 +357,9 @@
Limited features
Upgrade to Pro
Manage your subscription in the Google Play Store
+ Support
+ Contact Support
+ Get help with your account
Settings
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
index 45cffa3..a19e50e 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt
@@ -9,7 +9,7 @@ package com.example.casera.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
- val CURRENT_ENV = Environment.LOCAL
+ val CURRENT_ENV = Environment.DEV
enum class Environment {
LOCAL,
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
index 58cfb06..61d07c9 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt
@@ -11,6 +11,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -250,6 +251,48 @@ fun ProfileScreen(
}
}
+ // Contact Support Section
+ val uriHandler = LocalUriHandler.current
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ uriHandler.openUri("mailto:caseraSupport@treymail.com?subject=Casera%20Support%20Request")
+ },
+ shape = RoundedCornerShape(AppRadius.md),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(AppSpacing.lg),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
+ ) {
+ Text(
+ text = stringResource(Res.string.profile_contact_support),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = stringResource(Res.string.profile_contact_support_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = stringResource(Res.string.profile_contact_support),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+
// Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift
index cea95ec..a2f79f4 100644
--- a/iosApp/iosApp/Helpers/L10n.swift
+++ b/iosApp/iosApp/Helpers/L10n.swift
@@ -545,6 +545,11 @@ enum L10n {
static var emailNotifications: String { String(localized: "profile_email_notifications") }
static var emailTaskCompleted: String { String(localized: "profile_email_task_completed") }
static var emailTaskCompletedDescription: String { String(localized: "profile_email_task_completed_description") }
+
+ // Support
+ static var support: String { String(localized: "profile_support") }
+ static var contactSupport: String { String(localized: "profile_contact_support") }
+ static var contactSupportSubtitle: String { String(localized: "profile_contact_support_subtitle") }
}
// MARK: - Settings
diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings
index 8d1acbb..5f4832a 100644
--- a/iosApp/iosApp/Localizable.xcstrings
+++ b/iosApp/iosApp/Localizable.xcstrings
@@ -19372,6 +19372,39 @@
}
}
},
+ "profile_support" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Support"
+ }
+ }
+ }
+ },
+ "profile_contact_support" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Contact Support"
+ }
+ }
+ }
+ },
+ "profile_contact_support_subtitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Get help with your account"
+ }
+ }
+ }
+ },
"profile_subscription" : {
"extractionState" : "manual",
"localizations" : {
diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift
index e076ae7..36f53c2 100644
--- a/iosApp/iosApp/Profile/ProfileTabView.swift
+++ b/iosApp/iosApp/Profile/ProfileTabView.swift
@@ -139,6 +139,22 @@ struct ProfileTabView: View {
}
.listRowBackground(Color.appBackgroundSecondary)
+ Section(L10n.Profile.support) {
+ Button(action: {
+ sendSupportEmail()
+ }) {
+ HStack {
+ Label(L10n.Profile.contactSupport, systemImage: "envelope")
+ .foregroundColor(Color.appTextPrimary)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption)
+ .foregroundColor(Color.appTextSecondary)
+ }
+ }
+ }
+ .listRowBackground(Color.appBackgroundSecondary)
+
Section {
Button(action: {
showingLogoutAlert = true
@@ -193,4 +209,14 @@ struct ProfileTabView: View {
Text(L10n.Profile.purchasesRestoredMessage)
}
}
+
+ private func sendSupportEmail() {
+ let email = "caseraSupport@treymail.com"
+ let subject = "Casera Support Request"
+ let urlString = "mailto:\(email)?subject=\(subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject)"
+
+ if let url = URL(string: urlString) {
+ UIApplication.shared.open(url)
+ }
+ }
}
diff --git a/iosApp/iosApp/Subviews/Common/MyCribIconView.swift b/iosApp/iosApp/Subviews/Common/MyCribIconView.swift
new file mode 100644
index 0000000..8e4b207
--- /dev/null
+++ b/iosApp/iosApp/Subviews/Common/MyCribIconView.swift
@@ -0,0 +1,323 @@
+import SwiftUI
+
+/// SVG viewBox dimensions for coordinate conversion
+private let svgSize: CGFloat = 512
+
+/// Optical center adjustment (checkmark visual weight is left-heavy)
+private let opticalCenterOffset: CGFloat = 8
+
+/// Animatable house roof path (right side)
+struct HouseRightPath: Shape {
+ var trimEnd: CGFloat = 1.0
+
+ var animatableData: CGFloat {
+ get { trimEnd }
+ set { trimEnd = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let scale = min(rect.width, rect.height) / svgSize
+ let ox = opticalCenterOffset * scale // optical offset
+
+ var path = Path()
+ // M256 108 L375 200 Q392 212 392 235 L392 345 Q392 375 362 375 L335 375
+ path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
+ path.addLine(to: CGPoint(x: 375 * scale + ox, y: 200 * scale))
+ path.addQuadCurve(
+ to: CGPoint(x: 392 * scale + ox, y: 235 * scale),
+ control: CGPoint(x: 392 * scale + ox, y: 212 * scale)
+ )
+ path.addLine(to: CGPoint(x: 392 * scale + ox, y: 345 * scale))
+ path.addQuadCurve(
+ to: CGPoint(x: 362 * scale + ox, y: 375 * scale),
+ control: CGPoint(x: 392 * scale + ox, y: 375 * scale)
+ )
+ path.addLine(to: CGPoint(x: 335 * scale + ox, y: 375 * scale))
+
+ return path.trimmedPath(from: 0, to: trimEnd)
+ }
+}
+
+/// Animatable house roof path (left side)
+struct HouseLeftPath: Shape {
+ var trimEnd: CGFloat = 1.0
+
+ var animatableData: CGFloat {
+ get { trimEnd }
+ set { trimEnd = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let scale = min(rect.width, rect.height) / svgSize
+ let ox = opticalCenterOffset * scale // optical offset
+
+ var path = Path()
+ // M256 108 L137 200 Q120 212 120 235 L120 345 Q120 375 150 375 L177 375
+ path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
+ path.addLine(to: CGPoint(x: 137 * scale + ox, y: 200 * scale))
+ path.addQuadCurve(
+ to: CGPoint(x: 120 * scale + ox, y: 235 * scale),
+ control: CGPoint(x: 120 * scale + ox, y: 212 * scale)
+ )
+ path.addLine(to: CGPoint(x: 120 * scale + ox, y: 345 * scale))
+ path.addQuadCurve(
+ to: CGPoint(x: 150 * scale + ox, y: 375 * scale),
+ control: CGPoint(x: 120 * scale + ox, y: 375 * scale)
+ )
+ path.addLine(to: CGPoint(x: 177 * scale + ox, y: 375 * scale))
+
+ return path.trimmedPath(from: 0, to: trimEnd)
+ }
+}
+
+/// Animatable checkmark path
+struct CheckmarkPath: Shape {
+ var trimEnd: CGFloat = 1.0
+
+ var animatableData: CGFloat {
+ get { trimEnd }
+ set { trimEnd = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let scale = min(rect.width, rect.height) / svgSize
+ let ox = opticalCenterOffset * scale // optical offset
+
+ var path = Path()
+ // M175 320 L235 380 L355 250
+ path.move(to: CGPoint(x: 175 * scale + ox, y: 320 * scale))
+ path.addLine(to: CGPoint(x: 235 * scale + ox, y: 380 * scale))
+ path.addLine(to: CGPoint(x: 355 * scale + ox, y: 250 * scale))
+
+ return path.trimmedPath(from: 0, to: trimEnd)
+ }
+}
+
+/// Animatable window (rounded rectangle)
+struct WindowShape: Shape {
+ var scale: CGFloat = 1.0
+
+ var animatableData: CGFloat {
+ get { scale }
+ set { scale = newValue }
+ }
+
+ func path(in rect: CGRect) -> Path {
+ let viewScale = min(rect.width, rect.height) / svgSize
+ let ox = opticalCenterOffset * viewScale // optical offset
+
+ // Window at (230, 175) with size 52x52 and corner radius 12
+ let windowX: CGFloat = 230
+ let windowY: CGFloat = 175
+ let windowSize: CGFloat = 52
+ let cornerRadius: CGFloat = 12
+
+ // Center point of window (with optical offset)
+ let centerX = (windowX + windowSize / 2) * viewScale + ox
+ let centerY = (windowY + windowSize / 2) * viewScale
+
+ // Scaled dimensions
+ let scaledSize = windowSize * viewScale * scale
+ let scaledRadius = cornerRadius * viewScale * scale
+
+ let windowRect = CGRect(
+ x: centerX - scaledSize / 2,
+ y: centerY - scaledSize / 2,
+ width: scaledSize,
+ height: scaledSize
+ )
+
+ return Path(roundedRect: windowRect, cornerRadius: scaledRadius)
+ }
+}
+
+/// Background rounded rectangle shape
+struct BackgroundShape: Shape {
+ func path(in rect: CGRect) -> Path {
+ let scale = min(rect.width, rect.height) / svgSize
+
+ // rect at (24, 24) with 464x464 size and rx/ry=100
+ let bgRect = CGRect(
+ x: 24 * scale,
+ y: 24 * scale,
+ width: 464 * scale,
+ height: 464 * scale
+ )
+
+ return Path(roundedRect: bgRect, cornerRadius: 100 * scale)
+ }
+}
+
+/// MyCrib icon view with all paths for animation
+struct MyCribIconView: View {
+ // Animation progress values (0 to 1)
+ var houseLeftProgress: CGFloat = 1.0
+ var houseRightProgress: CGFloat = 1.0
+ var windowScale: CGFloat = 1.0
+ var checkmarkProgress: CGFloat = 1.0
+ var showBackground: Bool = true
+ var backgroundOpacity: Double = 1.0
+
+ // Colors
+ var backgroundColor: Color = Color(red: 0.96, green: 0.55, blue: 0.24) // #F58D3D
+ var foregroundColor: Color = Color(red: 1.0, green: 0.96, blue: 0.92) // #FFF5EB
+
+ // Stroke widths (scaled from SVG)
+ private let houseStrokeWidth: CGFloat = 28
+ private let checkmarkStrokeWidth: CGFloat = 32
+
+ var body: some View {
+ GeometryReader { geometry in
+ let size = min(geometry.size.width, geometry.size.height)
+ let scale = size / svgSize
+ // Center offset to position content in middle of available space
+ let offsetX = (geometry.size.width - size) / 2
+ let offsetY = (geometry.size.height - size) / 2
+
+ ZStack {
+ // Background
+ if showBackground {
+ BackgroundShape()
+ .fill(
+ LinearGradient(
+ colors: [
+ Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347
+ Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ .opacity(backgroundOpacity)
+ .shadow(color: .black.opacity(0.15), radius: 12 * scale, x: 0, y: 8 * scale)
+ }
+
+ // House left side
+ HouseLeftPath(trimEnd: houseLeftProgress)
+ .stroke(
+ foregroundColor,
+ style: StrokeStyle(
+ lineWidth: houseStrokeWidth * scale,
+ lineCap: .round,
+ lineJoin: .round
+ )
+ )
+
+ // House right side
+ HouseRightPath(trimEnd: houseRightProgress)
+ .stroke(
+ foregroundColor,
+ style: StrokeStyle(
+ lineWidth: houseStrokeWidth * scale,
+ lineCap: .round,
+ lineJoin: .round
+ )
+ )
+
+ // Window
+ WindowShape(scale: windowScale)
+ .fill(foregroundColor)
+
+ // Checkmark
+ CheckmarkPath(trimEnd: checkmarkProgress)
+ .stroke(
+ foregroundColor,
+ style: StrokeStyle(
+ lineWidth: checkmarkStrokeWidth * scale,
+ lineCap: .round,
+ lineJoin: .round
+ )
+ )
+ }
+ .frame(width: size, height: size)
+ .offset(x: offsetX, y: offsetY)
+ }
+ .aspectRatio(1, contentMode: .fit)
+ }
+}
+
+/// Animated version with built-in draw animation
+struct AnimatedMyCribIconView: View {
+ @State private var houseLeftProgress: CGFloat = 0
+ @State private var houseRightProgress: CGFloat = 0
+ @State private var windowScale: CGFloat = 0
+ @State private var checkmarkProgress: CGFloat = 0
+ @State private var backgroundOpacity: Double = 0
+
+ var animationDuration: Double = 0.5
+ var staggerDelay: Double = 0.15
+ var showBackground: Bool = true
+
+ var body: some View {
+ MyCribIconView(
+ houseLeftProgress: houseLeftProgress,
+ houseRightProgress: houseRightProgress,
+ windowScale: windowScale,
+ checkmarkProgress: checkmarkProgress,
+ showBackground: showBackground,
+ backgroundOpacity: backgroundOpacity
+ )
+ .onAppear {
+ animateIn()
+ }
+ }
+
+ func animateIn() {
+ // Background fade in
+ withAnimation(.easeOut(duration: animationDuration * 0.5)) {
+ backgroundOpacity = 1.0
+ }
+
+ // House sides draw together
+ withAnimation(.easeOut(duration: animationDuration).delay(staggerDelay)) {
+ houseLeftProgress = 1.0
+ houseRightProgress = 1.0
+ }
+
+ // Window pops in
+ withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(staggerDelay * 2)) {
+ windowScale = 1.0
+ }
+
+ // Checkmark draws
+ withAnimation(.easeOut(duration: animationDuration * 0.8).delay(staggerDelay * 3)) {
+ checkmarkProgress = 1.0
+ }
+ }
+
+ func reset() {
+ houseLeftProgress = 0
+ houseRightProgress = 0
+ windowScale = 0
+ checkmarkProgress = 0
+ backgroundOpacity = 0
+ }
+}
+
+#Preview("Static Icon") {
+ MyCribIconView()
+ .frame(width: 200, height: 200)
+ .padding()
+}
+
+#Preview("Animated Icon") {
+ AnimatedMyCribIconView()
+ .frame(width: 200, height: 200)
+ .padding()
+}
+
+#Preview("Icon Without Background") {
+ MyCribIconView(showBackground: false)
+ .frame(width: 200, height: 200)
+ .padding()
+ .background(Color.orange)
+}
+
+#Preview("Custom Colors") {
+ MyCribIconView(
+ backgroundColor: .blue,
+ foregroundColor: .white
+ )
+ .frame(width: 200, height: 200)
+ .padding()
+}