From fd8f6d612cfaa5010ad7993a5199b001495224d3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 4 Dec 2025 23:59:39 -0600 Subject: [PATCH] add support button, icon view --- .../composeResources/values/strings.xml | 3 + .../com/example/casera/network/ApiConfig.kt | 2 +- .../casera/ui/screens/ProfileScreen.kt | 43 +++ iosApp/iosApp/Helpers/L10n.swift | 5 + iosApp/iosApp/Localizable.xcstrings | 33 ++ iosApp/iosApp/Profile/ProfileTabView.swift | 26 ++ .../Subviews/Common/MyCribIconView.swift | 323 ++++++++++++++++++ 7 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 iosApp/iosApp/Subviews/Common/MyCribIconView.swift 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() +}