Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct AppScene: View {
@StateObject private var feeEstimatesManager: FeeEstimatesManager
@StateObject private var transfer: TransferViewModel
@StateObject private var widgets = WidgetsViewModel()
@StateObject private var cameraManager = CameraManager.shared
@StateObject private var pushManager = PushNotificationManager.shared
@StateObject private var scannerManager = ScannerManager()
@StateObject private var settings = SettingsViewModel.shared
Expand Down Expand Up @@ -124,6 +125,7 @@ struct AppScene: View {
.environmentObject(activity)
.environmentObject(transfer)
.environmentObject(widgets)
.environmentObject(cameraManager)
.environmentObject(pushManager)
.environmentObject(scannerManager)
.environmentObject(settings)
Expand Down
15 changes: 15 additions & 0 deletions Bitkit/Assets.xcassets/icons/camera.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "camera.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.
15 changes: 15 additions & 0 deletions Bitkit/Assets.xcassets/icons/eye-slash.imageset/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"images" : [
{
"filename" : "eye-slash.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.
12 changes: 10 additions & 2 deletions Bitkit/Components/ActivityIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@ import SwiftUI

struct ActivityIndicator: View {
let size: CGFloat
let theme: Theme

enum Theme {
case light
case dark
}

@State private var isRotating = false
@State private var opacity: Double = 0

init(size: CGFloat = 32) {
init(size: CGFloat = 32, theme: Theme = .light) {
self.size = size
self.theme = theme
}

var body: some View {
let strokeWidth = size / 12
let color = theme == .light ? Color.white : Color.black

ZStack {
Circle()
.trim(from: 0.1, to: 0.94)
.stroke(
AngularGradient(
gradient: Gradient(colors: [.black, .white]),
gradient: Gradient(colors: [.clear, color]),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Components/Button/PrimaryButtonView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct PrimaryButtonView: View {
.background(backgroundGradient)
.cornerRadius(64)
.shadow(color: shadowColor, radius: 0, x: 0, y: -1)
.shadow(color: Color.black.opacity(0.32), radius: 4, x: 0, y: 2)
.opacity(isDisabled ? 0.32 : 1.0)
.contentShape(Rectangle())
}
Expand Down
58 changes: 46 additions & 12 deletions Bitkit/Components/Scanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,58 @@ struct Scanner: View {
let onScan: (String) async -> Void
let onImageSelection: (PhotosPickerItem?) async -> Void

@EnvironmentObject private var cameraManager: CameraManager
@State private var isTorchOn = false

var body: some View {
ZStack {
ScannerCamera(
isTorchOn: isTorchOn,
onScan: { uri in
await onScan(uri)
}
)
if cameraManager.hasPermission {
ScannerCamera(
isTorchOn: isTorchOn,
onScan: { uri in
await onScan(uri)
}
)

ScannerCornerButtons(
isTorchOn: $isTorchOn,
onImageSelection: { item in
await onImageSelection(item)
}
)
ScannerCornerButtons(
isTorchOn: $isTorchOn,
onImageSelection: { item in
await onImageSelection(item)
}
)
} else {
ScannerPermissionRequest(onRequestPermission: cameraManager.requestPermission)
}
}
.cornerRadius(16)
.onAppear {
guard !cameraManager.hasPermission else { return }
cameraManager.requestPermissionIfNeeded()
}
}
}

struct ScannerPermissionRequest: View {
let onRequestPermission: () -> Void

var body: some View {
Color.black
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
VStack(spacing: 0) {
DisplayText(t("other__camera_no_title"), accentColor: .brandAccent)
.padding(.bottom, 8)
BodyMText(t("other__camera_no_text"))
.padding(.bottom, 32)
CustomButton(
title: t("other__camera_no_button"),
icon: Image("camera").foregroundColor(.textPrimary)
) {
onRequestPermission()
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
}
}
}
25 changes: 25 additions & 0 deletions Bitkit/Components/SendSectionView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import SwiftUI

/// A section with a caption label, content, and a divider below. Used for form-style rows (e.g. "Send from", "Send to").
struct SendSectionView<Content: View>: View {
private let title: String
@ViewBuilder private let content: () -> Content

init(_ title: String, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.content = content
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
CaptionMText(title)
.padding(.bottom, 8)

content()

CustomDivider()
.padding(.top, 16)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
30 changes: 19 additions & 11 deletions Bitkit/Components/SwipeButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import SwiftUI
struct SwipeButton: View {
let title: String
let accentColor: Color
/// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent.
var swipeProgress: Binding<CGFloat>?
let onComplete: () async throws -> Void

@State private var offset: CGFloat = 0
@State private var isDragging = false
@State private var isLoading = false

private let buttonHeight: CGFloat = 76
private let innerPadding: CGFloat = 16

var body: some View {
GeometryReader { geometry in
let maxOffset = max(1, geometry.size.width - buttonHeight)
let clampedOffset = max(0, min(offset, geometry.size.width - buttonHeight))
let trailWidth = max(0, min(clampedOffset + (buttonHeight - innerPadding), geometry.size.width - innerPadding))
let textProgress = offset / maxOffset
let halfWidth = geometry.size.width / 2

ZStack(alignment: .leading) {
// Track
RoundedRectangle(cornerRadius: buttonHeight / 2)
Expand All @@ -22,7 +29,7 @@ struct SwipeButton: View {
// Colored trail
RoundedRectangle(cornerRadius: buttonHeight / 2)
.fill(accentColor.opacity(0.2))
.frame(width: max(0, min(offset + (buttonHeight - innerPadding), geometry.size.width - innerPadding)))
.frame(width: trailWidth)
.frame(height: buttonHeight - innerPadding)
.padding(.horizontal, innerPadding / 2)
.mask {
Expand All @@ -34,52 +41,51 @@ struct SwipeButton: View {
// Track text
BodySSBText(title)
.frame(maxWidth: .infinity, alignment: .center)
.opacity(Double(1.0 - (offset / (geometry.size.width - buttonHeight))))
.opacity(Double(1.0 - textProgress))

// Sliding circle
// Knob
Circle()
.fill(accentColor)
.frame(width: buttonHeight - innerPadding, height: buttonHeight - innerPadding)
.overlay(
ZStack {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .gray7))
ActivityIndicator(theme: .dark)
} else {
Image("arrow-right")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.gray7)
.opacity(Double(1.0 - (offset / (geometry.size.width / 2))))
.opacity(Double(1.0 - (offset / halfWidth)))

Image("check-mark")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.gray7)
.opacity(Double(max(0, (offset - geometry.size.width / 2) / (geometry.size.width / 2))))
.opacity(Double(max(0, (offset - halfWidth) / halfWidth)))
}
}
)
.accessibilityIdentifier("GRAB")
.offset(x: max(0, min(offset, geometry.size.width - buttonHeight)))
.offset(x: clampedOffset)
.padding(.horizontal, innerPadding / 2)
.gesture(
DragGesture()
.onChanged { value in
guard !isLoading else { return }
withAnimation(.interactiveSpring()) {
isDragging = true
offset = value.translation.width
swipeProgress?.wrappedValue = max(0, min(1, offset / maxOffset))
}
}
.onEnded { _ in
guard !isLoading else { return }
isDragging = false
withAnimation(.spring()) {
let threshold = geometry.size.width * 0.7
if offset > threshold {
Haptics.play(.medium)
offset = geometry.size.width - buttonHeight
swipeProgress?.wrappedValue = 1
isLoading = true
Task { @MainActor in
do {
Expand All @@ -88,6 +94,7 @@ struct SwipeButton: View {
// Reset the slider back to the start on error
withAnimation(.spring(duration: 0.3)) {
offset = 0
swipeProgress?.wrappedValue = 0
}

// Adjust the delay to match animation duration
Expand All @@ -98,6 +105,7 @@ struct SwipeButton: View {
}
} else {
offset = 0
swipeProgress?.wrappedValue = 0
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SwiftUI

struct MainNavView: View {
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var cameraManager: CameraManager
@EnvironmentObject private var currency: CurrencyViewModel
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var notificationManager: PushNotificationManager
Expand Down Expand Up @@ -169,8 +170,9 @@ struct MainNavView: View {
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
// Update notification permission in case user changed it in OS settings
// Update permissions in case user changed them in OS settings
notificationManager.updateNotificationPermission()
cameraManager.refreshPermission()

guard settings.readClipboard else { return }

Expand Down
49 changes: 49 additions & 0 deletions Bitkit/Managers/CameraManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import AVFoundation
import SwiftUI

final class CameraManager: ObservableObject {
static let shared = CameraManager()
@Published var hasPermission: Bool = false

init() {
refreshPermission()
}

func refreshPermission() {
let granted = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
DispatchQueue.main.async {
self.hasPermission = granted
}
}

/// Call when the scanner appears; shows the system permission dialog only when status is .notDetermined (fresh install).
func requestPermissionIfNeeded() {
guard AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined else { return }
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
self.hasPermission = granted
}
}
}

func requestPermission() {
let status = AVCaptureDevice.authorizationStatus(for: .video)

switch status {
case .notDetermined:
requestPermissionIfNeeded()
case .denied, .restricted:
if let url = URL(string: UIApplication.openSettingsURLString) {
DispatchQueue.main.async {
UIApplication.shared.open(url)
}
}
case .authorized:
DispatchQueue.main.async {
self.hasPermission = true
}
@unknown default:
break
}
}
}
Loading
Loading