Replace Skip implementation with Kotlin Multiplatform implementation
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"idea": {
|
||||
"url": "http://127.0.0.1:64342/stream",
|
||||
"type": "http"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.android.application)
|
||||
id("skip-build-plugin")
|
||||
}
|
||||
|
||||
skip {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = group as String
|
||||
compileSdk = libs.versions.android.sdk.compile.get().toInt()
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.toVersion(libs.versions.jvm.get())
|
||||
targetCompatibility = JavaVersion.toVersion(libs.versions.jvm.get())
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = libs.versions.jvm.get().toString()
|
||||
}
|
||||
packaging {
|
||||
jniLibs.keepDebugSymbols.add("**/*.so")
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.android.sdk.min.get().toInt()
|
||||
targetSdk = libs.versions.android.sdk.compile.get().toInt()
|
||||
// skip.tools.skip-build-plugin will automatically use Skip.env properties for:
|
||||
// applicationId = PRODUCT_BUNDLE_IDENTIFIER
|
||||
// versionCode = CURRENT_PROJECT_VERSION
|
||||
// versionName = MARKETING_VERSION
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// default signing configuration tries to load from keystore.properties
|
||||
signingConfigs {
|
||||
val keystorePropertiesFile = file("keystore.properties")
|
||||
if (keystorePropertiesFile.isFile) {
|
||||
create("release") {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isDebuggable = false // can be set to true for debugging release build, but needs to be false when uploading to store
|
||||
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
-keeppackagenames **
|
||||
-keep class skip.** { *; }
|
||||
-keep class com.sun.jna.** { *; }
|
||||
-keep class * implements com.sun.jna.** { *; }
|
||||
-keep class sats.price.** { *; }
|
||||
-dontwarn java.awt.**
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- This AndroidManifest.xml template was generated by Skip -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- example permissions for using device location -->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> -->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> -->
|
||||
|
||||
<!-- permissions needed for using the internet or an embedded WebKit browser -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> -->
|
||||
|
||||
<application
|
||||
android:label="${PRODUCT_NAME}"
|
||||
android:name=".AndroidAppMain"
|
||||
android:supportsRtl="true"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|mnc|colorMode|density|fontScale|fontWeightAdjustment|keyboard|layoutDirection|locale|mcc|navigation|smallestScreenSize|touchscreen|uiMode"
|
||||
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,125 +0,0 @@
|
||||
package sats.price
|
||||
|
||||
import skip.lib.*
|
||||
import skip.model.*
|
||||
import skip.foundation.*
|
||||
import skip.ui.*
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Application
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.app.ActivityCompat
|
||||
|
||||
internal val logger: SkipLogger = SkipLogger(subsystem = "sats.price", category = "SatsPrice")
|
||||
|
||||
/// AndroidAppMain is the `android.app.Application` entry point, and must match `application android:name` in the AndroidMainfest.xml file.
|
||||
open class AndroidAppMain: Application {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
logger.info("starting app")
|
||||
ProcessInfo.launch(applicationContext)
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
/// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file.
|
||||
open class MainActivity: AppCompatActivity {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: android.os.Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
logger.info("starting activity")
|
||||
UIApplication.launch(this)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
val saveableStateHolder = rememberSaveableStateHolder()
|
||||
saveableStateHolder.SaveableStateProvider(true) {
|
||||
PresentationRootView(ComposeContext())
|
||||
}
|
||||
}
|
||||
|
||||
// Example of requesting permissions on startup.
|
||||
// These must match the permissions in the AndroidManifest.xml file.
|
||||
//let permissions = listOf(
|
||||
// Manifest.permission.ACCESS_COARSE_LOCATION,
|
||||
// Manifest.permission.ACCESS_FINE_LOCATION
|
||||
// Manifest.permission.CAMERA,
|
||||
// Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
//)
|
||||
//let requestTag = 1
|
||||
//ActivityCompat.requestPermissions(self, permissions.toTypedArray(), requestTag)
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(bundle: android.os.Bundle): Unit = super.onSaveInstanceState(bundle)
|
||||
|
||||
override fun onRestoreInstanceState(bundle: android.os.Bundle) {
|
||||
// Usually you restore your state in onCreate(). It is possible to restore it in onRestoreInstanceState() as well, but not very common. (onRestoreInstanceState() is called after onStart(), whereas onCreate() is called before onStart().
|
||||
logger.info("onRestoreInstanceState")
|
||||
super.onRestoreInstanceState(bundle)
|
||||
}
|
||||
|
||||
override fun onRestart() {
|
||||
logger.info("onRestart")
|
||||
super.onRestart()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
logger.info("onStart")
|
||||
super.onStart()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
logger.info("onResume")
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
logger.info("onPause")
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
logger.info("onStop")
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
logger.info("onDestroy")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
logger.info("onRequestPermissionsResult: ${requestCode}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PresentationRootView(context: ComposeContext) {
|
||||
val colorScheme = if (isSystemInDarkTheme()) ColorScheme.dark else ColorScheme.light
|
||||
PresentationRoot(defaultColorScheme = colorScheme, context = context) { ctx ->
|
||||
val contentContext = ctx.content()
|
||||
Box(modifier = ctx.modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
RootView().Compose(context = contentContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 12 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -1,11 +0,0 @@
|
||||
# This file contains the app distribution configuration
|
||||
# for the Android half of the Skip app.
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
|
||||
# Load the shared Skip.env properties with the app info
|
||||
require('dotenv')
|
||||
Dotenv.load '../../Skip.env'
|
||||
package_name(ENV['PRODUCT_BUNDLE_IDENTIFIER'])
|
||||
|
||||
# Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
|
||||
json_key_file("fastlane/apikey.json")
|
||||
@@ -1,49 +0,0 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# for the Android half of the Skip app.
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
|
||||
# Load the shared Skip.env properties with the app info
|
||||
require('dotenv')
|
||||
Dotenv.load '../../Skip.env'
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
# use the Homebrew gradle rather than expecting a local gradlew
|
||||
gradle_bin = (ENV['HOMEBREW_PREFIX'] ? ENV['HOMEBREW_PREFIX'] : "/opt/homebrew") + "/bin/gradle"
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
desc "Build Skip Android App"
|
||||
lane :build do |options|
|
||||
build_config = (options[:release] ? "Release" : "Debug")
|
||||
gradle(
|
||||
task: "build${build_config}",
|
||||
gradle_path: gradle_bin,
|
||||
flags: "--warning-mode none -x lint"
|
||||
)
|
||||
end
|
||||
|
||||
desc "Test Skip Android App"
|
||||
lane :test do
|
||||
gradle(
|
||||
task: "test",
|
||||
gradle_path: gradle_bin
|
||||
)
|
||||
end
|
||||
|
||||
desc "Assemble Skip Android App"
|
||||
lane :assemble do
|
||||
gradle(
|
||||
gradle_path: gradle_bin,
|
||||
task: "bundleRelease"
|
||||
)
|
||||
# sh "your_script.sh"
|
||||
end
|
||||
|
||||
desc "Deploy Skip Android App to Google Play"
|
||||
lane :release do
|
||||
assemble
|
||||
upload_to_play_store(
|
||||
aab: '../.build/Android/app/outputs/bundle/release/app-release.aab'
|
||||
)
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
A great new app built with Skip!
|
||||
@@ -1 +0,0 @@
|
||||
A great new app built with Skip!
|
||||
@@ -1 +0,0 @@
|
||||
SatsPrice
|
||||
@@ -1,3 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx4g
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -1,2 +0,0 @@
|
||||
#Sat Aug 31 09:41:39 EEST 2024
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
@@ -1,46 +0,0 @@
|
||||
// This gradle project is part of a conventional Skip app project.
|
||||
// It invokes the shared build skip plugin logic, which included as part of the skip-unit buildSrc
|
||||
// When built from Android Studio, it uses the BUILT_PRODUCTS_DIR folder to share the same build outputs as Xcode, otherwise it uses SwiftPM's .build/ folder
|
||||
pluginManagement {
|
||||
// local override of BUILT_PRODUCTS_DIR
|
||||
if (System.getenv("BUILT_PRODUCTS_DIR") == null) {
|
||||
//System.setProperty("BUILT_PRODUCTS_DIR", "${System.getProperty("user.home")}/Library/Developer/Xcode/DerivedData/MySkipProject-aqywrhrzhkbvfseiqgxuufbdwdft/Build/Products/Debug-iphonesimulator")
|
||||
}
|
||||
|
||||
// the source for the plugin is linked as part of the SkipUnit transpilation
|
||||
val skipOutput = System.getenv("BUILT_PRODUCTS_DIR") ?: System.getProperty("BUILT_PRODUCTS_DIR")
|
||||
|
||||
val outputExt = if (skipOutput != null) ".output" else "" // Xcode saves output in package-name.output; SPM has no suffix
|
||||
val skipOutputs: File = if (skipOutput != null) {
|
||||
// BUILT_PRODUCTS_DIR is set when building from Xcode, in which case we will use Xcode's DerivedData plugin output
|
||||
file(skipOutput).resolve("../../../SourcePackages/plugins/")
|
||||
} else {
|
||||
exec {
|
||||
// create transpiled Kotlin and generate Gradle projects from SwiftPM modules
|
||||
commandLine("swift", "build")
|
||||
workingDir = file("..")
|
||||
}
|
||||
// SPM output folder is a peer of the parent Package.swift
|
||||
rootDir.resolve("../.build/plugins/outputs/")
|
||||
}
|
||||
|
||||
// load the Skip plugin (part of the skip-unit project), which handles configuring the Android project
|
||||
// because this path is a symlink, we need to use the canonical path or gradle will mis-interpret it as a different build source
|
||||
var pluginSource = skipOutputs.resolve("skip-unit${outputExt}/SkipUnit/skipstone/buildSrc/").canonicalFile
|
||||
if (!pluginSource.isDirectory) {
|
||||
// check new SwiftPM6 plugin "destination" folder for command-line builds
|
||||
pluginSource = skipOutputs.resolve("skip-unit${outputExt}/SkipUnit/destination/skipstone/buildSrc/").canonicalFile
|
||||
}
|
||||
|
||||
if (!pluginSource.isDirectory) {
|
||||
throw GradleException("Missing expected Skip output folder: ${pluginSource}. Run `swift build` in the root folder to create, or specify Xcode environment BUILT_PRODUCTS_DIR.")
|
||||
}
|
||||
includeBuild(pluginSource.path) {
|
||||
name = "skip-plugins"
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("skip-plugin") apply true
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bitcoin-calculator-1024-no-alpha 1.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-16-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-32-no-alpha 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-32-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-64-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-128-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-256-no-alpha 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-256-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-512-no-alpha 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-512-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "bitcoin-calculator-1024-no-alpha.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,56 +0,0 @@
|
||||
#include "../Skip.env"
|
||||
|
||||
// Set the action that will be executed as part of the Xcode Run Script phase
|
||||
// Setting to "launch" will build and run the app in the first open Android emulator or device
|
||||
// Setting to "build" will just run gradle build, but will not launch the app
|
||||
SKIP_ACTION = launch
|
||||
//SKIP_ACTION = build
|
||||
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
|
||||
|
||||
INFOPLIST_FILE = Info.plist
|
||||
GENERATE_INFOPLIST_FILE = YES
|
||||
|
||||
// The user-visible name of the app (localizable)
|
||||
//INFOPLIST_KEY_CFBundleDisplayName = App Name
|
||||
//INFOPLIST_KEY_LSApplicationCategoryType = public.app-category.utilities
|
||||
|
||||
// iOS-specific Info.plist property keys
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*] = YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*] = YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = YES
|
||||
INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations[sdk=iphone*] = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0
|
||||
SUPPORTS_MACCATALYST = NO
|
||||
|
||||
// iPhone + iPad
|
||||
TARGETED_DEVICE_FAMILY = 1,2
|
||||
|
||||
// iPhone only
|
||||
// TARGETED_DEVICE_FAMILY = 1
|
||||
|
||||
SWIFT_EMIT_LOC_STRINGS = YES
|
||||
|
||||
// the name of the product module; this can be anything, but cannot conflict with any Swift module names
|
||||
PRODUCT_MODULE_NAME = $(PRODUCT_NAME:c99extidentifier)App
|
||||
|
||||
// On-device testing may need to override the bundle ID
|
||||
// PRODUCT_BUNDLE_IDENTIFIER[config=Debug][sdk=iphoneos*] = cool.beans.BundleIdentifer
|
||||
|
||||
SDKROOT = auto
|
||||
SUPPORTED_PLATFORMS = iphoneos iphonesimulator macosx
|
||||
SWIFT_EMIT_LOC_STRINGS = YES
|
||||
|
||||
SWIFT_VERSION = 5.0
|
||||
//SWIFT_VERSION = 6.0
|
||||
|
||||
// Development team ID for on-device testing
|
||||
CODE_SIGNING_REQUIRED = NO
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
CODE_SIGN_ENTITLEMENTS = Entitlements.plist
|
||||
//CODE_SIGNING_IDENTITY = -
|
||||
//DEVELOPMENT_TEAM =
|
||||
@@ -1,298 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 56;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
49231BAC2AC5BCEF00F98ADF /* SatsPriceApp in Frameworks */ = {isa = PBXBuildFile; productRef = 49231BAB2AC5BCEF00F98ADF /* SatsPriceApp */; };
|
||||
49231BAD2AC5BCEF00F98ADF /* SatsPriceApp in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 49231BAB2AC5BCEF00F98ADF /* SatsPriceApp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
||||
499CD43B2AC5B799001AE8D8 /* SatsPriceAppMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* SatsPriceAppMain.swift */; };
|
||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
49231BAD2AC5BCEF00F98ADF /* SatsPriceApp in Embed Frameworks */,
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
4900101C2BACEA710000DE33 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
493609562A6B7EAE00C401E2 /* SatsPrice */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SatsPrice; path = ..; sourceTree = "<group>"; };
|
||||
496BDBEB2B89A47800C09264 /* SatsPrice.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SatsPrice.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = ../Sources/SatsPrice/Resources/Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
496EB72F2A6AE4DE00C1253A /* Skip.env */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Skip.env; path = ../Skip.env; sourceTree = "<group>"; };
|
||||
496EB72F2A6AE4DE00C1253B /* SatsPrice.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SatsPrice.xcconfig; sourceTree = "<group>"; };
|
||||
496EB72F2A6AE4DE00C1253C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
|
||||
499AB9082B0581F4005E8330 /* plugins */ = {isa = PBXFileReference; lastKnownFileType = folder; name = plugins; path = ../../../SourcePackages/plugins; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
49F90C2B2A52156200F06D93 /* SatsPriceAppMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SatsPriceAppMain.swift; path = Sources/SatsPriceAppMain.swift; sourceTree = SOURCE_ROOT; };
|
||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
499CD43C2AC5B799001AE8D8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
49231BAC2AC5BCEF00F98ADF /* SatsPriceApp in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
496BDBEC2B89A47800C09264 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
496BDBEB2B89A47800C09264 /* SatsPrice.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
49AB54462B066A7E007B79B2 /* SkipStone */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
499AB9082B0581F4005E8330 /* plugins */,
|
||||
);
|
||||
name = SkipStone;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
49F90C1F2A52156200F06D93 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
496EB72F2A6AE4DE00C1253C /* README.md */,
|
||||
496EB72F2A6AE4DE00C1253A /* Skip.env */,
|
||||
496EB72F2A6AE4DE00C1253B /* SatsPrice.xcconfig */,
|
||||
496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */,
|
||||
493609562A6B7EAE00C401E2 /* SatsPrice */,
|
||||
49F90C2A2A52156200F06D93 /* App */,
|
||||
49AB54462B066A7E007B79B2 /* SkipStone */,
|
||||
496BDBEC2B89A47800C09264 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
49F90C2A2A52156200F06D93 /* App */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
49F90C2B2A52156200F06D93 /* SatsPriceAppMain.swift */,
|
||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
||||
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
||||
4900101C2BACEA710000DE33 /* Info.plist */,
|
||||
);
|
||||
name = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
499CD4382AC5B799001AE8D8 /* SatsPrice */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "SatsPrice" */;
|
||||
buildPhases = (
|
||||
499CD43A2AC5B799001AE8D8 /* Sources */,
|
||||
499CD43C2AC5B799001AE8D8 /* Frameworks */,
|
||||
499CD43E2AC5B799001AE8D8 /* Resources */,
|
||||
499CD4452AC5B869001AE8D8 /* Run skip gradle */,
|
||||
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = SatsPrice;
|
||||
packageProductDependencies = (
|
||||
49231BAB2AC5BCEF00F98ADF /* SatsPriceApp */,
|
||||
);
|
||||
productName = App;
|
||||
productReference = 496BDBEB2B89A47800C09264 /* SatsPrice.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
49F90C202A52156200F06D93 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1430;
|
||||
LastUpgradeCheck = 1540;
|
||||
};
|
||||
buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "SatsPrice" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 49F90C1F2A52156200F06D93;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 496BDBEC2B89A47800C09264 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
499CD4382AC5B799001AE8D8 /* SatsPrice */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
499CD43E2AC5B799001AE8D8 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
499CD4452AC5B869001AE8D8 /* Run skip gradle */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run skip gradle";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = "/bin/sh -e";
|
||||
shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n\techo \"note: skipping skip due to SKIP_ZERO\"\n\texit 0\nelif [ \"${ENABLE_PREVIEWS}\" == \"YES\" ]; then\n\techo \"note: skipping skip due to ENABLE_PREVIEWS\"\n\texit 0\nelif [ \"${ACTION}\" == \"install\" ]; then\n\techo \"note: skipping skip due to archive install\"\n\texit 0\nelse\n\tSKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
499CD43A2AC5B799001AE8D8 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
499CD43B2AC5B799001AE8D8 /* SatsPriceAppMain.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
499CD4422AC5B799001AE8D8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
499CD4432AC5B799001AE8D8 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEVELOPMENT_TEAM = S99A5B637C;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = SatsPrice;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
49F90C4B2A52156300F06D93 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* SatsPrice.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
49F90C4C2A52156300F06D93 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* SatsPrice.xcconfig */;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "SatsPrice" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
499CD4422AC5B799001AE8D8 /* Debug */,
|
||||
499CD4432AC5B799001AE8D8 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
49F90C232A52156200F06D93 /* Build configuration list for PBXProject "SatsPrice" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
49F90C4B2A52156300F06D93 /* Debug */,
|
||||
49F90C4C2A52156300F06D93 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
49231BAB2AC5BCEF00F98ADF /* SatsPriceApp */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = SatsPriceApp;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 49F90C202A52156200F06D93 /* Project object */;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,10 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
|
||||
import SwiftUI
|
||||
import SatsPrice
|
||||
|
||||
/// The entry point to the app simply loads the App implementation from SPM module.
|
||||
@main struct AppMain: App, SatsPriceApp {
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// Additional properties included by the Fastfile build_app
|
||||
|
||||
// This file can be used to override various properties from Skip.env
|
||||
//PRODUCT_BUNDLE_IDENTIFIER =
|
||||
//DEVELOPMENT_TEAM =
|
||||
@@ -1,8 +0,0 @@
|
||||
# For more information about the Appfile, see:
|
||||
# https://docs.fastlane.tools/advanced/#appfile
|
||||
|
||||
require('dotenv')
|
||||
Dotenv.load '../../Skip.env'
|
||||
#app_identifier(ENV['PRODUCT_BUNDLE_IDENTIFIER'])
|
||||
|
||||
# apple_id("my@email")
|
||||
@@ -1,28 +0,0 @@
|
||||
|
||||
copyright "#{Time.now.year}"
|
||||
default_language("en-US")
|
||||
|
||||
force(true) # Skip HTML report verification
|
||||
automatic_release(true)
|
||||
skip_screenshots(false)
|
||||
precheck_include_in_app_purchases(false)
|
||||
|
||||
#skip_binary_upload(true)
|
||||
submit_for_review(true)
|
||||
|
||||
submission_information({
|
||||
add_id_info_serves_ads: false,
|
||||
add_id_info_uses_idfa: false,
|
||||
add_id_info_tracks_install: false,
|
||||
add_id_info_tracks_action: false,
|
||||
add_id_info_limits_tracking: false,
|
||||
content_rights_has_rights: false,
|
||||
content_rights_contains_third_party_content: false,
|
||||
export_compliance_contains_third_party_cryptography: false,
|
||||
export_compliance_encryption_updated: false,
|
||||
export_compliance_platform: 'ios',
|
||||
export_compliance_compliance_required: false,
|
||||
export_compliance_uses_encryption: false,
|
||||
export_compliance_is_exempt: false,
|
||||
export_compliance_contains_proprietary_cryptography: false
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# for the iOS half of the Skip app.
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
lane :assemble do |options|
|
||||
# only build the iOS side of the app
|
||||
ENV["SKIP_ZERO"] = "true"
|
||||
build_app(
|
||||
sdk: "iphoneos",
|
||||
xcconfig: "fastlane/AppStore.xcconfig",
|
||||
xcargs: "-skipPackagePluginValidation -skipMacroValidation",
|
||||
derived_data_path: "../.build/Darwin/DerivedData",
|
||||
output_directory: "../.build/fastlane/Darwin",
|
||||
skip_archive: ENV["FASTLANE_SKIP_ARCHIVE"] == "YES",
|
||||
skip_codesigning: ENV["FASTLANE_SKIP_CODESIGNING"] == "YES"
|
||||
)
|
||||
end
|
||||
|
||||
lane :release do |options|
|
||||
desc "Build and release app"
|
||||
|
||||
assemble
|
||||
|
||||
upload_to_app_store(
|
||||
api_key_path: "fastlane/apikey.json",
|
||||
app_rating_config_path: "fastlane/metadata/rating.json",
|
||||
release_notes: { default: "Fixes and improvements." }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
A great new app built with Skip!
|
||||
@@ -1 +0,0 @@
|
||||
app,key,words
|
||||
@@ -1 +0,0 @@
|
||||
https://example.org/privacy/
|
||||
@@ -1 +0,0 @@
|
||||
Bug fixes and performance improvements.
|
||||
@@ -1 +0,0 @@
|
||||
https://example.org/app/
|
||||
@@ -1 +0,0 @@
|
||||
A new Skip app
|
||||
@@ -1 +0,0 @@
|
||||
https://example.org/support/
|
||||
@@ -1 +0,0 @@
|
||||
SatsPrice
|
||||
@@ -1 +0,0 @@
|
||||
New features and better performance.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"alcoholTobaccoOrDrugUseOrReferences": "NONE",
|
||||
"contests": "NONE",
|
||||
"gamblingSimulated": "NONE",
|
||||
"horrorOrFearThemes": "NONE",
|
||||
"matureOrSuggestiveThemes": "NONE",
|
||||
"medicalOrTreatmentInformation": "NONE",
|
||||
"profanityOrCrudeHumor": "NONE",
|
||||
"sexualContentGraphicAndNudity": "NONE",
|
||||
"sexualContentOrNudity": "NONE",
|
||||
"violenceCartoonOrFantasy": "NONE",
|
||||
"violenceRealisticProlongedGraphicOrSadistic": "NONE",
|
||||
"violenceRealistic": "NONE",
|
||||
"gambling": false,
|
||||
"seventeenPlus": false,
|
||||
"unrestrictedWebAccess": false
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
// This is a Skip (https://skip.tools) package,
|
||||
// containing a Swift Package Manager project
|
||||
// that will use the Skip build plugin to transpile the
|
||||
// Swift Package, Sources, and Tests into an
|
||||
// Android Gradle Project with Kotlin sources and JUnit tests.
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "sats-price",
|
||||
defaultLocalization: "en",
|
||||
platforms: [.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9), .macCatalyst(.v16)],
|
||||
products: [
|
||||
.library(name: "SatsPriceApp", type: .dynamic, targets: ["SatsPrice"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://source.skip.tools/skip.git", from: "1.0.7"),
|
||||
.package(url: "https://source.skip.tools/skip-ui.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-foundation.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-model.git", from: "1.0.0"),
|
||||
.package(url: "https://source.skip.tools/skip-sql.git", "0.0.0"..<"2.0.0")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "SatsPrice",
|
||||
dependencies: [
|
||||
.product(name: "SkipUI", package: "skip-ui"),
|
||||
.product(name: "SkipFoundation", package: "skip-foundation"),
|
||||
.product(name: "SkipModel", package: "skip-model"),
|
||||
.product(name: "SkipSQLPlus", package: "skip-sql")
|
||||
],
|
||||
resources: [.process("Resources")],
|
||||
plugins: [.plugin(name: "skipstone", package: "skip")]
|
||||
),
|
||||
.testTarget(name: "SatsPriceTests", dependencies: ["SatsPrice", .product(name: "SkipTest", package: "skip")], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
|
||||
]
|
||||
)
|
||||
@@ -29,53 +29,55 @@ height="70">](https://github.com/tyiu/sats-price/releases)
|
||||
|
||||
iOS 16.0+ • macOS 13.0+ • Android 10.0+
|
||||
|
||||
</div>
|
||||
## Kotlin Multiplatform
|
||||
|
||||
## Building
|
||||
This is a Kotlin Multiplatform project targeting Android, iOS, Web, Desktop (JVM).
|
||||
|
||||
This is a free [Skip](https://skip.tools) dual-platform app project.
|
||||
It builds a native app for both iOS and Android.
|
||||
* [/iosApp](./iosApp/iosApp) contains an iOS application. Even if you’re sharing your UI with Compose Multiplatform, you
|
||||
need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.
|
||||
|
||||
This project is both a stand-alone Swift Package Manager module,
|
||||
as well as an Xcode project that builds and transpiles the project
|
||||
into a Kotlin Gradle project for Android using the Skip plugin.
|
||||
* [/shared](./shared/src) is for code that will be shared across your Compose Multiplatform applications. It contains
|
||||
several subfolders:
|
||||
- [commonMain](./shared/src/commonMain/kotlin) is for code that’s common for all targets.
|
||||
- Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For
|
||||
example, if you want to use Apple’s CoreCrypto for the iOS part of your Kotlin app,
|
||||
the [iosMain](./shared/src/iosMain/kotlin) folder would be the right place for such calls. Similarly, if you want
|
||||
to edit the Desktop (JVM) specific part, the [jvmMain](./shared/src/jvmMain/kotlin)
|
||||
folder is the appropriate location.
|
||||
|
||||
Building the module requires that Skip be installed using
|
||||
[Homebrew](https://brew.sh) with `brew install skiptools/skip/skip`.
|
||||
### Running the apps
|
||||
|
||||
This will also install the necessary transpiler prerequisites:
|
||||
Kotlin, Gradle, and the Android build tools.
|
||||
Use the run configurations provided by the run widget in your IDE's toolbar. You can also use these commands and
|
||||
options:
|
||||
|
||||
Installation prerequisites can be confirmed by running `skip checkup`.
|
||||
- Android app: `./gradlew :androidApp:assembleDebug`
|
||||
- Desktop app:
|
||||
- Hot reload: `./gradlew :desktopApp:hotRun --auto`
|
||||
- Standard run: `./gradlew :desktopApp:run`
|
||||
- Web app:
|
||||
- Wasm target (faster, modern browsers): `./gradlew :webApp:wasmJsBrowserDevelopmentRun`
|
||||
- JS target (slower, supports older browsers): `./gradlew :webApp:jsBrowserDevelopmentRun`
|
||||
- iOS app: open the [/iosApp](./iosApp) directory in Xcode and run it from there.
|
||||
|
||||
## Testing
|
||||
### Running tests
|
||||
|
||||
The module can be tested using the standard `swift test` command
|
||||
or by running the test target for the macOS destination in Xcode,
|
||||
which will run the Swift tests as well as the transpiled
|
||||
Kotlin JUnit tests in the Robolectric Android simulation environment.
|
||||
Use the run button in your IDE's editor gutter, or run tests using Gradle tasks:
|
||||
|
||||
Parity testing can be performed with `skip test`,
|
||||
which will output a table of the test results for both platforms.
|
||||
|
||||
## Running
|
||||
|
||||
Xcode and Android Studio must be downloaded and installed in order to
|
||||
run the app in the iOS simulator / Android emulator.
|
||||
An Android emulator must already be running, which can be launched from
|
||||
Android Studio's Device Manager.
|
||||
|
||||
To run both the Swift and Kotlin apps simultaneously,
|
||||
launch the SatsPriceApp target from Xcode.
|
||||
A build phases runs the "Launch Android APK" script that
|
||||
will deploy the transpiled app a running Android emulator or connected device.
|
||||
Logging output for the iOS app can be viewed in the Xcode console, and in
|
||||
Android Studio's logcat tab for the transpiled Kotlin app.
|
||||
- Android tests: `./gradlew :shared:testAndroidHostTest`
|
||||
- Desktop tests: `./gradlew :shared:jvmTest`
|
||||
- Web tests:
|
||||
- Wasm target: `./gradlew :shared:wasmJsTest`
|
||||
- JS target: `./gradlew :shared:jsTest`
|
||||
- iOS tests: `./gradlew :shared:iosSimulatorArm64Test`
|
||||
|
||||
## Attribution
|
||||
|
||||
This project depends on [Skip](https://skip.tools) to build as a multi-platform app.
|
||||
|
||||
This project uses [Kotlin Multiplatform](https://www.jetbrains.com/help/kotlin-multiplatform-dev/get-started.html),
|
||||
[Compose Multiplatform](https://github.com/JetBrains/compose-multiplatform/#compose-multiplatform), and
|
||||
[Kotlin/Wasm](https://kotl.in/wasm/).
|
||||
|
||||
The [Bitcoin Calculator](https://www.flaticon.com/free-icons/bitcoin-calculator) icon was created by Icon home and licensed as free for personal and commercial use with attribution.
|
||||
|
||||
The following free APIs are used:
|
||||
@@ -84,3 +86,5 @@ The following free APIs are used:
|
||||
- [Get Spot Price](https://docs.cdp.coinbase.com/coinbase-app/docs/api-prices#get-spot-price)
|
||||
- CoinGecko
|
||||
- [Coin Price by IDs](https://docs.coingecko.com/reference/simple-price)
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,20 +0,0 @@
|
||||
// The configuration file for your Skip App (https://skip.tools).
|
||||
// Properties specified here are shared between
|
||||
// Darwin/SatsPrice.xcconfig and Android/settings.gradle.kts
|
||||
// and will be included in the app's metadata files
|
||||
// Info.plist and AndroidManifest.xml
|
||||
|
||||
// PRODUCT_NAME is the default title of the app, which must match the app's Swift module name
|
||||
PRODUCT_NAME = SatsPrice
|
||||
|
||||
// PRODUCT_BUNDLE_IDENTIFIER is the unique id for both the iOS and Android app
|
||||
PRODUCT_BUNDLE_IDENTIFIER = xyz.tyiu.SatsPrice
|
||||
|
||||
// The semantic version of the app
|
||||
MARKETING_VERSION = 1.2.0
|
||||
|
||||
// The build number specifying the internal app version
|
||||
CURRENT_PROJECT_VERSION = 10
|
||||
|
||||
// The package name for the Android entry point, referenced by the AndroidManifest.xml
|
||||
ANDROID_PACKAGE_NAME = sats.price
|
||||
@@ -1,139 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
public struct ContentView: View {
|
||||
let model: SatsPriceModel
|
||||
|
||||
@StateObject private var satsViewModel: SatsViewModel
|
||||
|
||||
private let dateFormatter: DateFormatter
|
||||
|
||||
init(model: SatsPriceModel) {
|
||||
self.model = model
|
||||
|
||||
_satsViewModel = StateObject<SatsViewModel>(wrappedValue: SatsViewModel(model: model))
|
||||
|
||||
dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
}
|
||||
|
||||
public var addCurrencyView: some View {
|
||||
NavigationLink(
|
||||
destination: {
|
||||
CurrencyPickerView(satsViewModel: satsViewModel)
|
||||
},
|
||||
label: {
|
||||
Text("Change Currencies")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Price Source", selection: $satsViewModel.priceSource) {
|
||||
ForEach(PriceSource.allCases, id: \.self) {
|
||||
Text($0.description)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("1 BTC to \(satsViewModel.currentCurrency.identifier)", text: satsViewModel.btcToCurrencyString(for: satsViewModel.currentCurrency))
|
||||
.disabled(satsViewModel.priceSource != .manual)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
if satsViewModel.priceSource != .manual {
|
||||
Button(action: {
|
||||
Task {
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "arrow.clockwise.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("1 BTC to \(satsViewModel.currentCurrency.identifier)")
|
||||
} footer: {
|
||||
if satsViewModel.priceSource != .manual, let lastUpdated = satsViewModel.lastUpdated {
|
||||
Text("Last updated: \(dateFormatter.string(from: lastUpdated))")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Sats")
|
||||
TextField("Sats", text: $satsViewModel.satsString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("BTC")
|
||||
TextField("BTC", text: $satsViewModel.btcString)
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
} footer: {
|
||||
if satsViewModel.exceedsMaximum {
|
||||
Text("\(SatsViewModel.MAXIMUM_BTC.formatBTCString()) BTC is the maximum.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text(satsViewModel.currentCurrency.identifier)
|
||||
TextField(satsViewModel.currentCurrency.identifier, text: satsViewModel.currencyValueString(for: satsViewModel.currentCurrency))
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
|
||||
if satsViewModel.priceSource != .manual {
|
||||
ForEach(satsViewModel.selectedCurrencies.sorted { $0.identifier < $1.identifier }.filter { $0 != satsViewModel.currentCurrency }, id: \.identifier) { currency in
|
||||
HStack {
|
||||
Text(currency.identifier)
|
||||
TextField(currency.identifier, text: satsViewModel.currencyValueString(for: currency))
|
||||
#if os(iOS) || SKIP
|
||||
.keyboardType(.decimalPad)
|
||||
#endif
|
||||
}
|
||||
.tag(currency.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if satsViewModel.priceSource != .manual {
|
||||
addCurrencyView
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await satsViewModel.pullSelectedCurrenciesFromDB()
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
.onChange(of: satsViewModel.priceSource) { newPriceSource in
|
||||
satsViewModel.lastUpdated = nil
|
||||
Task {
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.formStyle(.grouped)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let satsPriceModel = try! SatsPriceModel(url: nil)
|
||||
ContentView(model: satsPriceModel)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// CurrencyPickerView.swift
|
||||
// sats-price
|
||||
//
|
||||
// Created by Terry Yiu on 11/10/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CurrencyPickerView: View {
|
||||
@ObservedObject var satsViewModel: SatsViewModel
|
||||
|
||||
var body: some View {
|
||||
let currentCurrency = satsViewModel.currentCurrency
|
||||
|
||||
List {
|
||||
Section("Current Currency") {
|
||||
let currentCurrency = satsViewModel.currentCurrency
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currentCurrency.identifier) {
|
||||
Text("\(currentCurrency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currentCurrency.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
if !satsViewModel.selectedCurrencies.isEmpty {
|
||||
Section("Selected Currencies") {
|
||||
ForEach(satsViewModel.selectedCurrencies.filter { $0 != currentCurrency }.sorted { $0.identifier < $1.identifier }, id: \.identifier) { currency in
|
||||
Button(
|
||||
action: {
|
||||
satsViewModel.removeSelectedCurrency(currency)
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Group {
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
|
||||
Text("\(currency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currency.identifier)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Currencies") {
|
||||
ForEach(satsViewModel.currencies.filter { $0 != currentCurrency && !satsViewModel.selectedCurrencies.contains($0) }, id: \.identifier) { currency in
|
||||
Button(
|
||||
action: {
|
||||
satsViewModel.addSelectedCurrency(currency)
|
||||
},
|
||||
label: {
|
||||
if let localizedCurrency = Locale.current.localizedString(forCurrencyCode: currency.identifier) {
|
||||
Text("\(currency.identifier) - \(localizedCurrency)")
|
||||
} else {
|
||||
Text(currency.identifier)
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear(perform: {
|
||||
Task {
|
||||
await satsViewModel.updatePrice()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let satsPriceModel = try! SatsPriceModel(url: nil)
|
||||
CurrencyPickerView(satsViewModel: SatsViewModel(model: satsPriceModel))
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// SatsPriceModel.swift
|
||||
// sats-price
|
||||
//
|
||||
// Created by Terry Yiu on 11/15/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Observation
|
||||
import SkipSQL
|
||||
|
||||
public struct SelectedCurrency: Identifiable {
|
||||
public var id: String {
|
||||
currencyCode
|
||||
}
|
||||
|
||||
public var currencyCode: String
|
||||
}
|
||||
|
||||
/// Notification posted by the model when selected currencies change.
|
||||
extension Notification.Name {
|
||||
public static var selectedCurrenciesDidChange: Notification.Name {
|
||||
return Notification.Name("selectedCurrenciesChange")
|
||||
}
|
||||
}
|
||||
|
||||
/// Payload of `selectedCurrenciesDidChange` notifications.
|
||||
public struct SelectedCurrenciesChange {
|
||||
public let inserts: [SelectedCurrency]
|
||||
/// Nil set means all records were deleted.
|
||||
public let deletes: Set<String>?
|
||||
|
||||
public init(inserts: [SelectedCurrency] = [], deletes: [String]? = []) {
|
||||
self.inserts = inserts
|
||||
self.deletes = deletes == nil ? nil : Set(deletes!)
|
||||
}
|
||||
}
|
||||
|
||||
public actor SatsPriceModel {
|
||||
private let ctx: SQLContext
|
||||
private var schemaInitializationResult: Result<Void, Error>?
|
||||
|
||||
public init(url: URL?) throws {
|
||||
ctx = try SQLContext(path: url?.path ?? ":memory:", flags: [.readWrite, .create], logLevel: .info, configuration: .platform)
|
||||
}
|
||||
|
||||
public func selectedCurrencies() throws -> [SelectedCurrency] {
|
||||
do {
|
||||
try initializeSchema()
|
||||
let statement = try ctx.prepare(sql: "SELECT currencyCode FROM SelectedCurrency")
|
||||
defer {
|
||||
do {
|
||||
try statement.close()
|
||||
} catch {
|
||||
logger.warning("Failed to close statement: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var selectedCurrencies: [SelectedCurrency] = []
|
||||
|
||||
while try statement.next() {
|
||||
let currencyCode = statement.string(at: 0) ?? ""
|
||||
selectedCurrencies.append(SelectedCurrency(currencyCode: currencyCode))
|
||||
}
|
||||
|
||||
return selectedCurrencies
|
||||
} catch {
|
||||
logger.error("Failed to get selected currencies from DB. Error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func insert(_ selectedCurrency: SelectedCurrency) throws -> [SelectedCurrency] {
|
||||
try initializeSchema()
|
||||
let statement = try ctx.prepare(sql: "INSERT INTO SelectedCurrency (currencyCode) VALUES (?)")
|
||||
defer {
|
||||
do {
|
||||
try statement.close()
|
||||
} catch {
|
||||
logger.warning("Failed to close statement: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
var insertedItems: [SelectedCurrency] = []
|
||||
try ctx.transaction {
|
||||
statement.reset()
|
||||
let values = Self.bindingValues(for: selectedCurrency)
|
||||
try statement.update(parameters: values)
|
||||
|
||||
insertedItems.append(selectedCurrency)
|
||||
}
|
||||
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(inserts: insertedItems))
|
||||
return insertedItems
|
||||
}
|
||||
|
||||
private static func bindingValues(for selectedCurrency: SelectedCurrency) -> [SQLValue] {
|
||||
return [
|
||||
.text(selectedCurrency.currencyCode)
|
||||
]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func deleteSelectedCurrency(currencyCode: String) throws -> Int {
|
||||
try initializeSchema()
|
||||
try ctx.exec(sql: "DELETE FROM SelectedCurrency WHERE currencyCode = ?", parameters: [.text(currencyCode)])
|
||||
NotificationCenter.default.post(name: .selectedCurrenciesDidChange, object: SelectedCurrenciesChange(deletes: [currencyCode]))
|
||||
return Int(ctx.changes)
|
||||
}
|
||||
|
||||
private func initializeSchema() throws {
|
||||
switch schemaInitializationResult {
|
||||
case .success:
|
||||
return
|
||||
case .failure(let failure):
|
||||
throw failure
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
|
||||
do {
|
||||
var currentVersion = try currentSchemaVersion()
|
||||
currentVersion = try migrateSchema(v: Int64(1), current: currentVersion, ddl: """
|
||||
CREATE TABLE SelectedCurrency (currencyCode TEXT PRIMARY KEY NOT NULL)
|
||||
""")
|
||||
// Future column additions, etc here...
|
||||
schemaInitializationResult = .success(())
|
||||
} catch {
|
||||
schemaInitializationResult = .failure(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func currentSchemaVersion() throws -> Int64 {
|
||||
try ctx.exec(sql: "CREATE TABLE IF NOT EXISTS SchemaVersion (id INTEGER PRIMARY KEY, version INTEGER)")
|
||||
try ctx.exec(sql: "INSERT OR IGNORE INTO SchemaVersion (id, version) VALUES (0, 0)")
|
||||
return try ctx.query(sql: "SELECT version FROM SchemaVersion").first?.first?.integerValue ?? Int64(0)
|
||||
}
|
||||
|
||||
private func migrateSchema(v version: Int64, current: Int64, ddl: String) throws -> Int64 {
|
||||
guard current < version else {
|
||||
return current
|
||||
}
|
||||
let startTime = Date.now
|
||||
try ctx.transaction {
|
||||
try ctx.exec(sql: ddl)
|
||||
try ctx.exec(sql: "UPDATE SchemaVersion SET version = ?", parameters: [.integer(version)])
|
||||
}
|
||||
logger.log("Updated database schema to \(version) in \(Date.now.timeIntervalSince1970 - startTime.timeIntervalSince1970)")
|
||||
return version
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// CoinGeckoPriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private struct CoinGeckoPriceResponse: Codable {
|
||||
#if !SKIP
|
||||
let bitcoin: [String: Decimal]
|
||||
#else
|
||||
let bitcoin: [String: String]
|
||||
#endif
|
||||
}
|
||||
|
||||
class CoinGeckoPriceFetcher : PriceFetcher {
|
||||
func urlString(toCurrency currency: Locale.Currency) -> String {
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currency.identifier.lowercased())&precision=18"
|
||||
}
|
||||
|
||||
func urlString(toCurrencies currencies: [Locale.Currency]) -> String {
|
||||
let currenciesString = currencies.map { $0.identifier.lowercased() }.joined(separator: ",")
|
||||
return "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=\(currenciesString)&precision=18"
|
||||
}
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data)
|
||||
guard let price = priceResponse.bitcoin[currency.identifier.lowercased()] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
return price
|
||||
#else
|
||||
return Decimal(price)
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
do {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
if currencies.count == 1, let currency = currencies.first {
|
||||
guard let price = try await convertBTC(toCurrency: currency) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [currency: price]
|
||||
}
|
||||
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrencies: currencies)), let url = urlComponents.url else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let priceResponse = try JSONDecoder().decode(CoinGeckoPriceResponse.self, from: data)
|
||||
|
||||
var results: [Locale.Currency : Decimal] = [:]
|
||||
for currency in currencies {
|
||||
if let price = priceResponse.bitcoin[currency.identifier.lowercased()] {
|
||||
#if !SKIP
|
||||
results[currency] = price
|
||||
#else
|
||||
results[currency] = Decimal(price)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// CoinbasePriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private struct CoinbasePriceResponse: Codable {
|
||||
let data: CoinbasePrice
|
||||
}
|
||||
|
||||
private struct CoinbasePrice: Codable {
|
||||
let amount: String
|
||||
let base: String
|
||||
let currency: String
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponse: Codable {
|
||||
let data: CoinbaseExchangeRatesResponseData
|
||||
}
|
||||
|
||||
private struct CoinbaseExchangeRatesResponseData: Codable {
|
||||
let currency: String
|
||||
let rates: [String: String]
|
||||
}
|
||||
|
||||
class CoinbasePriceFetcher : PriceFetcher {
|
||||
func urlString(toCurrency currency: Locale.Currency) -> String {
|
||||
"https://api.coinbase.com/v2/prices/BTC-\(currency.identifier)/spot"
|
||||
}
|
||||
|
||||
private static let urlStringForAllCurrencies: String = "https://api.coinbase.com/v2/exchange-rates?currency=BTC"
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
do {
|
||||
guard let urlComponents = URLComponents(string: urlString(toCurrency: currency)), let url = urlComponents.url else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let coinbasePriceResponse = try JSONDecoder().decode(CoinbasePriceResponse.self, from: data)
|
||||
let coinbasePrice = coinbasePriceResponse.data
|
||||
|
||||
guard coinbasePrice.base == "BTC" && coinbasePrice.currency == currency.identifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
return Decimal(string: coinbasePrice.amount)
|
||||
#else
|
||||
return Decimal(coinbasePrice.amount)
|
||||
#endif
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
do {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
if currencies.count == 1, let currency = currencies.first {
|
||||
guard let price = try await convertBTC(toCurrency: currency) else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [currency: price]
|
||||
}
|
||||
|
||||
guard let urlComponents = URLComponents(string: CoinbasePriceFetcher.urlStringForAllCurrencies), let url = urlComponents.url else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(from: url, delegate: nil)
|
||||
|
||||
let coinbaseExchangeRatesResponse = try JSONDecoder().decode(CoinbaseExchangeRatesResponse.self, from: data)
|
||||
let rates = coinbaseExchangeRatesResponse.data.rates
|
||||
|
||||
guard coinbaseExchangeRatesResponse.data.currency == "BTC" else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var results: [Locale.Currency : Decimal] = [:]
|
||||
for currency in currencies {
|
||||
if let price = rates[currency.identifier] {
|
||||
#if !SKIP
|
||||
results[currency] = Decimal(string: price)
|
||||
#else
|
||||
results[currency] = Decimal(price)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// FakePriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/21/24.
|
||||
//
|
||||
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class FakePriceFetcher: PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
randomPrice()
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let prices = currencies.map { _ in randomPrice() }
|
||||
return Dictionary(uniqueKeysWithValues: zip(currencies, prices))
|
||||
}
|
||||
|
||||
private func randomPrice() -> Decimal {
|
||||
Decimal(Double.random(in: 10000...100000))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,30 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// ManualPriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 8/29/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Fake price fetcher that returns a randomized price. Useful for development testing without requiring a network call.
|
||||
class ManualPriceFetcher: PriceFetcher {
|
||||
var prices: [Locale.Currency: Decimal] = [:]
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
prices[currency]
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
guard !currencies.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let filteredCurrencies = currencies.filter { prices.keys.contains($0) }
|
||||
let priceValues = filteredCurrencies.map { prices[$0, default: Decimal(0)] }
|
||||
return Dictionary(uniqueKeysWithValues: zip(filteredCurrencies, priceValues))
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// PriceFetcher.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol PriceFetcher {
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal?
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency: Decimal]
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// PriceFetcherDelegator.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/20/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class PriceFetcherDelegator: PriceFetcher {
|
||||
private let coinbasePriceFetcher = CoinbasePriceFetcher()
|
||||
private let coinGeckoPriceFetcher = CoinGeckoPriceFetcher()
|
||||
private let manualPriceFetcher = ManualPriceFetcher()
|
||||
#if DEBUG
|
||||
private let fakePriceFetcher = FakePriceFetcher()
|
||||
#endif
|
||||
|
||||
var priceSource: PriceSource
|
||||
|
||||
init(_ priceSource: PriceSource) {
|
||||
self.priceSource = priceSource
|
||||
}
|
||||
|
||||
private var delegate: PriceFetcher {
|
||||
switch priceSource {
|
||||
case .coinbase:
|
||||
coinbasePriceFetcher
|
||||
case .coingecko:
|
||||
coinGeckoPriceFetcher
|
||||
case .manual:
|
||||
manualPriceFetcher
|
||||
#if DEBUG
|
||||
case .fake:
|
||||
fakePriceFetcher
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func convertBTC(toCurrency currency: Locale.Currency) async throws -> Decimal? {
|
||||
return try await delegate.convertBTC(toCurrency: currency)
|
||||
}
|
||||
|
||||
func convertBTC(toCurrencies currencies: [Locale.Currency]) async throws -> [Locale.Currency : Decimal] {
|
||||
return try await delegate.convertBTC(toCurrencies: currencies)
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// PriceSource.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/20/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PriceSource: CaseIterable, CustomStringConvertible {
|
||||
|
||||
static var allCases: [PriceSource] {
|
||||
#if DEBUG
|
||||
[.coinbase, .coingecko, .manual, .fake]
|
||||
#else
|
||||
[.coinbase, .coingecko, .manual]
|
||||
#endif
|
||||
}
|
||||
|
||||
case coinbase
|
||||
case coingecko
|
||||
case manual
|
||||
|
||||
#if DEBUG
|
||||
case fake
|
||||
#endif
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .coinbase:
|
||||
"Coinbase"
|
||||
case .coingecko:
|
||||
"CoinGecko"
|
||||
case .manual:
|
||||
"Manual"
|
||||
#if DEBUG
|
||||
case .fake:
|
||||
"Fake"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"" : {
|
||||
|
||||
},
|
||||
"%@ - %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ - %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ BTC is the maximum." : {
|
||||
|
||||
},
|
||||
"1 BTC to %@" : {
|
||||
|
||||
},
|
||||
"BTC" : {
|
||||
|
||||
},
|
||||
"Change Currencies" : {
|
||||
|
||||
},
|
||||
"Currencies" : {
|
||||
|
||||
},
|
||||
"Current Currency" : {
|
||||
|
||||
},
|
||||
"Last updated: %@" : {
|
||||
|
||||
},
|
||||
"Price Source" : {
|
||||
|
||||
},
|
||||
"Sats" : {
|
||||
|
||||
},
|
||||
"Selected Currencies" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
|
||||
public class SatsPriceModule {
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
let logger: Logger = Logger(subsystem: "xyz.tyiu.SatsPrice", category: "SatsPrice")
|
||||
|
||||
/// The Android SDK number we are running against, or `nil` if not running on Android
|
||||
let androidSDK = ProcessInfo.processInfo.environment["android.os.Build.VERSION.SDK_INT"].flatMap({ Int($0) })
|
||||
|
||||
/// The shared data model.
|
||||
private let model = try! SatsPriceModel(url: URL.documentsDirectory.appendingPathComponent("satsprice.sqlite"))
|
||||
|
||||
/// The shared top-level view for the app, loaded from the platform-specific App delegates below.
|
||||
///
|
||||
/// The default implementation merely loads the `ContentView` for the app and logs a message.
|
||||
public struct RootView : View {
|
||||
public init() {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ContentView(model: model)
|
||||
.task {
|
||||
logger.log("Welcome to Skip on \(androidSDK != nil ? "Android" : "Darwin")!")
|
||||
logger.warning("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
public protocol SatsPriceApp : App {
|
||||
}
|
||||
|
||||
/// The entry point to the SatsPrice app.
|
||||
/// The concrete implementation is in the SatsPriceApp module.
|
||||
public extension SatsPriceApp {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,425 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
//
|
||||
// SatsViewModel.swift
|
||||
// SatsPrice
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class SatsViewModel: ObservableObject {
|
||||
static let MAXIMUM_BTC = Decimal(21000000)
|
||||
|
||||
private static let SATS_IN_BTC = Decimal(100000000)
|
||||
|
||||
let model: SatsPriceModel
|
||||
|
||||
@Published var lastUpdated: Date?
|
||||
|
||||
@Published var priceSourceInternal: PriceSource = .coinbase
|
||||
let priceFetcherDelegator = PriceFetcherDelegator(.coinbase)
|
||||
|
||||
@Published var satsStringInternal: String = ""
|
||||
@Published var btcStringInternal: String = ""
|
||||
@Published var selectedCurrencies = Set<Locale.Currency>()
|
||||
@Published var currencyValueStrings: [Locale.Currency: String] = [:]
|
||||
|
||||
@Published var currencyPrices: [Locale.Currency: Decimal] = [:]
|
||||
@Published var currencyPriceStrings: [Locale.Currency: String] = [:]
|
||||
|
||||
let currentCurrency: Locale.Currency = Locale.current.currency ?? Locale.Currency("USD")
|
||||
|
||||
init(model: SatsPriceModel) {
|
||||
self.model = model
|
||||
}
|
||||
|
||||
var currencies: [Locale.Currency] {
|
||||
let commonISOCurrencyCodes = Set(Locale.commonISOCurrencyCodes)
|
||||
if commonISOCurrencyCodes.contains(currentCurrency.identifier) {
|
||||
return Locale.commonISOCurrencyCodes.map { Locale.Currency($0) }
|
||||
} else {
|
||||
var commonAndCurrentCurrencies = Locale.commonISOCurrencyCodes
|
||||
commonAndCurrentCurrencies.append(currentCurrency.identifier)
|
||||
commonAndCurrentCurrencies.sort()
|
||||
return commonAndCurrentCurrencies.map { Locale.Currency($0) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func pullSelectedCurrenciesFromDB() async {
|
||||
do {
|
||||
let selectedCurrencies = Set(try await model.selectedCurrencies().compactMap { Locale.Currency($0.currencyCode) })
|
||||
let currenciesToAdd = selectedCurrencies.subtracting(self.selectedCurrencies)
|
||||
let currenciesToRemove = self.selectedCurrencies.subtracting(selectedCurrencies)
|
||||
|
||||
self.selectedCurrencies.subtract(currenciesToRemove)
|
||||
self.selectedCurrencies.formUnion(currenciesToAdd)
|
||||
} catch {
|
||||
logger.error("Unable to pull selected currencies from DB. Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func addSelectedCurrency(_ currency: Locale.Currency) {
|
||||
selectedCurrencies.insert(currency)
|
||||
Task {
|
||||
try await model.insert(SelectedCurrency(currencyCode: currency.identifier))
|
||||
}
|
||||
}
|
||||
|
||||
func removeSelectedCurrency(_ currency: Locale.Currency) {
|
||||
selectedCurrencies.remove(currency)
|
||||
Task {
|
||||
try await model.deleteSelectedCurrency(currencyCode: currency.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
var priceSource: PriceSource {
|
||||
get {
|
||||
priceSourceInternal
|
||||
}
|
||||
set {
|
||||
priceSourceInternal = newValue
|
||||
priceFetcherDelegator.priceSource = newValue
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updatePrice() async {
|
||||
do {
|
||||
let currencies = Set([currentCurrency] + selectedCurrencies)
|
||||
let prices = try await priceFetcherDelegator.convertBTC(toCurrencies: Array(currencies))
|
||||
|
||||
currencyPrices = prices
|
||||
updateCurrencyPriceStrings()
|
||||
updateCurrencyValueStrings()
|
||||
} catch {
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
lastUpdated = Date.now
|
||||
}
|
||||
|
||||
func updateCurrencyPriceStrings() {
|
||||
currencyPriceStrings = Dictionary(
|
||||
uniqueKeysWithValues: currencyPrices.map { ($0.key, $0.value.formatString(currency: $0.key)) }
|
||||
)
|
||||
}
|
||||
|
||||
private func priceWithoutGroupingSeparator(_ priceString: String) -> String {
|
||||
let numberFormatter = NumberFormatter()
|
||||
numberFormatter.numberStyle = .decimal
|
||||
let decimalSeparator = numberFormatter.decimalSeparator
|
||||
|
||||
return priceString.filter {
|
||||
$0.isDigit || String($0) == decimalSeparator
|
||||
}
|
||||
}
|
||||
|
||||
var satsString: String {
|
||||
get {
|
||||
satsStringInternal
|
||||
}
|
||||
set {
|
||||
let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
|
||||
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
satsStringInternal = newPriceWithoutGroupingSeparator
|
||||
|
||||
if let sats {
|
||||
#if !SKIP
|
||||
// Formatting the internal string after modifying it only if the platform is Apple.
|
||||
// Apple does not seem to call get after set until after focus is moved to a different component.
|
||||
// Android, on the other hand, does call get immediately after set,
|
||||
// which causes text entry issues if the user keeps on entering input.
|
||||
satsStringInternal = sats.formatSatsString()
|
||||
|
||||
let btc = sats / SatsViewModel.SATS_IN_BTC
|
||||
#else
|
||||
let btc = sats.divide(SatsViewModel.SATS_IN_BTC, 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
btcStringInternal = btc.formatBTCString()
|
||||
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
btcStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btcString: String {
|
||||
get {
|
||||
btcStringInternal
|
||||
}
|
||||
set {
|
||||
let oldPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
|
||||
let newPriceWithoutGroupingSeparator = priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
btcStringInternal = newPriceWithoutGroupingSeparator
|
||||
|
||||
if let btc {
|
||||
#if !SKIP
|
||||
// Formatting the internal string after modifying it only if the platform is Apple.
|
||||
// Apple does not seem to call get after set until after focus is moved to a different component.
|
||||
// Android, on the other hand, does call get immediately after set,
|
||||
// which causes text entry issues if the user keeps on entering input.
|
||||
btcStringInternal = btc.formatBTCString()
|
||||
#endif
|
||||
|
||||
let sats = btc * SatsViewModel.SATS_IN_BTC
|
||||
satsStringInternal = sats.formatSatsString()
|
||||
|
||||
updateCurrencyValueStrings()
|
||||
} else {
|
||||
satsStringInternal = ""
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateCurrencyValueStrings(excludedCurrency: Locale.Currency? = nil) {
|
||||
if let btc {
|
||||
let currencies = Set([currentCurrency] + selectedCurrencies)
|
||||
.filter { $0 != excludedCurrency }
|
||||
|
||||
for currency in currencies {
|
||||
if let btcToCurrency = btcToCurrency(for: currency) {
|
||||
currencyValueStrings[currency] = (btc * btcToCurrency).formatString(currency: currency)
|
||||
} else {
|
||||
currencyValueStrings[currency] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
|
||||
func clearCurrencyValueStrings() {
|
||||
for currency in currencyValueStrings.keys {
|
||||
currencyValueStrings[currency] = ""
|
||||
}
|
||||
}
|
||||
|
||||
func currencyValueString(for currency: Locale.Currency) -> Binding<String> {
|
||||
Binding<String>(
|
||||
get: {
|
||||
self.currencyValueStrings[currency, default: ""]
|
||||
},
|
||||
set: { newValue in
|
||||
let oldPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(self.currencyValueStrings[currency] ?? "")
|
||||
let newPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currencyValueStrings[currency] = newPriceWithoutGroupingSeparator
|
||||
|
||||
if let currencyValue = self.currencyValue(for: currency) {
|
||||
if let btcToCurrency = self.currencyPrices[currency] {
|
||||
#if !SKIP
|
||||
let btc = currencyValue / btcToCurrency
|
||||
#else
|
||||
let btc = currencyValue.divide(btcToCurrency, 20, java.math.RoundingMode.DOWN)
|
||||
#endif
|
||||
self.btcStringInternal = btc.formatBTCString()
|
||||
|
||||
let sats = btc * SatsViewModel.SATS_IN_BTC
|
||||
self.satsStringInternal = sats.formatSatsString()
|
||||
|
||||
#if !SKIP
|
||||
// Formatting the internal string after modifying it only if the platform is Apple.
|
||||
// Apple does not seem to call get after set until after focus is moved to a different component.
|
||||
// Android, on the other hand, does call get immediately after set,
|
||||
// which causes text entry issues if the user keeps on entering input.
|
||||
self.updateCurrencyValueStrings(excludedCurrency: nil)
|
||||
#else
|
||||
self.updateCurrencyValueStrings(excludedCurrency: currency)
|
||||
#endif
|
||||
} else {
|
||||
self.satsStringInternal = ""
|
||||
self.btcStringInternal = ""
|
||||
self.clearCurrencyValueStrings()
|
||||
}
|
||||
} else {
|
||||
self.satsStringInternal = ""
|
||||
self.btcStringInternal = ""
|
||||
self.clearCurrencyValueStrings()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func currencyValue(for currency: Locale.Currency) -> Decimal? {
|
||||
guard let currencyValueString = currencyValueStrings[currency] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !SKIP
|
||||
return Decimal(string: priceWithoutGroupingSeparator(currencyValueString))
|
||||
#else
|
||||
do {
|
||||
return Decimal(currencyValueString)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func btcToCurrency(for currency: Locale.Currency) -> Decimal? {
|
||||
currencyPrices[currency]
|
||||
}
|
||||
|
||||
func btcToCurrencyString(for currency: Locale.Currency) -> Binding<String> {
|
||||
Binding<String>(
|
||||
get: {
|
||||
self.currencyPriceStrings[currency, default: ""]
|
||||
},
|
||||
set: { newValue in
|
||||
let oldPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(self.currencyPriceStrings[currency, default: ""])
|
||||
let newPriceWithoutGroupingSeparator = self.priceWithoutGroupingSeparator(newValue)
|
||||
|
||||
guard oldPriceWithoutGroupingSeparator != newPriceWithoutGroupingSeparator else {
|
||||
return
|
||||
}
|
||||
|
||||
self.currencyPriceStrings[currency] = newPriceWithoutGroupingSeparator
|
||||
|
||||
#if !SKIP
|
||||
if let newPrice = Decimal(string: newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
|
||||
self.currencyPrices[currency] = newPrice
|
||||
|
||||
// Formatting the internal string after modifying it only if the platform is Apple.
|
||||
// Apple does not seem to call get after set until after focus is moved to a different
|
||||
// component. Android, on the other hand, does call get immediately after set,
|
||||
// which causes text entry issues if the user keeps on entering input.
|
||||
self.currencyPriceStrings[currency] = newPrice.formatString(currency: currency)
|
||||
|
||||
if let btc = self.btc {
|
||||
self.currencyValueStrings[currency] = (btc * newPrice).formatString(currency: currency)
|
||||
} else {
|
||||
self.currencyValueStrings[currency] = ""
|
||||
}
|
||||
}
|
||||
#else
|
||||
do {
|
||||
if let newPrice = Decimal(newPriceWithoutGroupingSeparator), self.currencyPrices[currency] != newPrice {
|
||||
self.currencyPrices[currency] = newPrice
|
||||
|
||||
if let btc = self.btc {
|
||||
self.currencyValueStrings[currency] = (btc * newPrice).formatString()
|
||||
} else {
|
||||
self.currencyValueStrings[currency] = ""
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.currencyPrices.removeValue(forKey: currency)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var sats: Decimal? {
|
||||
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(satsStringInternal)
|
||||
#if !SKIP
|
||||
return Decimal(string: priceWithoutGroupingSeparator)
|
||||
#else
|
||||
do {
|
||||
return Decimal(priceWithoutGroupingSeparator)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var btc: Decimal? {
|
||||
let priceWithoutGroupingSeparator = priceWithoutGroupingSeparator(btcStringInternal)
|
||||
#if !SKIP
|
||||
return Decimal(string: priceWithoutGroupingSeparator)
|
||||
#else
|
||||
do {
|
||||
return Decimal(priceWithoutGroupingSeparator)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var exceedsMaximum: Bool {
|
||||
if let btc, btc > SatsViewModel.MAXIMUM_BTC {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension Decimal {
|
||||
func formatString() -> String {
|
||||
#if !SKIP
|
||||
return String(describing: self)
|
||||
#else
|
||||
return stripTrailingZeros().toPlainString()
|
||||
#endif
|
||||
}
|
||||
|
||||
func formatString(minimumFractionDigits: Int, maximumFractionDigits: Int, usesGroupingSeparator: Bool) -> String {
|
||||
let numberFormatter = NumberFormatter()
|
||||
numberFormatter.numberStyle = .decimal
|
||||
numberFormatter.minimumFractionDigits = minimumFractionDigits
|
||||
numberFormatter.maximumFractionDigits = maximumFractionDigits
|
||||
numberFormatter.usesGroupingSeparator = usesGroupingSeparator
|
||||
#if !SKIP
|
||||
return numberFormatter.string(from: NSDecimalNumber(decimal: self)) ?? String(describing: self)
|
||||
#else
|
||||
return numberFormatter.string(from: android.icu.math.BigDecimal(self as java.math.BigDecimal) as NSNumber) ?? stripTrailingZeros().toPlainString()
|
||||
#endif
|
||||
}
|
||||
|
||||
func formatSatsString() -> String {
|
||||
formatString(minimumFractionDigits: 0, maximumFractionDigits: 0, usesGroupingSeparator: true)
|
||||
}
|
||||
|
||||
func formatBTCString() -> String {
|
||||
formatString(minimumFractionDigits: 0, maximumFractionDigits: 8, usesGroupingSeparator: true)
|
||||
}
|
||||
|
||||
func formatString(currency: Locale.Currency) -> String {
|
||||
#if !SKIP
|
||||
let currencyFormatter = NumberFormatter()
|
||||
currencyFormatter.numberStyle = .currency
|
||||
currencyFormatter.currencyCode = currency.identifier
|
||||
|
||||
return formatString(
|
||||
minimumFractionDigits: currencyFormatter.minimumFractionDigits,
|
||||
maximumFractionDigits: currencyFormatter.maximumFractionDigits,
|
||||
usesGroupingSeparator: currencyFormatter.usesGroupingSeparator
|
||||
)
|
||||
#else
|
||||
let javaCurrency = java.util.Currency.getInstance(currency.identifier)
|
||||
return formatString(
|
||||
minimumFractionDigits: javaCurrency.getDefaultFractionDigits(),
|
||||
maximumFractionDigits: javaCurrency.getDefaultFractionDigits(),
|
||||
usesGroupingSeparator: true
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension Character {
|
||||
var isDigit: Bool {
|
||||
self >= "0" && self <= "9"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Configuration file for https://skip.tools project
|
||||
build:
|
||||
contents:
|
||||
@@ -1,105 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
//
|
||||
// SatsViewModelTests.swift
|
||||
// SatsPriceTests
|
||||
//
|
||||
// Created by Terry Yiu on 2/19/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import SatsPrice
|
||||
|
||||
final class SatsViewModelTests: XCTestCase {
|
||||
|
||||
let currency = Locale.Currency("USD")
|
||||
|
||||
func testSatsViewModel() throws {
|
||||
let satsPriceModel = try XCTUnwrap(SatsPriceModel(url: nil))
|
||||
let satsViewModel = SatsViewModel(model: satsPriceModel)
|
||||
satsViewModel.btcToCurrencyString(for: currency).wrappedValue = "54321"
|
||||
|
||||
// Test BTC updates.
|
||||
satsViewModel.btcString = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "54321"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("1"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("100000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("54321"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "1")
|
||||
XCTAssertEqual(satsViewModel.satsString, "100,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "54,321.00")
|
||||
|
||||
// Test Sats updates.
|
||||
satsViewModel.satsString = "200000000"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "108642"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("2"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("200000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("108642"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "2")
|
||||
XCTAssertEqual(satsViewModel.satsString, "200,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "108,642.00")
|
||||
|
||||
// Test currency value updates.
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "162963"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "162963"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("3"))
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("300000000"))
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("162963"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.btcString, "3")
|
||||
XCTAssertEqual(satsViewModel.satsString, "300,000,000")
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "162,963.00")
|
||||
|
||||
// Test fractional amounts.
|
||||
// Precision between platforms on this calculation is different so we have different assertions for each.
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "1"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "0.00001841"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001841")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "1841"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1,841")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "1"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("0.00001841"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "0.00001841")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("1841"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "1,841")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal("1"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "1.00")
|
||||
|
||||
// Test large amounts that exceed the cap of 21M BTC.
|
||||
satsViewModel.currencyValueString(for: currency).wrappedValue = "11407419999999"
|
||||
#if !SKIP
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal(string: "210000184.09084884"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210,000,184.09084884")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal(string: "21000018409084884"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21,000,018,409,084,884")
|
||||
XCTAssertEqual(satsViewModel.currencyValue(for: currency), Decimal(string: "11407419999999"))
|
||||
#else
|
||||
XCTAssertEqual(satsViewModel.btc, Decimal("210000184.09084884"))
|
||||
XCTAssertEqual(satsViewModel.btcString, "210000184.09084884")
|
||||
XCTAssertEqual(satsViewModel.sats, Decimal("21000018409084884"))
|
||||
XCTAssertEqual(satsViewModel.satsString, "21000018409084884")
|
||||
XCTAssertEqual(satsViewModel.currencyValue, Decimal("11407419999999"))
|
||||
#endif
|
||||
XCTAssertEqual(satsViewModel.currencyValueString(for: currency).wrappedValue, "11,407,419,999,999.00")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Configuration file for https://skip.tools project
|
||||
#build:
|
||||
# contents:
|
||||
@@ -1,32 +0,0 @@
|
||||
// This is free software: you can redistribute and/or modify it
|
||||
// under the terms of the GNU General Public License 3.0
|
||||
// as published by the Free Software Foundation https://fsf.org
|
||||
|
||||
import Foundation
|
||||
#if os(macOS) // Skip transpiled tests only run on macOS targets
|
||||
import SkipTest
|
||||
|
||||
/// This test case will run the transpiled tests for the Skip module.
|
||||
@available(macOS 13, macCatalyst 16, *)
|
||||
final class XCSkipTests: XCTestCase, XCGradleHarness {
|
||||
public func testSkipModule() async throws {
|
||||
// Run the transpiled JUnit tests for the current test module.
|
||||
// These tests will be executed locally using Robolectric.
|
||||
// Connected device or emulator tests can be run by setting the
|
||||
// `ANDROID_SERIAL` environment variable to an `adb devices`
|
||||
// ID in the scheme's Run settings.
|
||||
//
|
||||
// Note that it isn't currently possible to filter the tests to run.
|
||||
try await runGradleTests()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// True when running in a transpiled Java runtime environment
|
||||
let isJava = ProcessInfo.processInfo.environment["java.io.tmpdir"] != nil
|
||||
/// True when running within an Android environment (either an emulator or device)
|
||||
let isAndroid = isJava && ProcessInfo.processInfo.environment["ANDROID_ROOT"] != nil
|
||||
/// True is the transpiled code is currently running in the local Robolectric test environment
|
||||
let isRobolectric = isJava && !isAndroid
|
||||
/// True if the system's `Int` type is 32-bit.
|
||||
let is32BitInteger = Int64(Int.max) == Int64(Int32.max)
|
||||
@@ -0,0 +1,54 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_11
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
implementation(project(":shared"))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "xyz.tyiu.satsprice"
|
||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "xyz.tyiu.SatsPrice"
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 11
|
||||
versionName = "2.0.0"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,25 @@
|
||||
package xyz.tyiu.satsprice
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
App()
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |